One adaptive tone palette and one shape rule now drive every capsule on Stations, Favourites, the detail sheet and the Key. - CapsuleTone: the light/dark palette pairs in one place, resolved through a UIColor dynamic provider so a List row that re-renders without rebuilding its body still follows an appearance switch. Every pair clears 4.5:1 foreground-on-background in both appearances (4.96-6.72:1). - AdaptiveCapsule: capsule at one drawn line, rounded rect at two or more, radius 8 -> 10, measuring content before padding so insets cannot inflate the line count. - Listing verdict capsules move from white-on-muted-solid to the tinted palette, so Saves, Break even, Costs and Closest station read the same in the rows and in the detail sheet. - Best Value is purple, not green: green stays reserved for a financial saving, which is what the Saves capsule beside it means. - Price dots standardise to 12pt in the app (widget keeps its own 6pt dot; PriceRatingDot now takes a diameter so the difference is deliberate). - Station labels are sentence case with their recommended symbols (medal, tag, location.north.fill, trophy) and StationLabelKind replaces the Key's string-literal dispatch, so renaming a label can no longer drop a row. - Key says "Costs", matching the capsule's own headline (was "Costs more").
156 lines
6.3 KiB
Swift
156 lines
6.3 KiB
Swift
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<Content: View>: 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
|
|
)
|
|
}
|
|
}
|