import SwiftUI struct FuelSavingsAdjustmentView: View { @Binding var amount: FuelSavingsFillAmount let referencePricePencePerLitre: Double? @State private var textValue: String = "" private var range: ClosedRange { amount.unit == .pounds ? 10...100 : 10...80 } 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: amount.unit == .pounds ? 5 : 1) TextField(amount.unit == .pounds ? "£" : "Litres", text: valueBinding) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .frame(width: 72) .textFieldStyle(.roundedBorder) } Text("Changes the estimate for this session only — your Settings default stays unchanged.") .font(.caption2) .foregroundStyle(.secondary) } .onAppear { syncText() } .onChange(of: amount.value) { _, _ in syncText() } .onChange(of: amount.unit) { _, _ in syncText() } } private var unitBinding: Binding { Binding( get: { amount.unit }, set: { newUnit in guard newUnit != amount.unit else { return } if let referencePricePencePerLitre, let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) { amount = converted } else { amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: newUnit) } syncText() } ) } private var sliderBinding: Binding { Binding( get: { min(max(amount.value, range.lowerBound), range.upperBound) }, set: { amount.value = min(max($0, range.lowerBound), range.upperBound) } ) } private var valueBinding: Binding { Binding( get: { textValue }, set: { raw in textValue = raw if let value = Double(raw), value > 0 { amount.value = min(value, FuelSavingsCalculator.maximumFillAmount(for: amount.unit)) } } ) } private func syncText() { let value = amount.value textValue = value.rounded() == value ? String(format: "%.0f", value) : String(format: "%.1f", value) } } struct FuelSavingsSummaryView: View { let availability: FuelSavingsAvailability? let amount: FuelSavingsFillAmount let baselineStationName: String? var compact: Bool = false var body: some View { switch availability { case .available(let result): VStack(alignment: .leading, spacing: compact ? 2 : 8) { Label(result.status.displayName, systemImage: symbol(for: result.status)) .font(compact ? .caption.weight(.semibold) : .headline) Text(result.headline(compact: compact)) .font(compact ? .caption2.bold().monospacedDigit() : .subheadline.bold().monospacedDigit()) if !compact { if result.status == .baseline { Text("Your closest station with a price for this fuel — every other saving is measured against it.") .font(.caption) .foregroundStyle(.secondary) } else { Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary) LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP)) LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP)) LabeledContent("Extra distance", value: String(format: "%.1f miles", result.extraDistanceMiles)) if result.distanceBasis.isEstimate { Text("Estimated from straight-line distance — refined once the route is measured.") .font(.caption2) .foregroundStyle(.secondary) } Text("Estimate covers fuel cost only. It excludes time, parking, tolls and wear.") .font(.caption2) .foregroundStyle(.secondary) } } } case .unavailable(let reason): Text(compact ? "Savings unavailable" : "Worth the Drive 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) } } private 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" } } }