The More info sheet now lists every ranking a station qualifies for, directly under the map and above the price and distance: the same StationLabelBadge the listing rows render, in a flow layout so two or three capsules wrap to a new line rather than truncating at large Dynamic Type. The rules are not restated. StationRanking defers to StationRecommendationBadge.badge for the Best Value gate, and takes Cheapest and Top as the flags the host list already computed for its own rows, so the sheet cannot award a capsule the row behind it withheld. A host passes one of the two, never both: Stations sends isCheapestInRange, Favourites sends isTopFavourite. No calculation, ranking, eligibility or savings logic changed. Rename the card heading Best Value to Value breakdown, since the working behind it can end in Costs rather than a saving, and drop the green section tint so the card matches the neutral background used elsewhere in the sheet. The Saves, Break even and Costs capsules and every breakdown row are untouched. Rename the explanation link to How does FuelBoard calculate value? It names the calculation rather than the feature, of which the Best Value gate is only the last step. The sheet's own title stays short: the full question truncated to 'How does FuelBoard...' as a navigation title at both inline and large display modes, so the row carries the question and the sheet names the topic. Tests: 269 pass, 5 new covering capsule selection, order and sheet-vs-row agreement on Best Value.
254 lines
10 KiB
Swift
254 lines
10 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
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 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<Int>
|
|
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..<index, width: used, height: height))
|
|
start = index
|
|
used = size.width
|
|
height = size.height
|
|
} else {
|
|
used = needed
|
|
height = max(height, size.height)
|
|
}
|
|
}
|
|
if start < subviews.count {
|
|
rows.append(Row(range: start..<subviews.count, width: used, height: height))
|
|
}
|
|
return rows
|
|
}
|
|
}
|
|
|
|
/// The More info sheet's row of ranking capsules: every label the station
|
|
/// qualifies for, wrapped when the screen is narrow.
|
|
///
|
|
/// Renders as the SAME `StationLabelBadge` the listing rows use — same tone, same
|
|
/// symbol, same sentence-case copy — so a station carrying "Best value" here and
|
|
/// on the Stations list cannot look like two different things.
|
|
struct StationRankingCapsules: View {
|
|
let kinds: [StationLabelKind]
|
|
|
|
var body: some View {
|
|
CapsuleFlowLayout(spacing: 6, lineSpacing: 6) {
|
|
ForEach(kinds, id: \.self) { kind in
|
|
StationLabelBadge(kind: kind)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
// One phrase per capsule, read in the order they are shown.
|
|
.accessibilityElement(children: .contain)
|
|
}
|
|
}
|