import SwiftUI /// The More info sheet's facts row: the selected fuel's price on the left, the /// distance on the right, separated by a hairline down the middle. /// /// Every figure comes from the sheet's existing sources — the price through /// `FuelStore.priceText` and the distance through /// `FuelStore.stationDistanceDisplay(...)` — so this row can never disagree with /// the listing row, the widget or the Live Activity, and both columns keep the /// shared "Approx." confidence marking. struct StationFactsRow: View { let station: FuelStation let selectedFuel: FuelType let priceAge: FuelPriceFreshness.Assessment let distance: StationDistanceDisplay? let distanceUnit: DistanceUnit /// The user's price display style, from the same Settings choice the listing /// rows and the widgets read. var priceStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle() /// The price's point size, scaled with Dynamic Type: the price is built from /// an explicit size (its "/L" suffix is sized relative to it inside /// `priceTextAttributed`), so it needs a metric rather than a font style. /// 22pt matches the listing row's price. @ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22 var body: some View { HStack(alignment: .top, spacing: 12) { fuelColumn StationFactsDivider() distanceColumn } .padding(.vertical, 2) } private var fuelColumn: some View { VStack(alignment: .leading, spacing: 3) { Text(selectedFuel.displayName) .font(.caption) .foregroundStyle(.secondary) priceView StationPriceAgeView(assessment: priceAge, compact: true) } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } private var distanceColumn: some View { VStack(alignment: .leading, spacing: 3) { Text("Distance") .font(.caption) .foregroundStyle(.secondary) Text(distanceText) .font(.title2.weight(.bold)) .monospacedDigit() Text(returnText) .font(.caption2) .foregroundStyle(.secondary) .monospacedDigit() } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } /// The selected grade's price, in the user's chosen display style — the same /// `priceTextAttributed` the listing row uses, so the sheet prints the bare /// forecourt figure ("167.9") in Station sign and the superscripted /// "£1.67⁹/L" in Pounds & pence, and the two surfaces cannot disagree. /// The returned Text carries its own fonts, so no `.font(...)` goes on top. /// /// A missing grade shows an em dash rather than hiding the column, so the row /// keeps its two-column shape. @ViewBuilder private var priceView: some View { if let price = station.prices[selectedFuel] { FuelStore.priceTextAttributed(price, style: priceStyle, size: priceSize, weight: .bold) } else { Text("—") .font(.title2.weight(.bold)) .monospacedDigit() } } /// One-way distance, with its confidence marker, from the shared display. private var distanceText: String { distance?.label(unit: distanceUnit) ?? "—" } /// Out-and-back distance, same source and same marker rule. private var returnText: String { distance?.returnLabel(unit: distanceUnit) ?? "" } } /// A one-pixel vertical hairline between the facts row's two columns, drawn in /// the List's own separator colour so it matches every other line on the sheet. /// Decorative: VoiceOver reads each column as one phrase instead. private struct StationFactsDivider: View { var body: some View { Rectangle() .fill(Color(.separator)) .frame(width: 0.5) .frame(maxHeight: .infinity) .accessibilityHidden(true) } } /// The More info sheet's Best Value card: the verdict, then the figures it is /// built from. /// /// Nothing is recomputed here — every number comes from the shared /// `FuelSavingsResult`, the verdict capsule is the same `FuelSavingsCapsule` the /// listing rows use, and the total is the shared `headline()`. Wording and /// colour therefore cannot drift between the sheet and the list. struct BestValueDetailCard: View { let availability: FuelSavingsAvailability let amount: FuelSavingsFillAmount let baselineStationName: String? let distanceUnit: DistanceUnit var body: some View { switch availability { case .available(let result): availableBody(result) case .unavailable(let reason): Text("Best Value unavailable: \(reason)") .font(.caption) .foregroundStyle(.secondary) } } private func availableBody(_ result: FuelSavingsResult) -> some View { VStack(alignment: .leading, spacing: 14) { header(result) panels(result) workings(result) } } /// Card title on the left, the verdict on the right — ONE capsule, in the /// same large form the sheet used to repeat on its own line above the /// working. The comparison caption sits UNDER the capsule, trailing-aligned /// with it, the same way the listing rows lay the pair out. private func header(_ result: FuelSavingsResult) -> some View { HStack(alignment: .firstTextBaseline, spacing: 10) { Text("Best Value") .font(.headline) Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 4) { FuelSavingsCapsule(result: result, compact: false) .accessibilityLabel(result.spokenHeadline()) comparison(result) } } } /// The compact comparison caption ("vs closest station" / "your baseline"), /// under the capsule it belongs to. private func comparison(_ result: FuelSavingsResult) -> some View { Text(FuelSavingsFormat.comparisonLabel(status: result.status)) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.trailing) } /// The two figures the estimate is made OF, side by side: what the user /// spends, and how much fuel that buys at this station. private func panels(_ result: FuelSavingsResult) -> some View { HStack(spacing: 10) { panel(symbol: "fuelpump.fill", title: "Your fill-up", value: Self.amountText(amount)) panel(symbol: "drop.fill", title: "Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres)) } } private func panel(symbol: String, title: String, value: String) -> some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 5) { Image(systemName: symbol) .font(.caption) .foregroundStyle(.secondary) .accessibilityHidden(true) Text(title) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.8) } Text(value) .font(.title3.weight(.semibold)) .monospacedDigit() .lineLimit(1) .minimumScaleFactor(0.8) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 10) .background(RoundedRectangle(cornerRadius: 10).fill(Color.primary.opacity(0.06))) .accessibilityElement(children: .combine) } /// The working: what the extra travel adds, what the pump saves, and the /// total. A baseline station has nothing to save against itself, so it stops /// after the panels — the capsule above already says "Closest station". @ViewBuilder private func workings(_ result: FuelSavingsResult) -> some View { if result.status != .baseline { Divider() extraTravelRow(result) savingRows(result) Divider() totalRow(result) if let baselineStationName { Text("Compared with \(baselineStationName).") .font(.caption) .foregroundStyle(.secondary) } } if result.journeyConfidence.isEstimated { Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.") .font(.caption2) .foregroundStyle(.secondary) } } /// The extra driving a saving has to beat, named in the row label and /// measured in the value — same figure and same "Approx." rule as the shared /// caption, from the shared formatter. @ViewBuilder private func extraTravelRow(_ result: FuelSavingsResult) -> some View { if let extraTravel = FuelSavingsFormat.extraTravelDistanceLabel( extraDistanceMiles: result.extraDistanceMiles, unit: distanceUnit, confidence: result.journeyConfidence ) { LabeledContent("Extra return travel", value: extraTravel) .monospacedDigit() } } @ViewBuilder private func savingRows(_ result: FuelSavingsResult) -> some View { LabeledContent( result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference", value: result.grossSavingGBP >= 0 ? FuelSavingsFormat.money(result.grossSavingGBP) : "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more" ) .monospacedDigit() LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP)) .monospacedDigit() } /// The Total row is the WORKINGS; the capsule above is the quick verdict. /// Both take their wording and colour from the shared result. private func totalRow(_ result: FuelSavingsResult) -> some View { LabeledContent { Text(result.headline()) .fontWeight(.semibold) .foregroundStyle(FuelSavingsCapsule.color(for: result.status)) .monospacedDigit() } label: { Text("Total") .fontWeight(.semibold) } } /// "£60" or "60 litres" — the same strings the sheet printed before the /// redesign, so the fill-up reads identically in both units. static func amountText(_ amount: FuelSavingsFillAmount) -> String { switch amount.unit { case .pounds: return String(format: "£%.0f", amount.value) case .litres: return String(format: "%.0f litres", amount.value) } } } /// "How Best Value works" lives in the shared `HowBestValueRow` /// (HowBestValueSheet.swift) — the same row the Key sheet uses, so the sheet and /// the Key cannot drift.