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 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) Text(priceText) .font(.title2.weight(.bold)) .monospacedDigit() 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, exactly as the listing row prints it. A /// missing grade shows an em dash rather than hiding the column, so the row /// keeps its two-column shape. private var priceText: String { guard let price = station.prices[selectedFuel] else { return "—" } return FuelStore.priceText(price, style: .stationSign) } /// 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) verdict(result) panels(result) workings(result) } } /// Card title on the left, the verdict badge on the right — the same capsule /// the listing rows carry, so the badge and the large capsule below it can /// never disagree. private func header(_ result: FuelSavingsResult) -> some View { HStack(alignment: .firstTextBaseline, spacing: 10) { Text("Best Value") .font(.headline) Spacer(minLength: 8) FuelSavingsCapsule(result: result) } } /// The verdict capsule keeps its own line above the working, as before. private func verdict(_ result: FuelSavingsResult) -> some View { VStack(alignment: .leading, spacing: 4) { FuelSavingsCapsule(result: result, compact: false) .accessibilityLabel(result.spokenHeadline()) Text(FuelSavingsFormat.comparisonLabel(status: result.status)) .font(.subheadline) .foregroundStyle(.secondary) } } /// 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.