Compare commits

..
Author SHA1 Message Date
FuelBoard Contributor 558f956d8d Update FuelBoard onboarding copy, title formatting, key sheet card wrapping, and best-value button placement 2026-10-14 16:22:31 +01:00
FuelBoard Contributor f04a0fe0f5 Key sheet: gate the How Best Value row on the feature, like the More info row
The More info row went inside the Best Value card's `if let fuelSavings`, but the
Key sheet row was left ungated, so it stayed visible with Best Value switched off.
It is now gated on `fuelSavingsSettings.isConfigured` — exactly the condition
`savingsForStation` uses, i.e. the same gate as the card — so the two rows appear
and disappear together.

Verified on the simulator by toggling Best Value in Settings and scrolling the Key
sheet to its end (four drags, sheet still up and its footer in view each time):
- OFF: the row is absent at every scroll position, and the More info sheet shows
  neither the card nor the row;
- ON: the row is present, and the More info card and its row are both back.
210 tests pass; Release builds.
2026-10-14 15:19:20 +01:00
FuelBoard Contributor f35f605b58 Onboarding welcome: the app icon stands on its own, with no circle behind it
The first onboarding screen showed the app icon inside AnimatedOnboardingIcon's
soft circle — a 10% tinted disc with an 18% ring around it. The wrapper now takes
`showsHalo`, defaulting to true so the other five pages and BestValueIntroView keep
theirs unchanged; the welcome page passes false.

Verified with before/after installs on the simulator:
- the icon's artwork is pixel-identical and unmoved (centre 603,661, 95 pt);
- the halo is gone — the tile's non-background footprint falls from the 118 pt
  ring to exactly the 96 pt tile, and the ring pixels at r=59 pt lose their tint;
- page 1 differs only by the status-bar clock, so the other pages are untouched.

The copy below the icon tightens by ~11 pt: the page block is centred between two
spacers and the icon's container no longer contributes 22 pt of height. The page
dots and the Continue button do not move. The icon keeps its soft accent shadow.
2026-10-14 15:02:29 +01:00
FuelBoard Contributor 7da277547b More info sheet: the copy confirmation reads "Postcode copied"
The capsule label was just "Copied", which reads oddly above a row whose hint is
"Copies the postcode". It is now "Postcode copied".

Still centred over the address row (centre x 603 of a 1206 px screen) and still a
pure overlay: the bands above and below the capsule are pixel-identical while it
is up (no reflow), and the row stays tappable straight through it.

The "Copied" localisation key goes with its only use; "Postcode copied" replaces it.
2026-10-14 14:52:07 +01:00
FuelBoard Contributor 93df94ddc9 How Best Value works: its own sheet, opened from the Key sheet and More info sheet
The explanation used to be a section inside the Key sheet. It is a standalone
HowBestValueSheet now, opened from a tappable row with a chevron: at the bottom of
the Key sheet, and at the bottom of the More info sheet below the Best Value card.

Gating: the More info row sits INSIDE the same `if let fuelSavings` as the card it
follows, so it disappears with the feature. Verified with Best Value toggled off in
Settings: the More info sheet then shows neither the card nor the row. The Key
sheet row is deliberately ungated — that sheet is the legend and is reachable
whatever the feature is doing.

Copy is unchanged: the same Localizable.strings strings are reused, and the old
section header became the sheet's title. A DEBUG-only `-showBestValueInfo` hook
opens the sheet without a tap.
2026-10-14 14:37:18 +01:00
FuelBoard Contributor 78b43ce6d7 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 13:57:21 +01:00
FuelBoard Contributor 13967c3782 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 13:45:05 +01:00
FuelBoard Contributor 4718e9b76a Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 13:16:20 +01:00
FuelBoard Contributor 6dfe732199 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 12:48:33 +01:00
FuelBoard Contributor f0c13a79d6 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 12:25:17 +01:00
FuelBoard Contributor 6b41e8ac51 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 12:07:54 +01:00
FuelBoard Contributor e58dbb7833 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 11:52:42 +01:00
FuelBoard Contributor 7ee1f06ce6 Merge branch 'feature/best-value-candidate-fill' into integration/more-info-and-best-value 2026-10-14 11:34:33 +01:00
FuelBoard Contributor 6ea675499e Best Value detail sheet: keep the verdict capsule left-aligned
The capsule was right-aligned on its own line; the user asked for it back at
the left margin, leaving the Total row as it is.
2026-10-14 11:31:59 +01:00
FuelBoard Contributor 3c424ab397 Best Value: candidate-priced fills, key copy, detail total 2026-10-14 10:43:12 +01:00
12 changed files with 381 additions and 120 deletions
+37 -7
View File
@@ -566,10 +566,34 @@ struct ContentView: View {
func source(_ basis: FuelSavingsDistanceBasis) -> String { func source(_ basis: FuelSavingsDistanceBasis) -> String {
basis == .roadRoute ? "road route" : "straight-line estimate" basis == .roadRoute ? "road route" : "straight-line estimate"
} }
let price = station.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—" func amount(_ value: FuelSavingsFillAmount) -> String {
let baselinePrice = baseline?.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—" switch value.unit {
case .pounds: return String(format: "£%.2f", value.value)
case .litres: return String(format: "%.2f L", value.value)
}
}
func rawMoney(_ value: Double?) -> String {
value.map { String(format: "£%.6f", $0) } ?? "—"
}
let candidatePriceValue = station.prices[selectedFuel]
let baselinePriceValue = baseline?.prices[selectedFuel]
let price = candidatePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
let baselinePrice = baselinePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
let priceDifference = candidatePriceValue.flatMap { candidate in
baselinePriceValue.map { String(format: "%+.1fp/L", $0 - candidate) }
} ?? "—"
let litresBasis = sessionFuelSavingsAmount.unit == .pounds ? "candidate price" : "fixed litres"
let rows: [(String, String)] = [ let rows: [(String, String)] = [
("Station", "\(station.name) (\(station.id))"), ("Station", "\(station.name) (\(station.id))"),
("Settings default fill-up", amount(fuelSavingsSettings.preferredAmount)),
("Effective/session fill-up", amount(sessionFuelSavingsAmount)),
("Fill-up unit", sessionFuelSavingsAmount.unit.displayName),
("MPG", String(format: "%.1f UK MPG", fuelSavingsSettings.mpg)),
("Candidate price", price),
("Baseline price", baselinePrice),
("Price difference", priceDifference),
("Candidate litres", result.map { String(format: "%.6f L · %@", $0.fillLitres, litresBasis) } ?? "—"),
("Gross saving raw", rawMoney(result?.grossSavingGBP)),
("Displayed distance", "\(distanceUnit.format(stationDistance.displayDistanceKM)) · \(source(stationDistance.displaySource))"), ("Displayed distance", "\(distanceUnit.format(stationDistance.displayDistanceKM)) · \(source(stationDistance.displaySource))"),
("Outward road", meters(stationDistance.outwardMeters)), ("Outward road", meters(stationDistance.outwardMeters)),
("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"), ("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"),
@@ -581,12 +605,11 @@ struct ContentView: View {
("Station journey", miles(journey?.miles)), ("Station journey", miles(journey?.miles)),
("Baseline journey", miles(baselineJourney?.miles)), ("Baseline journey", miles(baselineJourney?.miles)),
("Additional journey", miles(result?.extraDistanceMiles)), ("Additional journey", miles(result?.extraDistanceMiles)),
("Additional fuel used", result.map { String(format: "%.6f L", $0.extraFuelLitres) } ?? "—"),
("Confidence", result?.journeyConfidence.rawValue ?? "estimated"), ("Confidence", result?.journeyConfidence.rawValue ?? "estimated"),
("Fuel price", price), ("Travel cost raw", rawMoney(result?.extraDrivingCostGBP)),
("Baseline price", baselinePrice), ("Net saving raw", rawMoney(result?.netSavingGBP)),
("Gross saving", result.map { FuelSavingsFormat.money($0.grossSavingGBP) } ?? "—"), ("Displayed net saving", result?.headline() ?? "—")
("Travel cost", result.map { FuelSavingsFormat.money($0.extraDrivingCostGBP) } ?? "—"),
("Net saving", result.map { FuelSavingsFormat.money($0.netSavingGBP) } ?? "—")
] ]
return FuelSavingsJourneyDiagnostics(rows: rows) return FuelSavingsJourneyDiagnostics(rows: rows)
#else #else
@@ -1104,6 +1127,13 @@ struct ContentView: View {
ensureBestValueAlertModeIsAvailable() ensureBestValueAlertModeIsAvailable()
updateAlertMonitor() updateAlertMonitor()
} }
.onChange(of: sessionFuelSavingsAmount) { _, _ in
// This remains a session-only override: do not write it to FuelStore.
// Any active Best Value alert/Live Activity must nevertheless use
// the same effective amount as rows, details, favourites and sorting.
updateAlertMonitor()
updateLiveActivity()
}
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
if newPhase == .active { if newPhase == .active {
// Never start location tracking while onboarding is on screen — // Never start location tracking while onboarding is on screen —
+39 -2
View File
@@ -78,6 +78,7 @@ struct FuelSavingsSummaryView: View {
/// Sizes the "extra return travel" caption in the user's own unit (the /// 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. /// stored figure is miles) — used by both the row banner and the detail sheet.
var distanceUnit: DistanceUnit = .miles var distanceUnit: DistanceUnit = .miles
var showInfoAction: (() -> Void)? = nil
var body: some View { var body: some View {
switch availability { switch availability {
@@ -119,6 +120,9 @@ struct FuelSavingsSummaryView: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
capsule(for: result, compact: false) capsule(for: result, compact: false)
.accessibilityLabel(accessibilityHeadline(for: result)) .accessibilityLabel(accessibilityHeadline(for: result))
LabeledContent("Your fill-up", value: amountText)
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
.monospacedDigit()
if let extraTravel = FuelSavingsFormat.extraTravelLabel( if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles, extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit, unit: distanceUnit,
@@ -134,9 +138,26 @@ struct FuelSavingsSummaryView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary) Text("Compared with \(baselineStationName ?? "your nearest station").")
LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP)) .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)) 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 { if result.journeyConfidence.isEstimated {
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.") Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
.font(.caption2) .font(.caption2)
@@ -146,6 +167,22 @@ struct FuelSavingsSummaryView: View {
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .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): case .unavailable(let reason):
+69
View File
@@ -0,0 +1,69 @@
import SwiftUI
/// The "How Best Value works" explanation as its own sheet.
///
/// It used to sit inline in the Key sheet as a `Section`; it is one shared view
/// now so the Key sheet and the More info sheet can both open it, and the copy is
/// unchanged (the same `Localizable.strings` keys are reused — the old section
/// header string is the sheet's title).
struct HowBestValueSheet: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
Text("Your fill-up amount and MPG are used to show whether you could save overall.")
Text("Results are marked “Approx.” until road distances are available.")
.foregroundStyle(.secondary)
}
.font(.subheadline)
.padding(.vertical, 4)
}
}
.navigationTitle("How Best Value works")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.presentationDetents([.fraction(0.42)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
}
}
/// The row that opens `HowBestValueSheet`. A tappable row with a chevron, like
/// the other rows in both sheets. `.buttonStyle(.plain)` keeps it from becoming a
/// row-wide tap target that could swallow a neighbouring control (see the
/// two-buttons-in-one-row rule).
struct HowBestValueRow: View {
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Text("How Best Value works")
Spacer(minLength: 8)
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
}
}
#if DEBUG
/// QA hook support: `-showBestValueInfo` opens this sheet without a tap, so it can
/// be screenshotted (simctl has no tap command) — the same pattern as
/// `-showKeySheet` / `-showDetail`.
enum BestValueInfoQA {
static var isRequested: Bool {
ProcessInfo.processInfo.arguments.contains("-showBestValueInfo")
}
}
#endif
+35 -24
View File
@@ -98,7 +98,7 @@ struct OnboardingView: View {
private var welcomePage: some View { private var welcomePage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
AnimatedOnboardingIcon(tint: .accentColor) { AnimatedOnboardingIcon(tint: .accentColor, showsHalo: false) {
// The app icon itself, with the standard iOS rounded-corner // The app icon itself, with the standard iOS rounded-corner
// treatment (~22% of the size), so onboarding matches the // treatment (~22% of the size), so onboarding matches the
// Home Screen. // Home Screen.
@@ -108,10 +108,10 @@ struct OnboardingView: View {
.frame(width: 96, height: 96) .frame(width: 96, height: 96)
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
} }
.padding(.bottom, 28) .padding(.bottom, 16)
Text(String(localized: "Welcome to FuelBoard", comment: "Onboarding welcome title")) Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title"))
.font(.largeTitle.bold()) .font(.title.bold())
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle")) Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle"))
@@ -121,12 +121,13 @@ struct OnboardingView: View {
.padding(.horizontal, 32) .padding(.horizontal, 32)
.padding(.top, 12) .padding(.top, 12)
featureRow(icon: "globe.europe.africa.fill", text: String(localized: "UK-wide prices — 8,000+ stations, updated twice a day", comment: "Onboarding feature row")) featureRow(icon: "map.fill", title: String(localized: "Compare fuel prices near you", comment: "Onboarding feature row title"), subtitle: String(localized: "Over 8,000 stations across Great Britain, with prices updated daily.", comment: "Onboarding feature row subtitle"))
featureRow(icon: "scope", text: String(localized: "Cheapest within your chosen radius, or closest station first", comment: "Onboarding feature row"))
featureRow(icon: "star.fill", text: String(localized: "Favourites with instant price comparison", comment: "Onboarding feature row")) featureRow(icon: "chart.line.uptrend.xyaxis", title: String(localized: "See how much you could save", comment: "Onboarding feature row title"), subtitle: String(localized: "Compare fill-up costs and potential savings at different stations.", comment: "Onboarding feature row subtitle"))
featureRow(icon: "chart.bar.fill", text: String(localized: "Green/amber/red rating vs the best nearby price", comment: "Onboarding feature row"))
featureRow(icon: "square.grid.2x2.fill", text: String(localized: "Home-screen widget showing the cheapest nearby", comment: "Onboarding feature row")) featureRow(icon: "car.fill", title: String(localized: "Know if it's worth the drive", comment: "Onboarding feature row title"), subtitle: String(localized: "Factor in your journey and fuel costs to find the best value.", comment: "Onboarding feature row subtitle"))
featureRow(icon: "bell.fill", text: String(localized: "Alerts when you approach the cheapest station", comment: "Onboarding feature row"))
featureRow(icon: "star.fill", title: String(localized: "Keep your favourites close", comment: "Onboarding feature row title"), subtitle: String(localized: "Save stations and quickly compare their latest prices.", comment: "Onboarding feature row subtitle"))
Spacer() Spacer()
} }
@@ -298,18 +299,23 @@ struct OnboardingView: View {
OnboardingPrimaryButton(title: title, action: action) OnboardingPrimaryButton(title: title, action: action)
} }
private func featureRow(icon: String, text: String) -> some View { private func featureRow(icon: String, title: String, subtitle: String) -> some View {
HStack(spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 20, weight: .semibold)) .font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color.accentColor) .foregroundStyle(Color.accentColor)
.frame(width: 32) .frame(width: 32)
Text(text) VStack(alignment: .leading, spacing: 2) {
.font(.subheadline) Text(title)
.font(.subheadline.bold())
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.padding(.horizontal, 36) .padding(.horizontal, 36)
.padding(.vertical, 10) .padding(.vertical, 8)
} }
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View { private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
@@ -393,6 +399,9 @@ struct AnimatedOnboardingIcon<Content: View>: View {
let tint: Color let tint: Color
var motion: Motion = .gentle var motion: Motion = .gentle
/// The soft circle behind the glyph. The welcome page turns it off: it shows
/// the app icon itself and reads better with no container around it.
var showsHalo: Bool = true
@ViewBuilder var content: Content @ViewBuilder var content: Content
@State private var settled = false @State private var settled = false
@@ -402,16 +411,18 @@ struct AnimatedOnboardingIcon<Content: View>: View {
var body: some View { var body: some View {
ZStack { ZStack {
Circle() if showsHalo {
.fill(tint.opacity(0.10)) Circle()
.frame(width: 108, height: 108) .fill(tint.opacity(0.10))
.scaleEffect(settled ? 1.0 : 0.94) .frame(width: 108, height: 108)
.scaleEffect(settled ? 1.0 : 0.94)
Circle() Circle()
.stroke(tint.opacity(0.18), lineWidth: 1) .stroke(tint.opacity(0.18), lineWidth: 1)
.frame(width: 118, height: 118) .frame(width: 118, height: 118)
.scaleEffect(settled ? 1.03 : 0.97) .scaleEffect(settled ? 1.03 : 0.97)
.opacity(settled ? 1.0 : 0.55) .opacity(settled ? 1.0 : 0.55)
}
content content
.scaleEffect(iconScale) .scaleEffect(iconScale)
+2 -2
View File
@@ -618,7 +618,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.sound = .default content.sound = .default
content.title = "Best Value \(fuel.displayName) nearby: \(brand)" content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
content.body = "\(station.name) could leave you \(result.headline()). Tap for directions." content.body = "\(station.name): \(result.headline()). Tap for directions."
addAlertRequest(content: content, station: station) addAlertRequest(content: content, station: station)
logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())") logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())")
lastAlert = "Best Value · \(brand) · \(result.headline())" lastAlert = "Best Value · \(brand) · \(result.headline())"
@@ -923,7 +923,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.sound = .default content.sound = .default
let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand
content.title = "Best Value \(fuel.displayName) nearby: \(brand)" content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
content.body = "\(candidate.station.name) could leave you \(candidate.result.headline()). Tap for directions." content.body = "\(candidate.station.name): \(candidate.result.headline()). Tap for directions."
let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))" let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))"
lastTestResult = baseResult lastTestResult = baseResult
logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())") logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())")
+12 -6
View File
@@ -29,10 +29,13 @@ struct StationDetailView: View {
var onToggleFavourite: (() -> Void)? = nil var onToggleFavourite: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showingReportIssueConfirmation = false @State private var showingReportIssueConfirmation = false
/// Transient "Copied" confirmation on the Address row. Cleared by a task so /// Transient "Postcode copied" confirmation on the Address row. Cleared by a task so
/// the caption fades out on its own. /// the caption fades out on its own.
@State private var showingCopied = false @State private var showingCopied = false
@State private var copyResetTask: Task<Void, Never>? @State private var copyResetTask: Task<Void, Never>?
/// The "How Best Value works" sheet, opened from the row at the bottom of the
/// Best Value card (the Key sheet opens the same shared view).
@State private var showBestValueInfo = false
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")! private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
@@ -134,7 +137,7 @@ struct StationDetailView: View {
// The whole row is a copy control: it puts JUST the postcode // The whole row is a copy control: it puts JUST the postcode
// on the clipboard so it can be pasted into another // on the clipboard so it can be pasted into another
// navigation app. The copy glyph sits ON the address line, // navigation app. The copy glyph sits ON the address line,
// and the "Copied" confirmation is a capsule that floats OVER // and the "Postcode copied" confirmation is a capsule that floats OVER
// THIS ROW — it confirms the address, so it must not hover // THIS ROW — it confirms the address, so it must not hover
// over the map. It is an overlay, so nothing moves while it is // over the map. It is an overlay, so nothing moves while it is
// up, and `allowsHitTesting(false)` keeps the row tappable // up, and `allowsHitTesting(false)` keeps the row tappable
@@ -169,7 +172,7 @@ struct StationDetailView: View {
if showingCopied { if showingCopied {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
Text("Copied") Text("Postcode copied")
} }
.font(.footnote.weight(.semibold)) .font(.footnote.weight(.semibold))
.foregroundStyle(.green) .foregroundStyle(.green)
@@ -235,7 +238,8 @@ struct StationDetailView: View {
availability: fuelSavings, availability: fuelSavings,
amount: fuelSavingsAmount, amount: fuelSavingsAmount,
baselineStationName: savingsBaselineStationName, baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit distanceUnit: distanceUnit,
showInfoAction: { showBestValueInfo = true }
) )
} header: { } header: {
Text("Best Value") Text("Best Value")
@@ -265,6 +269,8 @@ struct StationDetailView: View {
} message: { } message: {
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.") Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
} }
// Nested sheet: the More info sheet stays put behind the explanation.
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() } Button("Done") { dismiss() }
@@ -284,12 +290,12 @@ struct StationDetailView: View {
) )
} }
} }
// The "Copied" fade-out task must not outlive the sheet. // The "Postcode copied" fade-out task must not outlive the sheet.
.onDisappear { copyResetTask?.cancel() } .onDisappear { copyResetTask?.cancel() }
#if DEBUG #if DEBUG
// QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the // QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the
// clipboard value (read back with `xcrun simctl pbpaste`) and the // clipboard value (read back with `xcrun simctl pbpaste`) and the
// "Copied" confirmation can be verified without a tap — simctl // "Postcode copied" confirmation can be verified without a tap — simctl
// cannot drive taps into the Simulator. // cannot drive taps into the Simulator.
.task { .task {
guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return } guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return }
+39 -7
View File
@@ -35,6 +35,11 @@ struct StationsView: View {
/// info button in the header — keeps the list focused on stations. /// info button in the header — keeps the list focused on stations.
@State private var showKey = false @State private var showKey = false
/// The "How Best Value works" sheet. The explanation used to be a section in
/// the Key sheet; it is its own sheet now, opened from a row at the bottom of
/// the Key sheet and from the More info sheet.
@State private var showBestValueInfo = false
/// Station picked for the More-info sheet (tap action = showDetails). /// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation? @State private var detailStation: FuelStation?
@@ -218,6 +223,18 @@ struct StationsView: View {
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") { if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
showKey = true showKey = true
} }
// QA hook: `-showBestValueInfo` opens the Key sheet and then the
// explanation on top of it, so the copy can be screenshotted
// without a tap. It has to chain: the explanation's `.sheet` lives
// inside the Key sheet's content, so it cannot present while the
// Key sheet is shut.
if BestValueInfoQA.isRequested {
showKey = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_200_000_000)
showBestValueInfo = true
}
}
#endif #endif
} }
.task(id: stations.count) { .task(id: stations.count) {
@@ -249,15 +266,15 @@ struct StationsView: View {
VStack(spacing: 10) { VStack(spacing: 10) {
keyCard( keyCard(
icon: "checkmark.circle.fill", color: .green, icon: "checkmark.circle.fill", color: .green,
title: "Best value", blurb: "Within 1.5p of the cheapest", title: "Good price", blurb: "Within 1.5p of the cheapest",
threshold: "≤1.5p") threshold: "0–1.5p")
keyCard( keyCard(
icon: "equal.circle.fill", color: .orange, icon: "equal.circle.fill", color: .orange,
title: "Okay", blurb: "Within 4p of the cheapest", title: "Okay", blurb: "More than 1.5p, up to 4p above the cheapest",
threshold: "≤4p") threshold: ">1.5–4p")
keyCard( keyCard(
icon: "exclamationmark.circle.fill", color: .red, icon: "exclamationmark.circle.fill", color: .red,
title: "Pricey", blurb: "More than 4p over the cheapest", title: "Pricey", blurb: "More than 4p above the cheapest",
threshold: ">4p") threshold: ">4p")
Rectangle() Rectangle()
.fill(Color(.separator)) .fill(Color(.separator))
@@ -289,15 +306,27 @@ struct StationsView: View {
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 12) .padding(.vertical, 12)
.keyCardFill(color: .yellow) .keyCardFill(color: .yellow)
Text("Colours match each station's rating on the list") Text("Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity, alignment: .center)
.multilineTextAlignment(.center)
.padding(.top, 4) .padding(.top, 4)
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
} }
// The explanation is its own sheet now, opened from this
// row. Gated exactly like the More info row and the Best
// Value card itself: `isConfigured` is the same condition
// `savingsForStation` uses, so the row appears and
// disappears with the feature.
if fuelSavingsSettings.isConfigured {
Section {
HowBestValueRow { showBestValueInfo = true }
}
}
} }
.navigationTitle("Key") .navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -306,6 +335,8 @@ struct StationsView: View {
Button("Done") { showKey = false } Button("Done") { showKey = false }
} }
} }
// Nested sheet: the Key sheet stays behind the explanation.
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
} }
.presentationDetents([.fraction(0.6)]) .presentationDetents([.fraction(0.6)])
.presentationBackground(Color(UIColor.systemGroupedBackground)) .presentationBackground(Color(UIColor.systemGroupedBackground))
@@ -347,6 +378,7 @@ struct StationsView: View {
Text(LocalizedStringKey(blurb)) Text(LocalizedStringKey(blurb))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} }
Spacer(minLength: 8) Spacer(minLength: 8)
HStack(spacing: 6) { HStack(spacing: 6) {
+10 -5
View File
@@ -23,18 +23,22 @@
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)"; "Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
"All %lld stations shown" = "All %lld stations shown"; "All %lld stations shown" = "All %lld stations shown";
"Key" = "Key"; "Key" = "Key";
"Best value" = "Best value"; "Good price" = "Good price";
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest"; "Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
"Okay" = "Okay"; "Okay" = "Okay";
"Within 4p of the cheapest" = "Within 4p of the cheapest"; "More than 1.5p, up to 4p above the cheapest" = "More than 1.5p, up to 4p above the cheapest";
"Pricey" = "Pricey"; "Pricey" = "Pricey";
"More than 4p over the cheapest" = "More than 4p over the cheapest"; "More than 4p above the cheapest" = "More than 4p above the cheapest";
"Top result for the current sort" = "Top result for the current sort"; "Top result for the current sort" = "Top result for the current sort";
"Star a station to add it to Favourites" = "Star a station to add it to Favourites"; "Star a station to add it to Favourites" = "Star a station to add it to Favourites";
/* Star button in the More info sheet heading (accessibility labels) */ /* Star button in the More info sheet heading (accessibility labels) */
"Add to favourites" = "Add to favourites"; "Add to favourites" = "Add to favourites";
"Remove from favourites" = "Remove from favourites"; "Remove from favourites" = "Remove from favourites";
"Colours match each station's rating on the list" = "Colours match each station's rating on the list"; "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
"How Best Value works" = "How Best Value works";
"Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
/* Favourites tab */ /* Favourites tab */
"No favourites yet" = "No favourites yet"; "No favourites yet" = "No favourites yet";
@@ -67,8 +71,8 @@
"Name" = "Name"; "Name" = "Name";
"Address" = "Address"; "Address" = "Address";
"Distance" = "Distance"; "Distance" = "Distance";
"Copied" = "Copied";
"Copies the postcode" = "Copies the postcode"; "Copies the postcode" = "Copies the postcode";
"Postcode copied" = "Postcode copied";
"Address: %@" = "Address: %@"; "Address: %@" = "Address: %@";
"Directions to %@" = "Directions to %@"; "Directions to %@" = "Directions to %@";
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions"; "Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
@@ -199,6 +203,7 @@
"Cheapest station" = "Cheapest station"; "Cheapest station" = "Cheapest station";
"Alert type" = "Alert type"; "Alert type" = "Alert type";
"Best Value" = "Best Value"; "Best Value" = "Best Value";
"Total" = "Total";
"Minimum saving" = "Minimum saving"; "Minimum saving" = "Minimum saving";
"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost."; "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.";
"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost."; "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.";
@@ -82,18 +82,84 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344)) XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344))
} }
// MARK: - 2/4. The three stations from the brief // MARK: - 2. Candidate-priced fill-up
func testPoundsFillUsesCandidatePriceForLitresAndGrossSaving() throws {
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0.879,
candidateJourneyMiles: 8.306
)
XCTAssertEqual(result.fillLitres, 60 / 1.699, accuracy: 1e-9)
XCTAssertEqual(result.grossSavingGBP, (60 / 1.699) * 0.02, accuracy: 1e-9)
XCTAssertEqual(FuelSavingsFormat.money(result.grossSavingGBP), "£0.71")
XCTAssertEqual(result.extraFuelLitres, 1.1254603476666667, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 1.9121571306856667, accuracy: 1e-9)
XCTAssertEqual(result.netSavingGBP, -1.2058593084372853, accuracy: 1e-9)
XCTAssertEqual(result.headline(), "Costs £1.21 more")
}
func testPoundsExamplesScaleFromThirtyToOneHundredPounds() throws {
for (budget, expectedLitres, expectedGross) in [
(30.0, 17.657445556209535, 0.3531489111241907),
(60.0, 35.31489111241907, 0.7062978222483814),
(100.0, 58.85815185403179, 1.1771630370806357)
] {
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: budget, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0,
candidateJourneyMiles: 0
)
XCTAssertEqual(result.fillLitres, expectedLitres, accuracy: 1e-8)
XCTAssertEqual(result.grossSavingGBP, expectedGross, accuracy: 1e-8)
}
}
func testCandidatePriceControlsLitresWhileBaselinePriceOnlyChangesSaving() throws {
let amount = FuelSavingsFillAmount(value: 60, unit: .pounds)
let first = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
let differentBaseline = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 179.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
let differentCandidate = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 179.9,
candidatePricePencePerLitre: 159.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
XCTAssertEqual(first.fillLitres, differentBaseline.fillLitres, accuracy: 1e-12)
XCTAssertNotEqual(first.grossSavingGBP, differentBaseline.grossSavingGBP)
XCTAssertNotEqual(differentBaseline.fillLitres, differentCandidate.fillLitres)
}
// MARK: - 3/5. The three stations from the brief
func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws { func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws {
let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0) let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0)
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6) XCTAssertEqual(result.fillLitres, 35.108250, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 0.349040, accuracy: 1e-6, "1p/litre cheaper on a 34.90 L fill") XCTAssertEqual(result.grossSavingGBP, 0.351083, accuracy: 1e-6, "£60 buys 35.11 L at the candidate price")
XCTAssertEqual(result.extraDistanceMiles, 2.0, accuracy: 1e-9) XCTAssertEqual(result.extraDistanceMiles, 2.0, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6) XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -0.168911, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, -0.166869, accuracy: 1e-6)
XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "£0.17 worse off after travel") XCTAssertEqual(result.headline(), "Costs £0.17 more")
} }
/// The brief recorded "£0.18 worse off" for this row against a displayed /// The brief recorded "£0.18 worse off" for this row against a displayed
@@ -104,9 +170,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
/// is entirely the rounding of the distance, not of the money. /// is entirely the rounding of the distance, not of the money.
func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws { func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws {
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(), XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(),
"£0.17 worse off after travel") "Costs £0.17 more")
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(), XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(),
"£0.18 worse off after travel") "Costs £0.18 more")
// 2.05 mi shows as "2.0 mi" at one decimal place — the same caption. // 2.05 mi shows as "2.0 mi" at one decimal place — the same caption.
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles), XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles),
@@ -118,13 +184,13 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
func testBriefCaseMFGHareAndHoundsIsWorseOff() throws { func testBriefCaseMFGHareAndHoundsIsWorseOff() throws {
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8) let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6) XCTAssertEqual(result.fillLitres, 34.702140, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, -0.349040, accuracy: 1e-6, "1p/litre dearer costs 34.90 L × 1p") XCTAssertEqual(result.grossSavingGBP, -0.347021, accuracy: 1e-6, "£60 buys 34.70 L at the candidate price")
XCTAssertEqual(result.extraDistanceMiles, 2.8, accuracy: 1e-9) XCTAssertEqual(result.extraDistanceMiles, 2.8, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6) XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "£1.08 worse off after travel") XCTAssertEqual(result.headline(), "Costs £1.08 more")
} }
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws { func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
@@ -135,7 +201,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6, XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6,
"with no price difference the whole result is the extra driving") "with no price difference the whole result is the extra driving")
XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "£0.73 worse off after travel") XCTAssertEqual(result.headline(), "Costs £0.73 more")
} }
// MARK: - 5/6. Classification, tolerance and rounding // MARK: - 5/6. Classification, tolerance and rounding
@@ -150,15 +216,15 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
} }
func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws { func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws {
// 171.9p vs 170.9p over a 1.23 mi delta: net +£0.0305. // 171.9p vs 170.9p over a 1.23 mi delta: the small saving remains within tolerance.
let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23) let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23)
XCTAssertEqual(result.netSavingGBP, 0.030500, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, 0.03254252417853143, accuracy: 1e-9)
XCTAssertEqual(result.status, .breakEven, "±5p reads as break even rather than a meaningless £0.03") XCTAssertEqual(result.status, .breakEven, "±5p reads as break even rather than a meaningless £0.03")
XCTAssertEqual(result.headline(), "Break even") XCTAssertEqual(result.headline(), "Break even")
} }
func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws { func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws {
let net = -0.168911 let net = -0.166869
// A tolerance wider than the loss → break even; narrower → worse off. // A tolerance wider than the loss → break even; narrower → worse off.
let lenient = try FuelSavingsCalculator.calculate( let lenient = try FuelSavingsCalculator.calculate(
mpg: 30, mpg: 30,
@@ -187,7 +253,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
func testMoneyIsRoundedOnlyForDisplay() throws { func testMoneyIsRoundedOnlyForDisplay() throws {
// Full precision retained internally… // Full precision retained internally…
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8) let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded") XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded")
// …and rounded to two places only when shown. // …and rounded to two places only when shown.
XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08") XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08")
@@ -237,12 +303,12 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 2.0, baselineJourneyMiles: 2.0,
candidateJourneyMiles: 7.0 candidateJourneyMiles: 7.0
) )
XCTAssertEqual(result.fillLitres, 27.416038, accuracy: 1e-6) XCTAssertEqual(result.fillLitres, 28.797696, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 1.919123, accuracy: 1e-6) XCTAssertEqual(result.grossSavingGBP, 2.015839, accuracy: 1e-6)
XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6) XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, 1.217509, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, 1.314226, accuracy: 1e-6)
XCTAssertEqual(result.status, .worthTheDrive) XCTAssertEqual(result.status, .worthTheDrive)
XCTAssertEqual(result.headline(), "£1.22 better off after travel") XCTAssertEqual(result.headline(), "Save £1.31")
} }
func testNearerCandidateIsNeverChargedExtraTravel() throws { func testNearerCandidateIsNeverChargedExtraTravel() throws {
@@ -270,9 +336,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8, baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6 candidateJourneyMiles: 3.6
) )
XCTAssertEqual(sixty.grossSavingGBP, -0.349040, accuracy: 1e-6, "MPG must not touch the pump saving") XCTAssertEqual(sixty.grossSavingGBP, -0.347021, accuracy: 1e-6, "MPG must not touch the pump saving")
XCTAssertEqual(sixty.extraDrivingCostGBP, 0.366809, accuracy: 1e-6) XCTAssertEqual(sixty.extraDrivingCostGBP, 0.366809, accuracy: 1e-6)
XCTAssertEqual(sixty.netSavingGBP, -0.715849, accuracy: 1e-6) XCTAssertEqual(sixty.netSavingGBP, -0.713830, accuracy: 1e-6)
let fortyFive = try FuelSavingsCalculator.calculate( let fortyFive = try FuelSavingsCalculator.calculate(
mpg: 45, mpg: 45,
@@ -283,7 +349,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
candidateJourneyMiles: 3.6 candidateJourneyMiles: 3.6
) )
XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6) XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6)
XCTAssertEqual(fortyFive.netSavingGBP, -0.838119, accuracy: 1e-6) XCTAssertEqual(fortyFive.netSavingGBP, -0.836100, accuracy: 1e-6)
} }
func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws { func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws {
@@ -295,10 +361,10 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8, baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6 candidateJourneyMiles: 3.6
) )
XCTAssertEqual(forty.fillLitres, 23.269343, accuracy: 1e-6, "£40 buys 23.27 L at 171.9p") XCTAssertEqual(forty.fillLitres, 23.134760, accuracy: 1e-6, "£40 buys 23.13 L at the candidate price")
XCTAssertEqual(forty.grossSavingGBP, -0.232693, accuracy: 1e-6) XCTAssertEqual(forty.grossSavingGBP, -0.231348, accuracy: 1e-6)
XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent") XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent")
XCTAssertEqual(forty.netSavingGBP, -0.966311, accuracy: 1e-6) XCTAssertEqual(forty.netSavingGBP, -0.964965, accuracy: 1e-6)
let ninety = try FuelSavingsCalculator.calculate( let ninety = try FuelSavingsCalculator.calculate(
mpg: 30, mpg: 30,
@@ -308,9 +374,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8, baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6 candidateJourneyMiles: 3.6
) )
XCTAssertEqual(ninety.fillLitres, 52.356021, accuracy: 1e-6) XCTAssertEqual(ninety.fillLitres, 52.053210, accuracy: 1e-6)
XCTAssertEqual(ninety.grossSavingGBP, -0.523560, accuracy: 1e-6) XCTAssertEqual(ninety.grossSavingGBP, -0.520532, accuracy: 1e-6)
XCTAssertEqual(ninety.netSavingGBP, -1.257178, accuracy: 1e-6) XCTAssertEqual(ninety.netSavingGBP, -1.254150, accuracy: 1e-6)
} }
func testFillUpInLitresIsFixedRegardlessOfPrice() throws { func testFillUpInLitresIsFixedRegardlessOfPrice() throws {
@@ -370,7 +436,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
journey: journey(2.8), journey: journey(2.8),
baselineJourney: journey(0.8) baselineJourney: journey(0.8)
) )
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.168911, accuracy: 1e-6) XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.166869, accuracy: 1e-6)
} }
func testMixedRouteBasisIsReportedAsAnEstimate() { func testMixedRouteBasisIsReportedAsAnEstimate() {
@@ -393,7 +459,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine, XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine,
"one unmeasured leg makes the whole estimate an estimate") "one unmeasured leg makes the whole estimate an estimate")
XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated) XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated)
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. £0.17 worse off after travel") XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. Costs £0.17 more")
} }
func testBaselineStationIsLabelledClosestStationNotBreakEven() { func testBaselineStationIsLabelledClosestStationNotBreakEven() {
@@ -284,9 +284,9 @@ final class FuelSavingsEstimateTests: XCTestCase {
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi). /// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi).
/// ///
/// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is /// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is
/// correct, not a calculation bug: £60 buys 34.90 L at 171.9p, so the /// correct, not a calculation bug: £60 buys 35.74 L at 167.9p, so the
/// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles /// cheaper pump saves 35.74 × 4p = £1.43, while the extra 6.8 return miles
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33. /// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.30.
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() { func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
let baseline = station("rontec-shelf", price: 171.9) let baseline = station("rontec-shelf", price: 171.9)
let candidate = station("shell-whetley-hill", price: 167.9) let candidate = station("shell-whetley-hill", price: 167.9)
@@ -304,16 +304,16 @@ final class FuelSavingsEstimateTests: XCTestCase {
guard let result = availability?.result else { guard let result = availability?.result else {
return XCTFail("expected an estimate, got \(String(describing: availability))") return XCTFail("expected an estimate, got \(String(describing: availability))")
} }
XCTAssertEqual(result.fillLitres, 34.904, accuracy: 0.005, XCTAssertEqual(result.fillLitres, 35.736, accuracy: 0.005,
"the £60 budget buys litres at the BASELINE pump price") "the £60 budget buys litres at the candidate pump price")
XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005) XCTAssertEqual(result.grossSavingGBP, 1.429, accuracy: 0.005)
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001, XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
"only the return-trip DELTA counts — the shared journey is never counted twice") "only the return-trip DELTA counts — the shared journey is never counted twice")
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005) XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
XCTAssertEqual(result.netSavingGBP, -0.334, accuracy: 0.005) XCTAssertEqual(result.netSavingGBP, -0.301, accuracy: 0.005)
XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.journeyConfidence, .estimated) XCTAssertEqual(result.journeyConfidence, .estimated)
XCTAssertEqual(result.headline(compact: true), "Approx. £0.33 worse off after travel") XCTAssertEqual(result.headline(compact: true), "Approx. Costs £0.30 more")
} }
func testMeasuredRoadRoutesDropTheEstimateMarker() { func testMeasuredRoadRoutesDropTheEstimateMarker() {
@@ -332,8 +332,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute) XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed) XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed)
XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel") XCTAssertEqual(availability?.result?.headline(compact: true), "Costs £0.30 more")
XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel") XCTAssertEqual(availability?.result?.headline(), "Costs £0.30 more")
} }
func testBaselineRowIsLabelledClosestStation() { func testBaselineRowIsLabelledClosestStation() {
@@ -375,10 +375,10 @@ final class FuelSavingsEstimateTests: XCTestCase {
return XCTFail("expected an estimate, got \(String(describing: availability))") return XCTFail("expected an estimate, got \(String(describing: availability))")
} }
XCTAssertEqual(result.status, .worthTheDrive) XCTAssertEqual(result.status, .worthTheDrive)
// £60 buys 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra // £60 buys 40 L at 150p; 10p/litre cheaper saves £4.00; 0.4 extra
// return miles cost ~£0.09. Net ≈ £3.66. // return miles cost ~£0.09. Net ≈ £3.91.
XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005) XCTAssertEqual(result.netSavingGBP, 3.909, accuracy: 0.005)
XCTAssertEqual(result.headline(compact: true), "Approx. £3.66 better off after travel") XCTAssertEqual(result.headline(compact: true), "Approx. Save £3.91")
} }
func testNeutralResultHeadlineIsBreakEven() { func testNeutralResultHeadlineIsBreakEven() {
@@ -413,7 +413,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: 0.52, netSavingGBP: 0.52,
status: .worthTheDrive, status: .worthTheDrive,
distanceBasis: .roadRoute) distanceBasis: .roadRoute)
XCTAssertEqual(saving.headline(), "£0.52 better off after travel") XCTAssertEqual(saving.headline(), "Save £0.52")
let loss = FuelSavingsResult(fillLitres: 34.9, let loss = FuelSavingsResult(fillLitres: 34.9,
grossSavingGBP: 1.40, grossSavingGBP: 1.40,
@@ -422,7 +422,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: -0.31, netSavingGBP: -0.31,
status: .notWorthTheDrive, status: .notWorthTheDrive,
distanceBasis: .roadRoute) distanceBasis: .roadRoute)
XCTAssertEqual(loss.headline(), "£0.31 worse off after travel") XCTAssertEqual(loss.headline(), "Costs £0.31 more")
} }
/// The sort must order by NET saving, not pump price: a pricier station can /// The sort must order by NET saving, not pump price: a pricier station can
@@ -497,7 +497,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .worthTheDrive, status: .worthTheDrive,
distanceBasis: .straightLine distanceBasis: .straightLine
) )
XCTAssertEqual(saving.capsuleHeadline(), "Approx. £1.00 better off after travel") XCTAssertEqual(saving.capsuleHeadline(), "Approx. Save £1.00")
let worse = FuelSavingsResult( let worse = FuelSavingsResult(
fillLitres: 30, fillLitres: 30,
@@ -507,7 +507,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: -0.31, netSavingGBP: -0.31,
status: .notWorthTheDrive status: .notWorthTheDrive
) )
XCTAssertEqual(worse.capsuleHeadline(), "£0.31 worse off after travel") XCTAssertEqual(worse.capsuleHeadline(), "Costs £0.31 more")
} }
// MARK: - Estimate wording (Approx. labels) // MARK: - Estimate wording (Approx. labels)
@@ -524,7 +524,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .worthTheDrive, status: .worthTheDrive,
distanceBasis: .straightLine distanceBasis: .straightLine
) )
XCTAssertEqual(saving.headline(), "Approx. £1.25 better off after travel") XCTAssertEqual(saving.headline(), "Approx. Save £1.25")
let loss = FuelSavingsResult( let loss = FuelSavingsResult(
fillLitres: 30, fillLitres: 30,
@@ -535,7 +535,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .notWorthTheDrive, status: .notWorthTheDrive,
distanceBasis: .straightLine distanceBasis: .straightLine
) )
XCTAssertEqual(loss.headline(), "Approx. £0.77 worse off after travel") XCTAssertEqual(loss.headline(), "Approx. Costs £0.77 more")
} }
/// A confirmed road-route comparison must never carry the marker — in either /// A confirmed road-route comparison must never carry the marker — in either
@@ -551,7 +551,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
distanceBasis: .roadRoute, distanceBasis: .roadRoute,
journeyConfidence: .confirmed journeyConfidence: .confirmed
) )
XCTAssertEqual(saving.headline(), "£1.25 better off after travel") XCTAssertEqual(saving.headline(), "Save £1.25")
let loss = FuelSavingsResult( let loss = FuelSavingsResult(
fillLitres: 30, fillLitres: 30,
@@ -563,7 +563,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
distanceBasis: .roadRoute, distanceBasis: .roadRoute,
journeyConfidence: .confirmed journeyConfidence: .confirmed
) )
XCTAssertEqual(loss.headline(), "£0.77 worse off after travel") XCTAssertEqual(loss.headline(), "Costs £0.77 more")
for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] { for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] {
XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate") XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate")
@@ -597,7 +597,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .notWorthTheDrive, status: .notWorthTheDrive,
distanceBasis: .straightLine distanceBasis: .straightLine
) )
XCTAssertEqual(estimated.spokenHeadline(), "Approximately £0.77 worse off after travel") XCTAssertEqual(estimated.spokenHeadline(), "Approximately Costs £0.77 more")
XCTAssertEqual( XCTAssertEqual(
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated), FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
@@ -8,15 +8,15 @@ final class FuelSavingsCalculatorTests: XCTestCase {
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001) XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001)
} }
func testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws { func testPoundsBudgetConvertsToLitresUsingGivenPrice() throws {
let amount = FuelSavingsFillAmount(value: 40, unit: .pounds) let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160) let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160)
XCTAssertEqual(litres, 25.0, accuracy: 0.0001) XCTAssertEqual(litres, 25.0, accuracy: 0.0001)
} }
func testLitresAmountPassesThrough() throws { func testLitresAmountPassesThrough() throws {
let amount = FuelSavingsFillAmount(value: 35, unit: .litres) let amount = FuelSavingsFillAmount(value: 35, unit: .litres)
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160) let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160)
XCTAssertEqual(litres, 35, accuracy: 0.0001) XCTAssertEqual(litres, 35, accuracy: 0.0001)
} }
@@ -29,11 +29,11 @@ final class FuelSavingsCalculatorTests: XCTestCase {
baselineJourneyMiles: 2, baselineJourneyMiles: 2,
candidateJourneyMiles: 8 candidateJourneyMiles: 8
) )
XCTAssertEqual(result.fillLitres, 25, accuracy: 0.0001) XCTAssertEqual(result.fillLitres, 26.6667, accuracy: 0.0001)
XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001) XCTAssertEqual(result.grossSavingGBP, 2.6667, accuracy: 0.0001)
XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001) XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001)
XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001) XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001)
XCTAssertEqual(result.netSavingGBP, 1.5908, accuracy: 0.0001) XCTAssertEqual(result.netSavingGBP, 1.7574, accuracy: 0.0001)
XCTAssertEqual(result.status, .worthTheDrive) XCTAssertEqual(result.status, .worthTheDrive)
} }
@@ -61,7 +61,7 @@ final class FuelSavingsCalculatorTests: XCTestCase {
} }
func testInvalidInputsRejected() { func testInvalidInputsRejected() {
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 0)) XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), pricePencePerLitre: 0))
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2)) XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2)) XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
} }
+11 -6
View File
@@ -82,6 +82,7 @@ enum JourneyConfidence: String, Codable, Equatable {
struct FuelSavingsResult: Codable, Equatable { struct FuelSavingsResult: Codable, Equatable {
let fillLitres: Double let fillLitres: Double
let grossSavingGBP: Double let grossSavingGBP: Double
let extraFuelLitres: Double
let extraDrivingCostGBP: Double let extraDrivingCostGBP: Double
let extraDistanceMiles: Double let extraDistanceMiles: Double
let netSavingGBP: Double let netSavingGBP: Double
@@ -91,6 +92,7 @@ struct FuelSavingsResult: Codable, Equatable {
init(fillLitres: Double, init(fillLitres: Double,
grossSavingGBP: Double, grossSavingGBP: Double,
extraFuelLitres: Double = 0,
extraDrivingCostGBP: Double, extraDrivingCostGBP: Double,
extraDistanceMiles: Double, extraDistanceMiles: Double,
netSavingGBP: Double, netSavingGBP: Double,
@@ -99,6 +101,7 @@ struct FuelSavingsResult: Codable, Equatable {
journeyConfidence: JourneyConfidence? = nil) { journeyConfidence: JourneyConfidence? = nil) {
self.fillLitres = fillLitres self.fillLitres = fillLitres
self.grossSavingGBP = grossSavingGBP self.grossSavingGBP = grossSavingGBP
self.extraFuelLitres = extraFuelLitres
self.extraDrivingCostGBP = extraDrivingCostGBP self.extraDrivingCostGBP = extraDrivingCostGBP
self.extraDistanceMiles = extraDistanceMiles self.extraDistanceMiles = extraDistanceMiles
self.netSavingGBP = netSavingGBP self.netSavingGBP = netSavingGBP
@@ -174,6 +177,7 @@ extension FuelSavingsResult {
FuelSavingsResult( FuelSavingsResult(
fillLitres: fillLitres, fillLitres: fillLitres,
grossSavingGBP: 0, grossSavingGBP: 0,
extraFuelLitres: 0,
extraDrivingCostGBP: 0, extraDrivingCostGBP: 0,
extraDistanceMiles: 0, extraDistanceMiles: 0,
netSavingGBP: 0, netSavingGBP: 0,
@@ -199,9 +203,9 @@ extension FuelSavingsResult {
case .breakEven: case .breakEven:
return Self.breakEvenHeadline return Self.breakEvenHeadline
case .worthTheDrive: case .worthTheDrive:
return "\(prefix)\(FuelSavingsFormat.money(netSavingGBP)) better off after travel" return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP))"
case .notWorthTheDrive: case .notWorthTheDrive:
return "\(prefix)\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel" return "\(prefix)Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more"
} }
} }
@@ -321,16 +325,16 @@ enum FuelSavingsCalculator {
distanceMiles / mpg * imperialGallonLitres distanceMiles / mpg * imperialGallonLitres
} }
static func fillLitres(amount: FuelSavingsFillAmount, baselinePricePencePerLitre: Double) throws -> Double { static func fillLitres(amount: FuelSavingsFillAmount, pricePencePerLitre: Double) throws -> Double {
guard amount.isValid else { throw FuelSavingsError.invalidAmount } guard amount.isValid else { throw FuelSavingsError.invalidAmount }
switch amount.unit { switch amount.unit {
case .litres: case .litres:
return amount.value return amount.value
case .pounds: case .pounds:
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else { guard pricePencePerLitre.isFinite, pricePencePerLitre > 0 else {
throw FuelSavingsError.invalidPrice throw FuelSavingsError.invalidPrice
} }
return amount.value / (baselinePricePencePerLitre / 100.0) return amount.value / (pricePencePerLitre / 100.0)
} }
} }
@@ -376,7 +380,7 @@ enum FuelSavingsCalculator {
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else { baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
throw FuelSavingsError.invalidDistance throw FuelSavingsError.invalidDistance
} }
let fillLitres = try fillLitres(amount: amount, baselinePricePencePerLitre: baselinePricePencePerLitre) let fillLitres = try fillLitres(amount: amount, pricePencePerLitre: candidatePricePencePerLitre)
let baselinePriceGBP = baselinePricePencePerLitre / 100.0 let baselinePriceGBP = baselinePricePencePerLitre / 100.0
let candidatePriceGBP = candidatePricePencePerLitre / 100.0 let candidatePriceGBP = candidatePricePencePerLitre / 100.0
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP) let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
@@ -395,6 +399,7 @@ enum FuelSavingsCalculator {
return FuelSavingsResult( return FuelSavingsResult(
fillLitres: fillLitres, fillLitres: fillLitres,
grossSavingGBP: grossSaving, grossSavingGBP: grossSaving,
extraFuelLitres: extraFuelLitres,
extraDrivingCostGBP: extraFuelCost, extraDrivingCostGBP: extraFuelCost,
extraDistanceMiles: extraDistance, extraDistanceMiles: extraDistance,
netSavingGBP: netSaving, netSavingGBP: netSaving,