import SwiftUI struct FuelSavingsAdjustmentView: View { @Binding var amount: FuelSavingsFillAmount @State private var unitChangeNotice: String? private var range: ClosedRange { FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit) } private var step: Double { FuelSavingsCalculator.fillAmountSliderStep(for: amount.unit) } /// Read-out of the figure the slider is on. Deliberately NOT a text field: /// a typed figure could sit outside the slider's own bounds, leaving the /// control and the value it claimed to show disagreeing. Slider-only means /// what you see is the figure the estimate actually uses. private var valueText: String { switch amount.unit { case .pounds: return String(format: "£%.0f", amount.value) case .litres: return String(format: "%.0f L", amount.value) } } var body: some View { VStack(alignment: .leading, spacing: 10) { Text("How much are you filling up?") .font(.subheadline.weight(.semibold)) Picker("Fill-up unit", selection: unitBinding) { ForEach(FuelSavingsFillUnit.allCases) { unit in Text(unit.displayName).tag(unit) } } .pickerStyle(.segmented) HStack { Slider(value: sliderBinding, in: range, step: step) .accessibilityLabel("Fill-up amount") .accessibilityValue(valueText) Text(valueText) .monospacedDigit() .frame(width: 72, alignment: .trailing) } Text("Changes the estimate for this session only — your Settings default stays unchanged.") .font(.caption2) .foregroundStyle(.secondary) Text("£ compares the litres your budget buys at each station; Litres compares one fixed quantity.") .font(.caption2) .foregroundStyle(.secondary) if let unitChangeNotice { Text(unitChangeNotice) .font(.caption2) .foregroundStyle(.orange) } } } private var unitBinding: Binding { Binding( get: { amount.unit }, set: { newUnit in guard newUnit != amount.unit else { return } // No one litre figure equals a budget at every candidate price. // A unit change starts a new, explicitly labelled comparison. amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit) unitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up." } ) } private var sliderBinding: Binding { Binding( get: { min(max(amount.value, range.lowerBound), range.upperBound) }, set: { amount.value = min(max($0, range.lowerBound), range.upperBound) } ) } } struct FuelSavingsSummaryView: View { let availability: FuelSavingsAvailability? let amount: FuelSavingsFillAmount var baselineStationName: String? = nil var compact: Bool = false /// Sizes the "extra return travel" caption in the user's own unit (the /// stored figure is miles) — used by both the row banner and the detail sheet. var distanceUnit: DistanceUnit = .miles var showInfoAction: (() -> Void)? = nil private func comparisonLabel(for result: FuelSavingsResult) -> String { FuelSavingsFormat.comparisonLabel(status: result.status) } var body: some View { switch availability { case .available(let result): if compact { // FULL-WIDTH banner under the row. Inline inside the middle // column the capsule truncated ("Approx. £2.75 better off after // tra…") because // the column is squeezed by the price block — the verdict is the // point of this sort mode, so it gets its own line with room. // Colour carries the verdict: green save, red worse off, grey // break even, blue baseline. lineLimit + minimumScaleFactor keep // it to one line, so rows stay even. VStack(alignment: .leading, spacing: 2) { // The capsule gets the WHOLE line and the comparison caption // sits beneath it. Side by side, the caption's layoutPriority // squeezed the verdict until it truncated ("Approx. £2.75 // better off after tra…"), and in this sort mode the verdict // is the point of the row. capsule(for: result, compact: true) .accessibilityLabel(accessibilityHeadline(for: result)) Text(comparisonLabel(for: result)) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.85) .accessibilityLabel("Comparison: \(comparisonLabel(for: result))") if let extraTravel = FuelSavingsFormat.extraTravelLabel( extraDistanceMiles: result.extraDistanceMiles, unit: distanceUnit, confidence: result.journeyConfidence ) { Text(extraTravel) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.85) .accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel)) } } // Aligns the banner with the row's text column (46 pt logo + 12 // pt spacing) so it reads as part of the row, not the cell. .padding(.leading, 58) } else { // Detail sheet: the SAME verdict capsule and the SAME caption as // the listing rows (one implementation, so wording and colour // cannot drift), followed by the breakdown only this view has // room for. The old shape led with a status word + headline that // matched neither the listing nor each other. VStack(alignment: .leading, spacing: 10) { // Same shape as the listing rows: the capsule takes the line // and the comparison caption sits directly beneath it, so the // verdict never competes for width with its own caption. VStack(alignment: .leading, spacing: 4) { capsule(for: result, compact: false) .accessibilityLabel(accessibilityHeadline(for: result)) Text(comparisonLabel(for: result)) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.85) .accessibilityLabel("Comparison: \(comparisonLabel(for: result))") } LabeledContent("Your fill-up", value: amountText) LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres)) .monospacedDigit() if amount.unit == .pounds { Text("You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund.") .font(.caption2) .foregroundStyle(.secondary) } if let extraTravel = FuelSavingsFormat.extraTravelLabel( extraDistanceMiles: result.extraDistanceMiles, unit: distanceUnit, confidence: result.journeyConfidence ) { Text(extraTravel) .font(.subheadline) .foregroundStyle(.secondary) .accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel)) } if result.status == .baseline { Text("Your closest station by journey distance is the baseline — every other saving is measured against it.") .font(.caption) .foregroundStyle(.secondary) } else { Text(baselineStationName.map { "Compared with \($0)." } ?? "Compared with your closest station.") .font(.caption) .foregroundStyle(.secondary) 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" ) LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP)) Divider() LabeledContent { Text(result.headline()) .fontWeight(.semibold) .foregroundStyle(capsuleColor(for: result.status)) .monospacedDigit() } label: { Text("Total") .fontWeight(.semibold) } if result.journeyConfidence.isEstimated { Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.") .font(.caption2) .foregroundStyle(.secondary) } Text("Estimate covers fuel cost only. It excludes time, parking, tolls and wear.") .font(.caption2) .foregroundStyle(.secondary) } if let showInfoAction { Rectangle() .fill(Color(.separator)) .frame(height: 0.5) .padding(.vertical, 4) Button(action: showInfoAction) { HStack(spacing: 8) { Text("How Best Value works") Spacer(minLength: 8) Image(systemName: "chevron.forward") .font(.footnote.weight(.semibold)) .foregroundStyle(.tertiary) } } .buttonStyle(.plain) } } } case .unavailable(let reason): Text(compact ? "Savings unavailable" : "Best Value unavailable: \(reason)") .font(compact ? .caption2 : .caption) .foregroundStyle(.secondary) case nil: EmptyView() } } private var amountText: String { switch amount.unit { case .pounds: return String(format: "£%.0f", amount.value) case .litres: return String(format: "%.0f litres", amount.value) } } /// VoiceOver form of the extra-travel caption ("Approximately 6.4 miles extra /// return travel"): the abbreviation is spelled out and the unit is a word. /// Falls back to the visible string if no spoken form exists. private func spokenExtraTravel(for result: FuelSavingsResult, fallback: String) -> String { FuelSavingsFormat.extraTravelSpokenLabel( extraDistanceMiles: result.extraDistanceMiles, unit: distanceUnit, confidence: result.journeyConfidence ) ?? fallback } /// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for /// and leaves the plain words ("Break even", "Closest station") alone — /// one source, so visible and spoken copy cannot drift. private func accessibilityHeadline(for result: FuelSavingsResult) -> String { result.spokenHeadline() } /// The colour-coded verdict capsule lives in `FuelSavingsCapsule` — ONE /// implementation for the listing rows, the detail sheet and this compact /// summary, so the verdict reads identically everywhere; the sheet just /// gets the larger font and padding. private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View { FuelSavingsCapsule(result: result, compact: compact) } /// Verdict colour lives in `FuelSavingsCapsule.color(for:)`. private func capsuleColor(for status: FuelSavingsStatus) -> Color { FuelSavingsCapsule.color(for: status) } private func symbol(for status: FuelSavingsStatus) -> String { FuelSavingsCapsule.symbol(for: status) } } /// The colour-coded verdict capsule shared by the station listing rows, the /// detail sheet and the compact summary. /// /// An HStack with an explicit spacing, NOT a Label: given the width to spare, /// a Label lays its icon and title out across the row and leaves a visible gap /// between them (22pt in the listing row), which read as the text having /// drifted away from its symbol. The icon is decorative here — the verdict is /// in the text — so VoiceOver reads the capsule as one phrase. struct FuelSavingsCapsule: View { let result: FuelSavingsResult var compact: Bool = true var body: some View { HStack(spacing: compact ? 3 : 5) { Image(systemName: Self.symbol(for: result.status)) .accessibilityHidden(true) Text(result.capsuleHeadline()) .lineLimit(1) // Measured on a 393pt screen: the capsule is handed ~101pt, of // which the icon, its spacing and the 8pt padding either side // take 32pt, leaving ~69pt for the text. "Closest station" needs // ~90pt at full size, so a 0.8 floor — 72pt — could not reach it // and the row showed "Closest stati…" instead. 0.7 leaves ~63pt, // which clears it at ~10pt type: scaled, never truncated. .minimumScaleFactor(0.7) } .font(compact ? .caption.weight(.semibold) : .headline) .foregroundStyle(Self.color(for: result.status)) .padding(.horizontal, compact ? 8 : 12) .padding(.vertical, compact ? 4 : 7) .background(Capsule().fill(Self.color(for: result.status).opacity(0.15))) } /// Verdict colour: green save, red worse off, grey break even, blue /// baseline — the same green/amber/red family the price dot already uses. static func color(for status: FuelSavingsStatus) -> Color { switch status { case .worthTheDrive: return .green case .notWorthTheDrive: return .red case .breakEven: return .secondary case .baseline: return .blue } } static func symbol(for status: FuelSavingsStatus) -> String { switch status { case .worthTheDrive: return "checkmark.circle.fill" case .breakEven: return "equal.circle.fill" case .notWorthTheDrive: return "xmark.circle.fill" case .baseline: return "location.circle.fill" } } }