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 closestStation = CapsuleTone( lightForeground: Color(hex: 0x525967), lightBackground: Color(hex: 0xE8EAF0), darkForeground: Color(hex: 0xC0C3CC), darkBackground: Color(hex: 0x424349) ) /// Blue, like `cheapest`: the top favourite IS the cheapest favourite, so it /// must not read as a different class of thing. static let top = CapsuleTone( lightForeground: Color(hex: 0x225F9B), lightBackground: Color(hex: 0xE0EFFB), darkForeground: Color(hex: 0x8BC5FA), darkBackground: Color(hex: 0x293F53) ) } /// 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 — "Closest station" is the case that /// forced this: one declared line that wraps to two in the listing's price /// column. /// /// 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 ) } }