import SwiftUI /// One semantic capsule tone: a foreground and a background for each appearance. /// /// The palette is deliberately a TINTED pair rather than a saturated solid. Every /// tone below clears 4.5:1 for its own foreground on its own background in BOTH /// appearances (measured, 4.96:1 to 6.72:1), which is what lets one component /// carry the same meaning on a white row and a dark card without a second /// colour per status. /// /// The pairs are resolved through `UIColor`'s dynamic provider rather than /// SwiftUI's `@Environment(\.colorScheme)`: a capsule inside a `List` is often /// re-rendered without its body being rebuilt, so a scheme read at body time can /// lag one switch behind. This way the colour itself follows the appearance. struct CapsuleTone: Equatable { let lightForeground: Color let lightBackground: Color let darkForeground: Color let darkBackground: Color var foreground: Color { dynamic(light: lightForeground, dark: darkForeground) } var background: Color { dynamic(light: lightBackground, dark: darkBackground) } private func dynamic(light: Color, dark: Color) -> Color { let lightUI = UIColor(light) let darkUI = UIColor(dark) return Color(UIColor { traits in traits.userInterfaceStyle == .dark ? darkUI : lightUI }) } } extension CapsuleTone { /// Green: a financial saving. static let saves = CapsuleTone( lightForeground: Color(hex: 0x176B3D), lightBackground: Color(hex: 0xDDF3E5), darkForeground: Color(hex: 0x66D69A), darkBackground: Color(hex: 0x204333) ) /// Grey: a neutral outcome, neither saving nor costing. static let breakEven = CapsuleTone( lightForeground: Color(hex: 0x555B64), lightBackground: Color(hex: 0xE9EAED), darkForeground: Color(hex: 0xD0D0D5), darkBackground: Color(hex: 0x404044) ) /// Red: an additional cost. static let costs = CapsuleTone( lightForeground: Color(hex: 0xA63F3D), lightBackground: Color(hex: 0xF9E1DF), darkForeground: Color(hex: 0xEF9690), darkBackground: Color(hex: 0x4B3032) ) /// Purple: best overall value after travel. static let bestValue = CapsuleTone( lightForeground: Color(hex: 0x7043A1), lightBackground: Color(hex: 0xEEE4F8), darkForeground: Color(hex: 0xD1B4FA), darkBackground: Color(hex: 0x44354F) ) /// Blue: the lowest fuel price in range. static let cheapest = CapsuleTone( lightForeground: Color(hex: 0x225F9B), lightBackground: Color(hex: 0xE0EFFB), darkForeground: Color(hex: 0x8BC5FA), darkBackground: Color(hex: 0x293F53) ) /// Neutral grey: proximity. Same family as `breakEven` — both mean "no /// judgement" — but kept as its own pair so proximity and a break-even /// verdict stay independently tunable. static let closest = CapsuleTone( lightForeground: Color(hex: 0x525967), lightBackground: Color(hex: 0xE8EAF0), darkForeground: Color(hex: 0xC0C3CC), darkBackground: Color(hex: 0x424349) ) } /// The one shape rule every capsule in the app follows. /// /// A capsule while the content is a single line; a rounded rectangle once it /// wraps. A pill's semicircular ends crowd the text corners at two lines or /// more, while a 10pt radius does not. The shape follows what the content /// actually DREW, not how many lines it declared, because a label can wrap in a /// narrow column without the data changing — the baseline label was the case that /// forced this: "Closest station" was one declared line that wrapped to two in the /// listing's price column, which is also why that label is now the single word /// "Closest". /// /// Measuring happens on the content BEFORE padding, so the drawn line count is /// never inflated by the capsule's own insets. struct AdaptiveCapsule: View { /// The tone's background fill. The caller resolves it from its own semantics. let fill: Color /// One line's height for the content's font, already scaled with Dynamic /// Type — pass a `@ScaledMetric` so the division below tracks the user's size. let lineHeight: CGFloat /// Lines the content declares, used for the first layout pass before a /// measured height exists. var declaredLineCount: Int = 1 var cornerRadius: CGFloat = 10 var horizontalPadding: CGFloat var verticalPadding: CGFloat @ViewBuilder let content: Content @State private var drawnHeight: CGFloat = 0 private var lineCount: Int { guard drawnHeight > 0, lineHeight > 0 else { return max(1, declaredLineCount) } return max(1, Int((drawnHeight / lineHeight).rounded())) } private var shape: AnyShape { lineCount > 1 ? AnyShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) : AnyShape(Capsule()) } var body: some View { content .background( GeometryReader { proxy in Color.clear.preference(key: CapsuleContentHeightKey.self, value: proxy.size.height) } ) .padding(.horizontal, horizontalPadding) .padding(.vertical, verticalPadding) .background(shape.fill(fill)) .onPreferenceChange(CapsuleContentHeightKey.self) { drawnHeight = $0 } } } /// Carries the drawn height of a capsule's content up to `lineCount`. private struct CapsuleContentHeightKey: PreferenceKey { static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } } extension Color { /// A fixed sRGB colour from a 24-bit literal. The capsule palette is /// appearance-specific and supplied per tone, so these are deliberately NOT /// dynamic — `CapsuleTone` resolves the pair itself. init(hex: UInt32) { self.init( .sRGB, red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255, opacity: 1 ) } } /// Lays its children left to right and wraps to a new line when the next one /// would overflow, so two or three capsules never squeeze or truncate each other /// on a narrow screen or at large Dynamic Type. /// /// A flow layout rather than `ViewThatFits`: a fallback would stack the capsules /// vertically as soon as one did not fit, which reads as three separate labels /// rather than one row of rankings. This keeps them on a line while they fit and /// only moves the overflowing one down. /// /// Children are measured with `.unspecified` width so a long label takes the width /// it needs (the badge sets `fixedSize` horizontally) instead of being clipped to /// the container. struct CapsuleFlowLayout: Layout { var spacing: CGFloat = 6 var lineSpacing: CGFloat = 6 private struct Row { var range: Range var width: CGFloat var height: CGFloat } func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let maxWidth = proposal.width ?? .infinity let rows = rows(maxWidth: maxWidth, subviews: subviews) let width = rows.map(\.width).max() ?? 0 let height = rows.reduce(0) { $0 + $1.height } + CGFloat(max(0, rows.count - 1)) * lineSpacing return CGSize(width: min(width, maxWidth), height: height) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { var y = bounds.minY for row in rows(maxWidth: bounds.width, subviews: subviews) { var x = bounds.minX for index in row.range { let size = subviews[index].sizeThatFits(.unspecified) subviews[index].place( at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: ProposedViewSize(size) ) x += size.width + spacing } y += row.height + lineSpacing } } private func rows(maxWidth: CGFloat, subviews: Subviews) -> [Row] { var rows: [Row] = [] var start = 0 var used: CGFloat = 0 var height: CGFloat = 0 for index in subviews.indices { let size = subviews[index].sizeThatFits(.unspecified) let needed = used == 0 ? size.width : used + spacing + size.width // `used > 0` guards the first child: a single capsule wider than the // container must still be placed (and may wrap its own text) rather // than starting an empty line above itself. if used > 0, needed > maxWidth { rows.append(Row(range: start..