Compare commits

...
Author SHA1 Message Date
FuelBoard Contributor 5c0458dbf6 Drop deprecated MapKit and ActivityKit calls, and a dead capture
- RoadDistanceService builds route endpoints with MKMapItem(location:address:)
  instead of the MKPlacemark path deprecated in iOS 26.
- LiveActivityManager updates through ActivityContent, replacing the
  ActivityKit update(using:) call deprecated in iOS 16.2.
- The location-update hook no longer captures an unused state object; the
  closure body already runs on self, so behaviour is unchanged.
2026-10-15 21:37:41 +01:00
FuelBoard Contributor 9e77ee2947 Best Value: explain price age per surface instead of one blanket warning
- Station detail now separates the station's own report age from the
  selected fuel's update time, so a recent station-wide report no longer
  reads as an old price.
- Warning copy names the fuel and says which side is weak: the station's
  price is not individually dated, or the baseline's is. The misleading
  "unverified or older than 7 days" line is gone.
- Wording stays "Closest" throughout; new copy uses Unleaded / Premium /
  Diesel rather than the internal grade codes.
- Localisation: new and updated keys for the added copy.
2026-10-15 21:37:35 +01:00
FuelBoard Contributor 7c2eeb2604 Best Value: baseline on the closest eligible price and gate on freshness
- Add FuelPriceFreshness: selected-fuel-aware eligibility plus a shared
  comparison confidence, so a saving is presented as verified only when
  both the candidate and the baseline pass the gate for the fuel on screen.
- Rank against the closest station with an eligible selected-fuel price
  instead of the physically closest station, so a single stale neighbour
  can no longer invalidate every comparison in the list. Ordinary Closest
  sorting still includes every priced station.
- Keep ineligible prices visible, fall back to pump-price order, and label
  Best Value unavailable when no comparison qualifies.
- Widget: same baseline and confidence path as the app.
- Tests: stale-closest baseline, alert baseline, eligibility policy,
  boundary and missing-timestamp regressions.
2026-10-15 21:37:24 +01:00
FuelBoard Contributor 39a97f4905 Standardise onboarding header sizes 2026-10-15 17:23:25 +01:00
FuelBoard Contributor 3819b8a6ab Add Best Value small widget and standardise Closest wording 2026-10-15 17:14:02 +01:00
FuelBoard Contributor a5688cbac2 Clarify fuel savings comparison labels 2026-10-15 11:38:24 +01:00
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 7dbb3d1a15 More info sheet: float the "Copied" capsule over the address row, not the map
The confirmation belongs to the address row, so hovering it over the map read as
belonging to the map. It now overlays the address row itself, horizontally centred
on the address line, as .overlay(alignment: .center) on the row with
allowsHitTesting(false) still keeping the row tappable through the capsule.

Verified: with the capsule up, the OCR box sits at y 1189-1231 inside the address
band (map bottom is 1075), the tint blob is centred at x 602.5 against a screen
centre of 603, the row's ink runs outside the capsule footprint are byte-identical
to the idle frame, and a real tap on the capsule's own footprint still copies.
2026-10-14 13:57:19 +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 6e534ed5b7 More info sheet: float the "Copied" capsule and put the copy glyph on the address line
The address row is back to a single line: the copy glyph now sits on the address
line itself (trailing, plain glyph — the confirmation says it elsewhere), and the
always-reserved second line is gone.

The "Copied" confirmation is now a green-tinted capsule that floats over the
bottom of the map, directly above the address and horizontally centred. It is an
overlay with allowsHitTesting(false), so nothing reflows while it is up (verified:
the address row's ink, column for column, does not move) and the map's tap target
stays live underneath (verified: tapping where the capsule floats still opens
Apple Maps). A material base keeps it legible against map tiles, where a 14% wash
alone — the notice-capsule recipe — disappears. Timing is unchanged: 2s, then
fades, with the success haptic on copy.
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 73db893f4f More info sheet: drop the hairline between the map and the address
The map already reads as its own block, so the row separator below it only
added clutter. Removed with .listRowSeparator(.hidden, edges: .bottom) on the map
row, leaving the Address/Distance and Distance/actions hairlines in place.
2026-10-14 13:16:19 +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 7da09928ca More info sheet: one card for the map, address, distance and the two actions
The map, the address, the distance, any notices and the Directions/Report issue
row now share a single section, in that order, with the buttons as the last row.
The list's own row hairlines separate them (inset like Address/Distance), so no
hand-rolled Divider is added — that would double the line.

The map loses .listRowInsets(EdgeInsets()) so it sits inset inside the card with
its rounded corners, aligned with the hairlines and text rather than butting the
card edges.

Directions/Report issue keep .buttonStyle(.borderless); verified with real
simulated taps in the merged card that each fires alone.
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 666d59c483 More info sheet: give each action button its own tap target
Directions and Report issue both fired on any tap inside their row: with the
default button style a List row has a single row-wide tap target, so tapping
one button triggered the other's action too (tapping Report issue opened Apple
Maps, and tapping Directions showed the report alert).

`.buttonStyle(.borderless)` gives each button its own hit area over its
half-row frame. Verified with real simulated taps on both buttons.
2026-10-14 12:25:16 +01:00
FuelBoard Contributor 311118f0fa Add a DEBUG screen-marker hook so Simulator taps can be verified
`-qaScreenMarker` paints the screen flat magenta, which lets a macOS capture
locate the device-screen rectangle exactly and map simulator-screenshot pixels
to click coordinates. `simctl` cannot drive taps and the project has no UI-test
target, so this is the only way to test real touch behaviour on the sheet.
Guarded by #if DEBUG, exactly like the existing -qaCopyPostcode hook.
2026-10-14 12:25:16 +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 1b2bdd46ea More info sheet: give the two station actions their own card below the map
Sharing the map's section made the buttons read as part of the map card and
the map tap target. They now sit in their own section, rendering as a
separate card directly beneath the map.
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 6983fa83cc More info sheet: put Directions and Report issue on one line under the map
The two station actions now sit together directly beneath the map card, each
taking half the width, instead of being two separate rows at the foot of the
sheet.
2026-10-14 11:52:41 +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 68139e6690 More info sheet: overlay the map tap target, move the copy line below the address
The map tap target is now an OVERLAY button on top of the Map. A Map wrapped
in a Button inside a List row never received the touch, so the first attempt
left the card inert. The Map stays non-interactive and the overlay owns an
explicit rounded-rect content shape.

The copy glyph and its Copied confirmation move onto their own always-present
second line, so the confirmation can no longer reflow the address above it.
Verified: the address line's ink-pixel runs are identical with and without
the confirmation.
2026-10-14 11:26:21 +01:00
FuelBoard ContributorandFuelBoard Contributor d537667aa3 More info sheet: tappable map opens directions, address copies postcode 2026-10-14 11:26:08 +01:00
29 changed files with 1770 additions and 296 deletions
+8 -8
View File
@@ -533,7 +533,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -599,7 +599,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -663,7 +663,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -728,7 +728,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -826,7 +826,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -851,7 +851,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -875,7 +875,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
@@ -900,7 +900,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
+48 -27
View File
@@ -161,7 +161,7 @@ struct ContentView: View {
} }
private func installLocationUpdateHook() { private func installLocationUpdateHook() {
locationManager.onLocationUpdate = { [weak monitor] in locationManager.onLocationUpdate = {
self.updateAlertMonitor() self.updateAlertMonitor()
// The Live Activity follows the same wake-ups — this hook // The Live Activity follows the same wake-ups — this hook
// fires on every fix incl. background significant-change // fires on every fix incl. background significant-change
@@ -401,28 +401,27 @@ struct ContentView: View {
/// (computing a journey for each) and `savingsLookup` immediately scanned them /// (computing a journey for each) and `savingsLookup` immediately scanned them
/// all AGAIN to fill the per-station map. Both now share one result. /// all AGAIN to fill the per-station map. Both now share one result.
/// ///
/// The baseline is chosen with EXACTLY the loop and guards of /// The baseline is chosen with the same journey and selected-fuel freshness
/// `FuelSavingsEstimator.baselineStation`, so the row labelled "Closest /// guards as `FuelSavingsEstimator.bestValueBaselineStation` when configured;
/// station" cannot move — and it is still selected when the feature is /// with the feature off, the ordinary Closest station still names that row.
/// unconfigured (it names that row), while the maps are only built when the
/// labels will actually be shown.
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) { private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
guard let location else { return (nil, [:]) } guard let location else { return (nil, [:]) }
let configured = fuelSavingsSettings.isConfigured guard fuelSavingsSettings.isConfigured else {
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
savingsJourneyMiles(for: $0, location: location)
}
return (baseline, [:])
}
var journeys: [String: FuelSavingsJourney] = [:] var journeys: [String: FuelSavingsJourney] = [:]
var bestStation: FuelStation?
var bestMiles = Double.infinity
for station in stations where station.prices[selectedFuel] != nil { for station in stations where station.prices[selectedFuel] != nil {
guard let journey = savingsJourneyMiles(for: station, location: location) else { continue } if let journey = savingsJourneyMiles(for: station, location: location) {
if configured { journeys[station.id] = journey } journeys[station.id] = journey
let miles = journey.miles
guard miles.isFinite, miles >= 0 else { continue }
if miles < bestMiles {
bestMiles = miles
bestStation = station
} }
} }
return (bestStation, configured ? journeys : [:]) let baseline = FuelSavingsEstimator.bestValueBaselineStation(stations, fuel: selectedFuel) {
journeys[$0.id]
}
return (baseline, journeys)
} }
/// Row/detail savings lookup with the comparison baseline and the per-station /// Row/detail savings lookup with the comparison baseline and the per-station
@@ -455,15 +454,15 @@ struct ContentView: View {
} }
} }
/// The nearest station that sells the selected fuel — the Best Value /// The Closest station with an eligible selected-fuel price — the Best Value
/// comparison baseline. It is chosen with the SAME journey values used by /// comparison baseline. Ordinary Closest sorting still includes stale stations.
/// the savings calculation, so a cached road route can change the baseline /// It uses the same journey values as the savings calculation, so a cached
/// and keep the "extra return travel" labels synchronized with the /// road route can change the baseline and keep the "extra return travel"
/// cost calculation. When no road route is cached, the journey falls back to /// labels synchronized with the cost calculation. Without a cached route,
/// straight-line out-and-back, and the result is marked as an estimate. /// the journey falls back to straight-line and the result is an estimate.
private var nearestSavingsBaseline: FuelStation? { private var nearestSavingsBaseline: FuelStation? {
guard let location else { return nil } guard let location else { return nil }
return FuelSavingsEstimator.baselineStation( return FuelSavingsEstimator.bestValueBaselineStation(
stations, stations,
fuel: selectedFuel, fuel: selectedFuel,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) } journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
@@ -803,6 +802,10 @@ struct ContentView: View {
fuelSavings: savingsLookup(baseline: scaffold.baseline, fuelSavings: savingsLookup(baseline: scaffold.baseline,
journeys: scaffold.journeys)(station), journeys: scaffold.journeys)(station),
fuelSavingsAmount: sessionFuelSavingsAmount, fuelSavingsAmount: sessionFuelSavingsAmount,
savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence.assess(
station: station, fuel: selectedFuel, baseline: scaffold.baseline),
savingsPriceWarning: FuelPriceFreshness.comparisonWarning(
station: station, fuel: selectedFuel, baseline: scaffold.baseline),
savingsBaselineStationName: scaffold.baseline?.name, savingsBaselineStationName: scaffold.baseline?.name,
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline), journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
isFavourite: favouriteIDs.contains(station.id), isFavourite: favouriteIDs.contains(station.id),
@@ -1119,6 +1122,11 @@ struct ContentView: View {
} }
} }
.onChange(of: fuelSavingsSettings) { old, new in .onChange(of: fuelSavingsSettings) { old, new in
// An explicit Settings default edit supersedes the temporary slider
// override. Changes to MPG/enabled alone leave the session fill intact.
if old.preferredAmount != new.preferredAmount {
sessionFuelSavingsAmount = new.preferredAmount
}
// Fuel Savings changed in Settings while the app was running: the // Fuel Savings changed in Settings while the app was running: the
// effective sort follows automatically (no stored sort is touched). // effective sort follows automatically (no stored sort is touched).
#if DEBUG #if DEBUG
@@ -1523,8 +1531,13 @@ struct ContentView: View {
fuelSavingsSettings: fuelSavingsSettings, fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: $sessionFuelSavingsAmount, fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys), savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
savingsBaselineStationName: baselineStation?.name,
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) }, journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
savingsPriceConfidenceForStation: { station in
FuelPriceFreshness.ComparisonConfidence.assess(station: station, fuel: selectedFuel, baseline: baselineStation)
},
savingsPriceWarningForStation: { station in
FuelPriceFreshness.comparisonWarning(station: station, fuel: selectedFuel, baseline: baselineStation)
},
onToggleFavourite: toggleFavourite, onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) } onRefresh: { await refresh(force: true) }
) )
@@ -1545,8 +1558,13 @@ struct ContentView: View {
fuelSavingsSettings: fuelSavingsSettings, fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: $sessionFuelSavingsAmount, fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys), savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
savingsBaselineStationName: baselineStation?.name,
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) }, journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
savingsPriceConfidenceForStation: { station in
FuelPriceFreshness.ComparisonConfidence.assess(station: station, fuel: selectedFuel, baseline: baselineStation)
},
savingsPriceWarningForStation: { station in
FuelPriceFreshness.comparisonWarning(station: station, fuel: selectedFuel, baseline: baselineStation)
},
onToggleFavourite: toggleFavourite, onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites, onReorder: reorderFavourites,
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } }, onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
@@ -1784,6 +1802,8 @@ struct StationRow: View {
/// compact banner does not print it today, but a row that carried a hard /// compact banner does not print it today, but a row that carried a hard
/// zero would print "for £0" the moment it did — pass the real figure. /// zero would print "for £0" the moment it did — pass the real figure.
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var savingsPriceWarning: String? = nil
var onToggleFavourite: () -> Void = {} var onToggleFavourite: () -> Void = {}
var onShowDetails: (FuelStation) -> Void = { _ in } var onShowDetails: (FuelStation) -> Void = { _ in }
@@ -1944,7 +1964,8 @@ struct StationRow: View {
FuelSavingsSummaryView( FuelSavingsSummaryView(
availability: fuelSavings, availability: fuelSavings,
amount: fuelSavingsAmount, amount: fuelSavingsAmount,
baselineStationName: nil, priceConfidence: savingsPriceConfidence,
priceWarning: savingsPriceWarning,
compact: true, compact: true,
distanceUnit: distanceUnit distanceUnit: distanceUnit
) )
+15 -11
View File
@@ -21,11 +21,12 @@ struct FavouritesView: View {
/// still never touches the Settings default. /// still never touches the Settings default.
let fuelSavingsSettings: FuelSavingsSettings let fuelSavingsSettings: FuelSavingsSettings
@Binding var fuelSavingsAmount: FuelSavingsFillAmount @Binding var fuelSavingsAmount: FuelSavingsFillAmount
/// Estimated saving for a favourite, against the nearest eligible station — /// Estimated saving for a favourite, against the closest eligible station by
/// the same baseline and definition the Stations list uses. /// journey distance — the same baseline and definition the Stations list uses.
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil } var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var savingsBaselineStationName: String?
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil } var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var savingsPriceConfidenceForStation: (FuelStation) -> FuelPriceFreshness.ComparisonConfidence
var savingsPriceWarningForStation: (FuelStation) -> String? = { _ in nil }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop). /// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in } var onReorder: ([FavouriteEntry]) -> Void = { _ in }
@@ -84,8 +85,9 @@ struct FavouritesView: View {
fuelSavingsSettings: FuelSavingsSettings = .defaults, fuelSavingsSettings: FuelSavingsSettings = .defaults,
fuelSavingsAmount: Binding<FuelSavingsFillAmount>, fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil }, savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
savingsBaselineStationName: String? = nil,
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }, journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
savingsPriceConfidenceForStation: @escaping (FuelStation) -> FuelPriceFreshness.ComparisonConfidence = { _ in .ineligible },
savingsPriceWarningForStation: @escaping (FuelStation) -> String? = { _ in nil },
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }, onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }, onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
onHistoryUnavailable: (() -> Void)? = nil, onHistoryUnavailable: (() -> Void)? = nil,
@@ -99,8 +101,9 @@ struct FavouritesView: View {
self.fuelSavingsSettings = fuelSavingsSettings self.fuelSavingsSettings = fuelSavingsSettings
_fuelSavingsAmount = fuelSavingsAmount _fuelSavingsAmount = fuelSavingsAmount
self.savingsForStation = savingsForStation self.savingsForStation = savingsForStation
self.savingsBaselineStationName = savingsBaselineStationName
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
self.savingsPriceConfidenceForStation = savingsPriceConfidenceForStation
self.savingsPriceWarningForStation = savingsPriceWarningForStation
self.onToggleFavourite = onToggleFavourite self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder self.onReorder = onReorder
self.onHistoryUnavailable = onHistoryUnavailable self.onHistoryUnavailable = onHistoryUnavailable
@@ -147,12 +150,9 @@ struct FavouritesView: View {
if fuelSavingsSettings.isConfigured { if fuelSavingsSettings.isConfigured {
Section { Section {
FuelSavingsAdjustmentView( FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
amount: $fuelSavingsAmount,
referencePricePencePerLitre: cheapestPrice
)
} footer: { } footer: {
Text(String(localized: "Savings below are estimated against the nearest station with a price for this fuel — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation")) Text(String(localized: "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
} }
} }
@@ -171,6 +171,8 @@ struct FavouritesView: View {
tapAction: stationTapAction, tapAction: stationTapAction,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount, fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
onToggleFavourite: { onToggleFavourite(station, activeFuel) }, onToggleFavourite: { onToggleFavourite(station, activeFuel) },
onShowDetails: { detailStation = $0 } onShowDetails: { detailStation = $0 }
) )
@@ -219,9 +221,11 @@ struct FavouritesView: View {
station: station, station: station,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
selectedFuel: activeFuel,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount, fuelSavingsAmount: fuelSavingsAmount,
savingsBaselineStationName: savingsBaselineStationName, savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
journeyDiagnostics: journeyDiagnosticsForStation(station), journeyDiagnostics: journeyDiagnosticsForStation(station),
// Every station listed here IS a favourite for the tab's // Every station listed here IS a favourite for the tab's
// fuel, so the star starts filled; tapping it unstars. // fuel, so the star starts filled; tapping it unstars.
+19 -1
View File
@@ -21,7 +21,7 @@ struct FuelBoardApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() rootView
.onOpenURL(perform: handleOpenURL) .onOpenURL(perform: handleOpenURL)
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in .onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
WatchSyncManager.shared.pushSnapshot() WatchSyncManager.shared.pushSnapshot()
@@ -32,6 +32,24 @@ struct FuelBoardApp: App {
} }
} }
/// QA hook (Debug builds only): `-qaScreenMarker` paints the whole screen a
/// flat magenta. A macOS capture of the Simulator window can then locate the
/// device-screen rectangle exactly, which yields a pixel-accurate mapping
/// from simulator screenshots to click coordinates. That is the only way to
/// drive REAL taps into the Simulator: `simctl` cannot, and this project has
/// no UI-test target. Everything else renders ContentView as normal.
@ViewBuilder private var rootView: some View {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("-qaScreenMarker") {
Color(red: 1, green: 0, blue: 1).ignoresSafeArea()
} else {
ContentView()
}
#else
ContentView()
#endif
}
#if DEBUG #if DEBUG
private func startQALiveActivity(fuel: FuelType) { private func startQALiveActivity(fuel: FuelType) {
let state = FuelBoardLiveActivityAttributes.ContentState( let state = FuelBoardLiveActivityAttributes.ContentState(
+79 -16
View File
@@ -2,7 +2,8 @@ import SwiftUI
struct FuelSavingsAdjustmentView: View { struct FuelSavingsAdjustmentView: View {
@Binding var amount: FuelSavingsFillAmount @Binding var amount: FuelSavingsFillAmount
let referencePricePencePerLitre: Double?
@State private var unitChangeNotice: String?
private var range: ClosedRange<Double> { private var range: ClosedRange<Double> {
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit) FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
@@ -44,6 +45,14 @@ struct FuelSavingsAdjustmentView: View {
Text("Changes the estimate for this session only — your Settings default stays unchanged.") Text("Changes the estimate for this session only — your Settings default stays unchanged.")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .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)
}
} }
} }
@@ -52,12 +61,10 @@ struct FuelSavingsAdjustmentView: View {
get: { amount.unit }, get: { amount.unit },
set: { newUnit in set: { newUnit in
guard newUnit != amount.unit else { return } guard newUnit != amount.unit else { return }
if let referencePricePencePerLitre, // No one litre figure equals a budget at every candidate price.
let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) { // A unit change starts a new, explicitly labelled comparison.
amount = converted amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
} else { unitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: newUnit)
}
} }
) )
} }
@@ -73,11 +80,19 @@ struct FuelSavingsAdjustmentView: View {
struct FuelSavingsSummaryView: View { struct FuelSavingsSummaryView: View {
let availability: FuelSavingsAvailability? let availability: FuelSavingsAvailability?
let amount: FuelSavingsFillAmount let amount: FuelSavingsFillAmount
let baselineStationName: String? /// Candidate/baseline price confidence for every Best Value result.
var priceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var priceWarning: String? = nil
var baselineStationName: String? = nil
var compact: Bool = false var compact: Bool = false
/// 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
private func comparisonLabel(for result: FuelSavingsResult) -> String {
FuelSavingsFormat.comparisonLabel(status: result.status)
}
var body: some View { var body: some View {
switch availability { switch availability {
@@ -92,8 +107,22 @@ struct FuelSavingsSummaryView: View {
// break even, blue baseline. lineLimit + minimumScaleFactor keep // break even, blue baseline. lineLimit + minimumScaleFactor keep
// it to one line, so rows stay even. // it to one line, so rows stay even.
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
capsule(for: result, compact: true) HStack(alignment: .firstTextBaseline, spacing: 6) {
.accessibilityLabel(accessibilityHeadline(for: result)) capsule(for: result, compact: true)
.accessibilityLabel(accessibilityHeadline(for: result))
Text(comparisonLabel(for: result))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.layoutPriority(1)
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
}
if let warning = priceWarning ?? priceConfidence.warningText {
Text(warning)
.font(.caption2)
.foregroundStyle(.orange)
.accessibilityLabel(warning)
}
if let extraTravel = FuelSavingsFormat.extraTravelLabel( if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles, extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit, unit: distanceUnit,
@@ -117,11 +146,30 @@ struct FuelSavingsSummaryView: View {
// room for. The old shape led with a status word + headline that // room for. The old shape led with a status word + headline that
// matched neither the listing nor each other. // matched neither the listing nor each other.
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
capsule(for: result, compact: false) HStack(alignment: .firstTextBaseline, spacing: 6) {
.accessibilityLabel(accessibilityHeadline(for: result)) capsule(for: result, compact: false)
.accessibilityLabel(accessibilityHeadline(for: result))
Text(comparisonLabel(for: result))
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.layoutPriority(1)
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
}
LabeledContent("Your fill-up", value: amountText) LabeledContent("Your fill-up", value: amountText)
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres)) LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
.monospacedDigit() .monospacedDigit()
if amount.unit == .pounds {
Text("You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund.")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let warning = priceWarning ?? priceConfidence.warningText {
Text(warning)
.font(.caption2)
.foregroundStyle(.orange)
.accessibilityLabel(warning)
}
if let extraTravel = FuelSavingsFormat.extraTravelLabel( if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles, extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit, unit: distanceUnit,
@@ -133,11 +181,11 @@ struct FuelSavingsSummaryView: View {
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel)) .accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
} }
if result.status == .baseline { if result.status == .baseline {
Text("Your closest station with a price for this fuel — every other saving is measured against it.") Text("Your closest eligible station by journey distance is the baseline — every other saving is measured against it.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
Text("Compared with \(baselineStationName ?? "your nearest station").") Text(baselineStationName.map { "Compared with \($0)." } ?? "Compared with your closest eligible station.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
LabeledContent( LabeledContent(
@@ -166,6 +214,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):
@@ -196,7 +260,7 @@ struct FuelSavingsSummaryView: View {
} }
/// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for /// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for
/// estimated comparisons and leaves the plain words ("Break even", "Closest /// and leaves the plain words ("Break even", "Closest eligible
/// station") alone — one source, so visible and spoken copy cannot drift. /// station") alone — one source, so visible and spoken copy cannot drift.
private func accessibilityHeadline(for result: FuelSavingsResult) -> String { private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
result.spokenHeadline() result.spokenHeadline()
@@ -217,7 +281,6 @@ struct FuelSavingsSummaryView: View {
.accessibilityHidden(true) .accessibilityHidden(true)
Text(result.capsuleHeadline()) Text(result.capsuleHeadline())
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.8)
} }
.font(compact ? .caption.weight(.semibold) : .headline) .font(compact ? .caption.weight(.semibold) : .headline)
.foregroundStyle(capsuleColor(for: result.status)) .foregroundStyle(capsuleColor(for: result.status))
+71
View File
@@ -0,0 +1,71 @@
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 eligible station by journey distance. 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("With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest eligible station, then deduct extra journey fuel. You do not receive cash back.")
Text("With Litres, every station is compared for the fixed quantity you selected.")
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
+1 -1
View File
@@ -79,7 +79,7 @@ enum LiveActivityManager {
currentStartDate = nil currentStartDate = nil
start(attributes: attributes, state: state) start(attributes: attributes, state: state)
} else { } else {
Task { await current.update(using: state) } Task { await current.update(ActivityContent(state: state, staleDate: nil)) }
} }
} else { } else {
start(attributes: attributes, state: state) start(attributes: attributes, state: state)
+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,9 +108,9 @@ 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(.largeTitle.bold())
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -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()
} }
@@ -155,7 +156,7 @@ struct OnboardingView: View {
permissionStatusLabel( permissionStatusLabel(
granted: prompter.locationGranted, granted: prompter.locationGranted,
denied: prompter.locationDenied, denied: prompter.locationDenied,
deniedText: String(localized: "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it.", comment: "Onboarding location denied text") deniedText: String(localized: "Location was denied. You can still browse all stations, but \"closest\" sorting needs it.", comment: "Onboarding location denied text")
) )
.padding(.top, 24) .padding(.top, 24)
@@ -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)
+9 -2
View File
@@ -13,6 +13,7 @@
// every row at once; the mode must also be ON for any pass to run at all. // every row at once; the mode must also be ON for any pass to run at all.
import Foundation import Foundation
import CoreLocation
import MapKit import MapKit
import UIKit import UIKit
import WidgetKit import WidgetKit
@@ -407,8 +408,14 @@ enum RoadDistanceService {
/// Driving distance (metres) between two coordinates via Apple Maps routing. /// Driving distance (metres) between two coordinates via Apple Maps routing.
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? { private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
let request = MKDirections.Request() let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from)) request.source = MKMapItem(
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to)) location: CLLocation(latitude: from.latitude, longitude: from.longitude),
address: nil
)
request.destination = MKMapItem(
location: CLLocation(latitude: to.latitude, longitude: to.longitude),
address: nil
)
request.transportType = .automobile request.transportType = .automobile
request.requestsAlternateRoutes = false request.requestsAlternateRoutes = false
do { do {
+11 -5
View File
@@ -79,6 +79,7 @@ struct SettingsView: View {
/// Return key, so the keyboard's Done button clears this to dismiss AND /// Return key, so the keyboard's Done button clears this to dismiss AND
/// commit the typed value. Both Fuel Savings controls are sliders now, so /// commit the typed value. Both Fuel Savings controls are sliders now, so
/// nothing sets it — it stays for any numeric field this screen takes again. /// nothing sets it — it stays for any numeric field this screen takes again.
@State private var settingsUnitChangeNotice: String?
@FocusState private var focusedSavingsField: SavingsField? @FocusState private var focusedSavingsField: SavingsField?
private enum SavingsField: Hashable { private enum SavingsField: Hashable {
@@ -148,6 +149,11 @@ struct SettingsView: View {
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit)) step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit))
.accessibilityLabel("Typical fill-up") .accessibilityLabel("Typical fill-up")
.accessibilityValue(fillUpText) .accessibilityValue(fillUpText)
if let settingsUnitChangeNotice {
Text(settingsUnitChangeNotice)
.font(.caption2)
.foregroundStyle(.orange)
}
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled { if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.") Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
.font(.caption) .font(.caption)
@@ -285,7 +291,7 @@ struct SettingsView: View {
} header: { } header: {
Text("Debug") Text("Debug")
} footer: { } footer: {
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.") Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
} }
.onAppear { onRefreshDebugStatus() } .onAppear { onRefreshDebugStatus() }
@@ -709,10 +715,10 @@ struct SettingsView: View {
Binding( Binding(
get: { fuelSavingsSettings.preferredAmount.unit }, get: { fuelSavingsSettings.preferredAmount.unit },
set: { newUnit in set: { newUnit in
fuelSavingsSettings.preferredAmount.unit = newUnit guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return }
if !fuelSavingsSettings.preferredAmount.isValid { fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
fuelSavingsSettings.preferredAmount.value = newUnit == .pounds ? 40 : 30 afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
} settingsUnitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
persistFuelSavingsSettings() persistFuelSavingsSettings()
} }
) )
+210 -25
View File
@@ -17,8 +17,12 @@ struct StationDetailView: View {
let station: FuelStation let station: FuelStation
let location: Coordinate? let location: Coordinate?
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
/// Fuel whose price is being shown by the surrounding station list.
var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
var fuelSavings: FuelSavingsAvailability? = nil var fuelSavings: FuelSavingsAvailability? = nil
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var savingsPriceWarning: String? = nil
var savingsBaselineStationName: String? = nil var savingsBaselineStationName: String? = nil
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
/// Whether this station is starred for the fuel currently being viewed, and /// Whether this station is starred for the fuel currently being viewed, and
@@ -29,6 +33,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 "Postcode copied" confirmation on the Address row. Cleared by a task so
/// the caption fades out on its own.
@State private var showingCopied = false
@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")!
@@ -36,10 +47,73 @@ struct StationDetailView: View {
CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng) CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
} }
/// The row's address text — the same shared line the listing row uses, with
/// any leading repeat of the station name stripped.
private var addressLine: String {
StationAddressLine.display(address: station.address,
postcode: station.postcode,
name: station.name)
}
/// The station's postcode, trimmed. Empty means there is nothing to copy and
/// the Address row stays inert.
private var postcodeForCopying: String {
station.postcode.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var priceFreshness: FuelPriceFreshness.Assessment {
FuelPriceFreshness.assessment(for: station, fuel: selectedFuel)
}
/// The selected grade's current price, when present. Kept separate from
/// the freshness label so a stale price remains visible rather than being
/// silently hidden.
private var selectedFuelPriceText: String {
guard let price = station.prices[selectedFuel] else { return "—" }
return FuelStore.priceText(price, style: .stationSign)
}
/// Opens Apple Maps driving directions — the same destination (and the same
/// URL) the Directions button below uses, so the map and the button can
/// never point at two different places.
private func openDirections() {
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
}
/// Copies ONLY the postcode, so it can be pasted into another navigation app
/// without dragging the station name and street along with it. Confirms with
/// a light haptic and a caption that fades after ~2s.
private func copyPostcode() {
let postcode = postcodeForCopying
guard !postcode.isEmpty else { return }
UIPasteboard.general.string = postcode
UINotificationFeedbackGenerator().notificationOccurred(.success)
withAnimation(.easeInOut(duration: 0.2)) { showingCopied = true }
copyResetTask?.cancel()
copyResetTask = Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard !Task.isCancelled else { return }
withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false }
}
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
Section { Section {
// Tapping anywhere on the map opens the same Apple Maps
// directions the Directions button below opens.
//
// The tap target is an OVERLAY Button laid ON TOP of the
// Map, not a Button wrapping it. A Map runs its own UIKit
// view, and inside a List row the wrapped-Map form left the
// card untappable — the touch never reached the button. An
// overlay sits above the map, owns an explicit rounded-rect
// content shape, and cannot be intercepted by MapKit. The
// Map itself stays non-interactive: it is a preview, not
// something to pan.
Map(position: .constant(.region(MKCoordinateRegion( Map(position: .constant(.region(MKCoordinateRegion(
center: stationCoordinate, center: stationCoordinate,
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
@@ -50,16 +124,88 @@ struct StationDetailView: View {
.frame(height: 180) .frame(height: 180)
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
.allowsHitTesting(false) .allowsHitTesting(false)
.listRowInsets(EdgeInsets()) .overlay {
} Button {
Section { openDirections()
// The station name is NOT repeated here: the sheet header } label: {
// (navigationTitle, inline) already shows it, and the address Color.clear
// line below has any leading repeat of the name stripped, so .contentShape(RoundedRectangle(cornerRadius: 12))
// a "Name" row only said it twice. }
LabeledContent("Address", value: StationAddressLine.display(address: station.address, .buttonStyle(.plain)
postcode: station.postcode, .accessibilityLabel(String(localized: "Directions to \(station.name)",
name: station.name)) comment: "Accessibility label for the tappable map preview in the More info sheet"))
.accessibilityHint(String(localized: "Opens Apple Maps with driving directions",
comment: "Accessibility hint for the tappable map preview in the More info sheet"))
}
// No hairline between the map and the Address row: the map
// already reads as its own block, so the line only added
// clutter. The Address/Distance and Distance/actions lines
// stay.
.listRowSeparator(.hidden, edges: .bottom)
// The map, the address, the distance, any notices and the two
// station actions all share ONE card (the list's own row
// hairlines separate them), so no hand-rolled Divider here —
// adding one would double the line.
//
// The station name is NOT repeated: the sheet header already
// shows it.
//
// The whole row is a copy control: it puts JUST the postcode
// on the clipboard so it can be pasted into another
// navigation app. The copy glyph sits ON the address line,
// and the "Postcode copied" confirmation is a capsule that floats OVER
// THIS ROW — it confirms the address, so it must not hover
// over the map. It is an overlay, so nothing moves while it is
// up, and `allowsHitTesting(false)` keeps the row tappable
// through the capsule.
Button {
copyPostcode()
} label: {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("Address")
Spacer(minLength: 8)
Text(addressLine)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
// Stays the plain copy glyph — the capsule carries
// the confirmation, so swapping this to a checkmark
// would say it twice.
Image(systemName: "doc.on.doc")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.disabled(postcodeForCopying.isEmpty)
.accessibilityLabel(String(localized: "Address: \(addressLine)",
comment: "Accessibility label for the address row in the More info sheet"))
.accessibilityHint(postcodeForCopying.isEmpty
? ""
: String(localized: "Copies the postcode",
comment: "Accessibility hint for the address row in the More info sheet"))
// Horizontally centred over the address line itself.
.overlay(alignment: .center) {
if showingCopied {
HStack(spacing: 4) {
Image(systemName: "checkmark")
Text("Postcode copied")
}
.font(.footnote.weight(.semibold))
.foregroundStyle(.green)
.padding(.horizontal, 10)
.padding(.vertical, 6)
// A material base keeps it legible where the tint
// alone (the notice-capsule recipe) washes out.
.background {
Capsule()
.fill(.regularMaterial)
.overlay(Capsule().fill(Color.green.opacity(0.22)))
}
.allowsHitTesting(false)
.accessibilityHidden(true)
.transition(.opacity)
}
}
if let location { if let location {
let distance = FuelStore.stationDistanceDisplay( let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng station: station, userLat: location.lat, userLng: location.lng
@@ -67,6 +213,20 @@ struct StationDetailView: View {
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit)) LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
.monospacedDigit() .monospacedDigit()
} }
if let reportAge = priceFreshness.stationReportAgeLabel {
LabeledContent("Station price report", value: reportAge)
.monospacedDigit()
}
LabeledContent("\(selectedFuel.displayName) update time", value: priceFreshness.selectedFuelUpdateLabel)
.monospacedDigit()
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
.monospacedDigit()
if priceFreshness.warning {
Label("Price freshness is not confirmed for \(selectedFuel.displayName)",
systemImage: "exclamationmark.triangle.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
}
ForEach(StationNotice.notices(for: station)) { notice in ForEach(StationNotice.notices(for: station)) { notice in
Label(notice.title, systemImage: notice.symbolName) Label(notice.title, systemImage: notice.symbolName)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
@@ -75,6 +235,28 @@ struct StationDetailView: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Capsule().fill(Color.orange.opacity(0.14))) .background(Capsule().fill(Color.orange.opacity(0.14)))
} }
// The two station actions are the LAST row of the card: half
// the width each, on one line, below Address and Distance.
HStack(spacing: 12) {
Button {
openDirections()
} label: {
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
.frame(maxWidth: .infinity)
}
Button(role: .destructive) {
showingReportIssueConfirmation = true
} label: {
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
.frame(maxWidth: .infinity)
}
}
// REQUIRED: with the default button style a `List` row has ONE
// row-wide tap target, so two buttons in the same row BOTH fire
// on any tap inside the row (tapping Directions also showed the
// report-issue alert and vice versa). `.borderless` gives each
// button its own hit area — the half-row frame above.
.buttonStyle(.borderless)
} }
// Only shown while Fuel Savings backs it. `fuelSavings` is nil // Only shown while Fuel Savings backs it. `fuelSavings` is nil
// when the feature is off — `savingsLookup` returns nil when it // when the feature is off — `savingsLookup` returns nil when it
@@ -85,8 +267,11 @@ struct StationDetailView: View {
FuelSavingsSummaryView( FuelSavingsSummaryView(
availability: fuelSavings, availability: fuelSavings,
amount: fuelSavingsAmount, amount: fuelSavingsAmount,
priceConfidence: savingsPriceConfidence,
priceWarning: savingsPriceWarning,
baselineStationName: savingsBaselineStationName, baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit distanceUnit: distanceUnit,
showInfoAction: { showBestValueInfo = true }
) )
} header: { } header: {
Text("Best Value") Text("Best Value")
@@ -105,20 +290,6 @@ struct StationDetailView: View {
} }
#endif #endif
Section {
Button {
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
} label: {
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
}
Button(role: .destructive) {
showingReportIssueConfirmation = true
} label: {
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
}
}
} }
.navigationTitle(station.name) .navigationTitle(station.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -130,6 +301,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() }
@@ -149,6 +322,18 @@ struct StationDetailView: View {
) )
} }
} }
// The "Postcode copied" fade-out task must not outlive the sheet.
.onDisappear { copyResetTask?.cancel() }
#if DEBUG
// QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the
// clipboard value (read back with `xcrun simctl pbpaste`) and the
// "Postcode copied" confirmation can be verified without a tap — simctl
// cannot drive taps into the Simulator.
.task {
guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return }
copyPostcode()
}
#endif
} }
} }
} }
+92 -52
View File
@@ -19,8 +19,9 @@ struct StationsView: View {
let fuelSavingsSettings: FuelSavingsSettings let fuelSavingsSettings: FuelSavingsSettings
@Binding var fuelSavingsAmount: FuelSavingsFillAmount @Binding var fuelSavingsAmount: FuelSavingsFillAmount
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil } var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var savingsBaselineStationName: String?
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil } var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var savingsPriceConfidenceForStation: (FuelStation) -> FuelPriceFreshness.ComparisonConfidence
var savingsPriceWarningForStation: (FuelStation) -> String?
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onRefresh: () async -> Void = {} var onRefresh: () async -> Void = {}
@@ -35,6 +36,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?
@@ -63,7 +69,7 @@ struct StationsView: View {
} }
if location != nil { if location != nil {
if sortMode == .closest { if sortMode == .closest {
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated" return "\(mode) \(fuel) stations — closest first, lowest price within \(miles) \(unit) · \(ratio) stations updated"
} }
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)" return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
} }
@@ -89,7 +95,7 @@ struct StationsView: View {
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
// In Closest mode the whole country is the pool — the // In Closest mode the whole country is the pool — the
// radius is meaningless ("nearest" must never return an // radius is meaningless ("closest" must never return an
// empty state), so the picker is disabled but its value is // empty state), so the picker is disabled but its value is
// kept for when the user switches back to Cheapest. // kept for when the user switches back to Cheapest.
.disabled(sortMode == .closest) .disabled(sortMode == .closest)
@@ -116,10 +122,12 @@ struct StationsView: View {
// stale stored sort mode must not keep the control on screen // stale stored sort mode must not keep the control on screen
// for a switched-off feature. // for a switched-off feature.
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured { if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
FuelSavingsAdjustmentView( FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
amount: $fuelSavingsAmount, if !stations.isEmpty && stations.allSatisfy({ savingsPriceConfidenceForStation($0) == .ineligible }) {
referencePricePencePerLitre: baselinePrice Text("Best Value unavailable: no eligible price comparison. Stations and prices remain visible below.")
) .font(.caption)
.foregroundStyle(.orange)
}
} }
if isLoading && stations.isEmpty { if isLoading && stations.isEmpty {
@@ -144,22 +152,7 @@ struct StationsView: View {
.padding(.vertical, 4) .padding(.vertical, 4)
} else { } else {
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
StationRow( stationRow(station)
station: station,
fuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice,
isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id),
topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
tapAction: stationTapAction,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
onShowDetails: { detailStation = $0 }
)
} }
if visibleCount < stations.count { if visibleCount < stations.count {
@@ -218,6 +211,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) {
@@ -243,6 +248,31 @@ struct StationsView: View {
} }
} }
.sheet(isPresented: $showKey) { .sheet(isPresented: $showKey) {
keySheet
}
.sheet(item: $detailStation) { station in
detailSheet(for: station)
}
}
}
private func detailSheet(for station: FuelStation) -> some View {
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit,
selectedFuel: selectedFuel,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
journeyDiagnostics: journeyDiagnosticsForStation(station),
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
)
}
private var keySheet: some View {
NavigationStack { NavigationStack {
List { List {
Section { Section {
@@ -250,14 +280,14 @@ struct StationsView: View {
keyCard( keyCard(
icon: "checkmark.circle.fill", color: .green, icon: "checkmark.circle.fill", color: .green,
title: "Good price", 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,25 +319,26 @@ 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))
} }
Section("How Best Value works") { // The explanation is its own sheet now, opened from this
VStack(alignment: .leading, spacing: 10) { // row. Gated exactly like the More info row and the Best
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.") // Value card itself: `isConfigured` is the same condition
Text("Your fill-up amount and MPG are used to show whether you could save overall.") // `savingsForStation` uses, so the row appears and
Text("Results are marked “Approx.” until road distances are available.") // disappears with the feature.
.foregroundStyle(.secondary) if fuelSavingsSettings.isConfigured {
Section {
HowBestValueRow { showBestValueInfo = true }
} }
.font(.subheadline)
.padding(.vertical, 4)
} }
} }
.navigationTitle("Key") .navigationTitle("Key")
@@ -317,24 +348,32 @@ 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))
} }
.sheet(item: $detailStation) { station in
StationDetailView( private func stationRow(_ station: FuelStation) -> some View {
station: station, StationRow(
location: location, station: station,
distanceUnit: distanceUnit, fuel: selectedFuel,
fuelSavings: savingsForStation(station), location: location,
fuelSavingsAmount: fuelSavingsAmount, distanceUnit: distanceUnit,
savingsBaselineStationName: savingsBaselineStationName, priceDisplayStyle: priceDisplayStyle,
journeyDiagnostics: journeyDiagnosticsForStation(station), baselinePrice: baselinePrice,
isFavourite: favouriteIDs.contains(station.id), isTopResult: station.id == topStationID,
onToggleFavourite: { onToggleFavourite(station, selectedFuel) } isFavourite: favouriteIDs.contains(station.id),
) topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
} tapAction: stationTapAction,
} fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
onShowDetails: { detailStation = $0 }
)
} }
private var availableSortModes: [SortMode] { private var availableSortModes: [SortMode] {
@@ -358,6 +397,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) {
+3 -2
View File
@@ -76,7 +76,7 @@ struct WidgetMockScreen: View {
let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) } let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
// 5 miles — the widget config's default Distance. WidgetDistance itself // 5 miles — the widget config's default Distance. WidgetDistance itself
// lives in the widget target, so the preview mirrors its default here. // lives in the widget target, so the preview mirrors its default here.
let radiusKM = 5 * 1.60934 let radiusKM = 5 * FuelSavingsCalculator.kilometresPerMile
let all = FuelStore.loadStations() let all = FuelStore.loadStations()
let selling = all.filter { station in let selling = all.filter { station in
guard station.prices[fuel] != nil else { return false } guard station.prices[fuel] != nil else { return false }
@@ -94,7 +94,8 @@ struct WidgetMockScreen: View {
isFavourites: false, isFavourites: false,
location: location, location: location,
locationSource: location != nil ? "live" : "none", locationSource: location != nil ? "live" : "none",
unit: FuelStore.loadDistanceUnit() unit: FuelStore.loadDistanceUnit(),
bestValueResult: nil
) )
} }
} }
+37 -8
View File
@@ -12,7 +12,7 @@
"TOP" = "TOP"; "TOP" = "TOP";
/* Stations tab */ /* Stations tab */
"Closest %@ stations — nearest first, best value within %lld %@ · %lld stations" = "Closest %@ stations — nearest first, best value within %lld %@ · %lld stations"; "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations";
"Cheapest %@ within %lld %@ — %lld stations · tap a station for directions." = "Cheapest %@ within %lld %@ — %lld stations · tap a station for directions."; "Cheapest %@ within %lld %@ — %lld stations · tap a station for directions." = "Cheapest %@ within %lld %@ — %lld stations · tap a station for directions.";
"%@ %@ — tap a station for directions." = "%@ %@ — tap a station for directions."; "%@ %@ — tap a station for directions." = "%@ %@ — tap a station for directions.";
"Fuel type" = "Fuel type"; "Fuel type" = "Fuel type";
@@ -26,17 +26,20 @@
"Good price" = "Good price"; "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"; "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."; "Best Value compares each station with the closest eligible station by journey distance. 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 eligible station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
"With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest eligible station, then deduct extra journey fuel. You do not receive cash back." = "With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest eligible station, then deduct extra journey fuel. You do not receive cash back.";
"Your closest eligible station by journey distance is the baseline — every other saving is measured against it." = "Your closest eligible station by journey distance is the baseline — every other saving is measured against it.";
"Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list." = "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.";
"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."; "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."; "Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
@@ -70,13 +73,17 @@
"More info" = "More info"; "More info" = "More info";
"Name" = "Name"; "Name" = "Name";
"Address" = "Address"; "Address" = "Address";
"Distance" = "Distance"; "Copies the postcode" = "Copies the postcode";
"Postcode copied" = "Postcode copied";
"Address: %@" = "Address: %@";
"Directions to %@" = "Directions to %@";
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
"Show introduction" = "Replay onboarding"; "Show introduction" = "Replay onboarding";
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts."; "Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
"Test alert notification (real data)" = "Test alert notification (real data)"; "Test alert notification (real data)" = "Test alert notification (real data)";
"Send plain test notification" = "Send plain test notification"; "Send plain test notification" = "Send plain test notification";
"Debug" = "Debug"; "Debug" = "Debug";
"The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius." = "The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius."; "The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius." = "The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.";
"Widget Diagnostics" = "Widget Diagnostics"; "Widget Diagnostics" = "Widget Diagnostics";
"Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter." = "Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter."; "Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter." = "Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter.";
"SMALL: %@" = "SMALL: %@"; "SMALL: %@" = "SMALL: %@";
@@ -134,7 +141,7 @@
"Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day." = "Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day."; "Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day." = "Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day.";
"Prices near you" = "Prices near you"; "Prices near you" = "Prices near you";
"FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device." = "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device."; "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device." = "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.";
"Location was denied. You can still browse all stations, but \"nearest\" sorting needs it." = "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it."; "Location was denied. You can still browse all stations, but \"closest\" sorting needs it." = "Location was denied. You can still browse all stations, but \"closest\" sorting needs it.";
"Allowed" = "Allowed"; "Allowed" = "Allowed";
"The system prompt will appear next." = "The system prompt will appear next."; "The system prompt will appear next." = "The system prompt will appear next.";
"FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab." = "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab."; "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab." = "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab.";
@@ -212,3 +219,25 @@
"A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle." = "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle."; "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle." = "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.";
"Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires." = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires."; "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires." = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.";
"%@ — %@" = "%@ — %@"; "%@ — %@" = "%@ — %@";
/* Best Value calculation and price confidence */
"£ compares the litres your budget buys at each station; Litres compares one fixed quantity." = "£ compares the litres your budget buys at each station; Litres compares one fixed quantity.";
"You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund." = "You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund.";
"Price age" = "Price age";
"Best Value unavailable · price confidence insufficient" = "Best Value unavailable · price confidence insufficient";
"Best Value unavailable · location needed" = "Best Value unavailable · location needed";
"price order · Best Value unavailable" = "price order · Best Value unavailable";
"your order" = "your order";
"Price not individually dated — saving is an estimate." = "Price not individually dated — saving is an estimate.";
"Price age unavailable or too old — not a Best Value recommendation." = "Price age unavailable or too old — not a Best Value recommendation.";
"%@ ago" = "%@ ago";
"Not provided" = "Not provided";
"Station price report" = "Station price report";
"%@ update time" = "%@ update time";
"Estimated saving · %@ price not individually dated" = "Estimated saving · %@ price not individually dated";
"Estimated saving · closest eligible station's %@ price not individually dated" = "Estimated saving · closest eligible station's %@ price not individually dated";
"Estimated saving · %@ price over 7 days old" = "Estimated saving · %@ price over 7 days old";
"Estimated saving · closest eligible station's %@ price over 7 days old" = "Estimated saving · closest eligible station's %@ price over 7 days old";
"Fuel you'll receive" = "Fuel you'll receive";
"Best Value unavailable: no eligible price comparison. Stations and prices remain visible below." = "Best Value unavailable: no eligible price comparison. Stations and prices remain visible below.";
"Compared with your closest eligible station." = "Compared with your closest eligible station.";
@@ -0,0 +1 @@
../../../Shared/FuelPriceFreshness.swift
@@ -211,10 +211,10 @@ final class DistanceTests: XCTestCase {
} }
func testMilesConversion() { func testMilesConversion() {
// 5 miles ≈ 8.05 km (app uses × 1.60934) // 5 miles ≈ 8.05 km using the shared exact conversion
XCTAssertEqual(5 * 1.60934, 8.0467, accuracy: 0.001) XCTAssertEqual(5 * FuelSavingsCalculator.kilometresPerMile, 8.04672, accuracy: 0.001)
// 262 km ≈ 162.8 miles (display uses × 0.621371) // 262 km ≈ 162.8 miles using the shared exact conversion
XCTAssertEqual(262 * 0.621371, 162.8, accuracy: 0.1) XCTAssertEqual(262 / FuelSavingsCalculator.kilometresPerMile, 162.8, accuracy: 0.1)
} }
} }
@@ -267,14 +267,14 @@ final class FuelTypeLabelTests: XCTestCase {
} }
func testDistanceUnitConversion() { func testDistanceUnitConversion() {
XCTAssertEqual(DistanceUnit.miles.toKM(1), 1.60934, accuracy: 0.00001) XCTAssertEqual(DistanceUnit.miles.toKM(1), 1.609344, accuracy: 0.00001)
XCTAssertEqual(DistanceUnit.kilometers.toKM(5), 5) XCTAssertEqual(DistanceUnit.kilometers.toKM(5), 5)
XCTAssertEqual(DistanceUnit.miles.fromKM(1.60934), 1, accuracy: 0.00001) XCTAssertEqual(DistanceUnit.miles.fromKM(1.609344), 1, accuracy: 0.00001)
XCTAssertEqual(DistanceUnit.kilometers.fromKM(2.5), 2.5) XCTAssertEqual(DistanceUnit.kilometers.fromKM(2.5), 2.5)
} }
func testDistanceUnitFormat() { func testDistanceUnitFormat() {
XCTAssertEqual(DistanceUnit.miles.format(1.60934), "1.0 mi") XCTAssertEqual(DistanceUnit.miles.format(1.609344), "1.0 mi")
XCTAssertEqual(DistanceUnit.kilometers.format(3.4), "3.4 km") XCTAssertEqual(DistanceUnit.kilometers.format(3.4), "3.4 km")
} }
} }
@@ -400,7 +400,7 @@ final class AlertRadiusFollowTests: XCTestCase {
FuelStore.saveDistanceUnit(.miles) FuelStore.saveDistanceUnit(.miles)
FuelStore.saveStationLimit(5) FuelStore.saveStationLimit(5)
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: true, manualKM: 3) let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: true, manualKM: 3)
XCTAssertEqual(eff, 5 * 1.60934, accuracy: 0.001) XCTAssertEqual(eff, 5 * FuelSavingsCalculator.kilometresPerMile, accuracy: 0.001)
} }
} }
@@ -731,7 +731,7 @@ final class RoadDistanceCacheTests: XCTestCase {
sourceLat: sourceLat, sourceLng: sourceLng, sourceLat: sourceLat, sourceLng: sourceLng,
entries: ["far": .init(meters: 23_500, lat: 51.5074, lng: -0.1278, returnMeters: 23_500)]) entries: ["far": .init(meters: 23_500, lat: 51.5074, lng: -0.1278, returnMeters: 23_500)])
let tenMilesKM = 10 * 1.60934 let tenMilesKM = 10 * FuelSavingsCalculator.kilometresPerMile
XCTAssertLessThan(far.distanceKM(to: sourceLat, lng2: sourceLng), tenMilesKM, XCTAssertLessThan(far.distanceKM(to: sourceLat, lng2: sourceLng), tenMilesKM,
"fixture: the straight line must sit INSIDE the 10-mile radius") "fixture: the straight line must sit INSIDE the 10-mile radius")
XCTAssertGreaterThan(FuelStore.displayDistanceKM(station: far, userLat: sourceLat, userLng: sourceLng), XCTAssertGreaterThan(FuelStore.displayDistanceKM(station: far, userLat: sourceLat, userLng: sourceLng),
@@ -743,7 +743,7 @@ final class RoadDistanceCacheTests: XCTestCase {
userLat: sourceLat, userLng: sourceLng), userLat: sourceLat, userLng: sourceLng),
"a station whose row reads 14.6 mi must not appear in a 10-mile list") "a station whose row reads 14.6 mi must not appear in a 10-mile list")
// The same station is back once the radius actually covers its distance. // The same station is back once the radius actually covers its distance.
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: far, radiusKM: 15 * 1.60934, XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: far, radiusKM: 15 * FuelSavingsCalculator.kilometresPerMile,
userLat: sourceLat, userLng: sourceLng)) userLat: sourceLat, userLng: sourceLng))
// …and an unknown station (no cached route) still tests its displayed // …and an unknown station (no cached route) still tests its displayed
// straight line, so nothing changes for the bulk of the list. // straight line, so nothing changes for the bulk of the list.
@@ -783,10 +783,10 @@ final class RoadDistanceCacheTests: XCTestCase {
XCTAssertFalse(confirmed.isApproximate, "a cached road distance is not an approximation") XCTAssertFalse(confirmed.isApproximate, "a cached road distance is not an approximation")
XCTAssertEqual(confirmed.km, 3.2, accuracy: 0.0001) XCTAssertEqual(confirmed.km, 3.2, accuracy: 0.0001)
XCTAssertEqual(confirmed.label(unit: .miles), "2.0 mi") XCTAssertEqual(confirmed.label(unit: .miles), "2.0 mi")
XCTAssertEqual(confirmed.labelWithReturn(unit: .miles), "2.0 mi (4.0 mi return)") XCTAssertEqual(confirmed.labelWithReturn(unit: .miles), "2.0 mi (4.2 mi return)")
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.4 km return)") XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.8 km return)")
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles") XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles")
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.0 miles return") XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.2 miles return")
let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1) let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1)
XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one") XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one")
@@ -794,11 +794,40 @@ final class RoadDistanceCacheTests: XCTestCase {
XCTAssertEqual(approximate.label(unit: .miles), XCTAssertEqual(approximate.label(unit: .miles),
"Approx. " + DistanceUnit.miles.format(approximate.km)) "Approx. " + DistanceUnit.miles.format(approximate.km))
XCTAssertEqual(approximate.labelWithReturn(unit: .miles), XCTAssertEqual(approximate.labelWithReturn(unit: .miles),
"Approx. \(DistanceUnit.miles.format(approximate.km)) (\(DistanceUnit.miles.format(approximate.km * 2)) return)") "Approx. " + DistanceUnit.miles.format(approximate.km) + " (Approx. " + DistanceUnit.miles.format(approximate.km * 2) + " return)")
XCTAssertEqual(approximate.spokenLabel(unit: .kilometers), XCTAssertEqual(approximate.spokenLabel(unit: .kilometers),
"Approximately " + DistanceUnit.kilometers.spoken(approximate.km)) "Approximately " + DistanceUnit.kilometers.spoken(approximate.km))
XCTAssertEqual(approximate.spokenLabelWithReturn(unit: .kilometers), XCTAssertEqual(approximate.spokenLabelWithReturn(unit: .kilometers),
"Approximately \(DistanceUnit.kilometers.spoken(approximate.km)), \(DistanceUnit.kilometers.spoken(approximate.km * 2)) return") "Approximately \(DistanceUnit.kilometers.spoken(approximate.km)), Approximately \(DistanceUnit.kilometers.spoken(approximate.km * 2)) return")
}
func testDisplayedReturnUsesTheIndependentlyMeasuredReturnRoute() {
FuelStore.setAccurateDistancesEnabled(true)
let station = station("separate-legs", 51.5074, -0.1278)
FuelStore.saveRoadDistances(
sourceLat: 51.6,
sourceLng: -0.1,
entries: ["separate-legs": .init(
meters: 4_650,
lat: station.lat,
lng: station.lng,
returnMeters: 4_820
)],
complete: true
)
let display = FuelStore.stationDistanceDisplay(
station: station,
userLat: 51.6,
userLng: -0.1
)
XCTAssertEqual(display.km, 4.65, accuracy: 1e-12)
XCTAssertEqual(display.returnKM, 9.47, accuracy: 1e-12)
XCTAssertFalse(display.isApproximate)
XCTAssertFalse(display.returnIsApproximate)
XCTAssertEqual(display.labelWithReturn(unit: .miles), "2.9 mi (5.9 mi return)")
XCTAssertEqual(display.spokenLabelWithReturn(unit: .miles), "2.9 miles, 5.9 miles return")
} }
/// The marker tracks the cache on its own: same station, bare figure once its /// The marker tracks the cache on its own: same station, bare figure once its
@@ -961,7 +990,7 @@ final class RoadDistanceCacheTests: XCTestCase {
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1) let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
XCTAssertTrue(display.isApproximate, "the browse layer must never serve routes with the mode off") XCTAssertTrue(display.isApproximate, "the browse layer must never serve routes with the mode off")
XCTAssertEqual(display.km, routed.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001) XCTAssertEqual(display.km, routed.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * 1.60934, XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * FuelSavingsCalculator.kilometresPerMile,
userLat: 51.6, userLng: -0.1), userLat: 51.6, userLng: -0.1),
"with the mode off the radius is a straight-line radius, agreeing with the rows") "with the mode off the radius is a straight-line radius, agreeing with the rows")
@@ -1000,7 +1029,7 @@ final class RoadDistanceCacheTests: XCTestCase {
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1) let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
XCTAssertFalse(display.isApproximate) XCTAssertFalse(display.isApproximate)
XCTAssertEqual(display.km, 23.5, accuracy: 0.0001) XCTAssertEqual(display.km, 23.5, accuracy: 0.0001)
XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * 1.60934, XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * FuelSavingsCalculator.kilometresPerMile,
userLat: 51.6, userLng: -0.1), userLat: 51.6, userLng: -0.1),
"in this mode a 10-mile radius is 10 miles of driving, matching the routed rows") "in this mode a 10-mile radius is 10 miles of driving, matching the routed rows")
} }
@@ -0,0 +1,243 @@
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
final class FuelPriceFreshnessTests: XCTestCase {
private let now = Date(timeIntervalSince1970: 1_792_022_400)
private func station(
priceUpdated: TimeInterval? = nil,
priceUpdatedByFuel: [FuelType: TimeInterval]? = nil
) -> FuelStation {
FuelStation(
id: "freshness-station",
name: "Freshness Station",
brand: "Test",
address: "1 High Street",
postcode: "BD1 1AA",
lat: 53.79,
lng: -1.75,
prices: [.diesel: 129.9, .e10: 169.9],
priceUpdated: priceUpdated,
priceUpdatedByFuel: priceUpdatedByFuel
)
}
func testPerGradeTimestampIsDecodedAndEligibleWhenFresh() throws {
let json = """
{
"stations": [{
"id": "s1",
"name": "LISTER ARMS",
"brand": "Shell",
"address": "MANCHESTER ROAD, BRADFORD",
"postcode": "BD5 7JX",
"lat": 53.78,
"lng": -1.75,
"prices": {"E10": 169.9, "DIESEL": 129.9},
"price_updated": "2026-09-25T07:45:52Z",
"price_updated_by_grade": {
"E10": "2026-10-14T07:45:52Z",
"DIESEL": "2026-10-14T07:45:52Z"
}
}]
}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
let decoded = try XCTUnwrap(stations.first)
XCTAssertEqual(decoded.priceUpdatedByFuel?[.diesel],
ISO8601DateFormatter.fuelBoardDate("2026-10-14T07:45:52Z")?.timeIntervalSince1970)
let assessment = FuelPriceFreshness.assessment(for: decoded, fuel: .diesel, now: now)
XCTAssertEqual(assessment.source, .gradeSpecific)
XCTAssertEqual(assessment.status, .fresh)
XCTAssertTrue(assessment.isEligibleForBestValue)
}
func testStationWideTimestampIsNotPresentedAsGradeSpecificOrEligible() {
let updated = now.addingTimeInterval(-FuelPriceFreshness.warningAge / 2).timeIntervalSince1970
let assessment = FuelPriceFreshness.assessment(
for: station(priceUpdated: updated), fuel: .diesel, now: now
)
XCTAssertEqual(assessment.source, .stationWide)
XCTAssertEqual(assessment.status, .fresh)
XCTAssertEqual(assessment.confidence, .provisional)
XCTAssertTrue(assessment.recommendationEligibility == .eligible)
XCTAssertTrue(assessment.isEligibleForBestValue)
XCTAssertTrue(assessment.warning)
XCTAssertTrue(assessment.displayLabel.contains("station-wide"))
}
func testRecentStationReportExplainsSelectedFuelTimeIsMissing() {
let updated = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .e10, now: now)
XCTAssertEqual(assessment.stationReportAgeLabel, "28h ago")
XCTAssertEqual(assessment.selectedFuelUpdateLabel, "Not provided")
XCTAssertEqual(assessment.source, .stationWide)
XCTAssertEqual(assessment.status, .fresh)
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
station: station(priceUpdated: updated), fuel: .e10,
baseline: station(priceUpdated: updated), now: now),
"Estimated saving · Unleaded price not individually dated")
}
func testOlderFuelSpecificTimestampDoesNotClaimUndatedPrice() {
let updated = now.addingTimeInterval(-10 * 86_400).timeIntervalSince1970
let priced = station(priceUpdatedByFuel: [.diesel: updated])
let assessment = FuelPriceFreshness.assessment(for: priced, fuel: .diesel, now: now)
XCTAssertNil(assessment.stationReportAgeLabel)
XCTAssertEqual(assessment.selectedFuelUpdateLabel, "10d ago")
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
station: priced, fuel: .diesel, baseline: priced, now: now),
"Estimated saving · Diesel price over 7 days old")
}
func testProvisionalWarningNamesBaselineWhenCandidateHasFuelSpecificTimestamp() {
let recent = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
let candidate = station(priceUpdatedByFuel: [.e10: recent])
let baseline = station(priceUpdated: recent)
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
station: candidate, fuel: .e10, baseline: baseline, now: now),
"Estimated saving · closest eligible station's Unleaded price not individually dated")
}
func testStationWideTimestampAtWarningBoundaryChangesRecommendationEligibility() {
let updated = now.addingTimeInterval(-FuelPriceFreshness.warningAge).timeIntervalSince1970
let onBoundary = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .diesel, now: now)
XCTAssertEqual(onBoundary.status, .fresh)
XCTAssertTrue(onBoundary.isEligibleForBestValue)
let pastBoundary = FuelPriceFreshness.assessment(
for: station(priceUpdated: updated - 1), fuel: .diesel, now: now)
XCTAssertEqual(pastBoundary.status, .warning)
XCTAssertFalse(pastBoundary.isEligibleForBestValue)
}
func testStationWideTimestampOlderThanWarningThresholdCannotRecommendSuspiciousDiesel() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let shell = station(priceUpdated: now.addingTimeInterval(-20 * 86_400).timeIntervalSince1970)
let age = FuelPriceFreshness.assessment(for: shell, fuel: .diesel, now: now)
XCTAssertEqual(age.status, .warning)
XCTAssertFalse(age.isEligibleForBestValue)
XCTAssertEqual(shell.prices[.diesel], 129.9)
}
func testComparisonConfidenceRejectsMissingSelectedFuelPriceEvenWithFreshTimestamp() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let updated = now.addingTimeInterval(-86_400).timeIntervalSince1970
let dieselOnly = FuelStation(id: "diesel", name: "Diesel", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.diesel: 129.9], priceUpdated: nil,
priceUpdatedByFuel: [.e10: updated])
let e10 = FuelStation(id: "petrol", name: "Petrol", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.e10: 169.9], priceUpdated: nil,
priceUpdatedByFuel: [.e10: updated])
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: dieselOnly, fuel: .e10,
baseline: e10, now: now), .ineligible)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: e10, fuel: .e10,
baseline: dieselOnly, now: now), .ineligible)
}
func testComparisonConfidenceRequiresBothSelectedGradeTimestamps() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let recent = now.addingTimeInterval(-86_400).timeIntervalSince1970
let old = now.addingTimeInterval(-20 * 86_400).timeIntervalSince1970
let verified = station(priceUpdatedByFuel: [.diesel: recent])
let provisional = station(priceUpdated: recent)
let oldStationWide = station(priceUpdated: old)
let oldGrade = station(priceUpdatedByFuel: [.diesel: old])
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
baseline: verified, now: now), .verified)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
baseline: provisional, now: now), .provisional)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: oldGrade, fuel: .diesel,
baseline: verified, now: now), .provisional)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
baseline: oldStationWide, now: now), .ineligible)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: provisional, fuel: .diesel,
baseline: verified, now: now), .provisional)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .e10,
baseline: verified, now: now), .ineligible)
}
func testNonFiniteAndExtremeEpochsCannotBecomeTrusted() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
for value in [Double.nan, .infinity, -.infinity, Double.greatestFiniteMagnitude, -Double.greatestFiniteMagnitude] {
let assessment = FuelPriceFreshness.assessment(
priceUpdated: value, priceUpdatedByFuel: nil, fuel: .diesel, now: now)
XCTAssertFalse(assessment.isEligibleForBestValue)
XCTAssertTrue(assessment.warning)
}
}
func testMissingAndMalformedTimestampsAreUnknownAndIneligible() {
let missing = FuelPriceFreshness.assessment(for: station(), fuel: .diesel, now: now)
XCTAssertEqual(missing.status, .unknown)
XCTAssertEqual(missing.source, .unknown)
XCTAssertFalse(missing.isEligibleForBestValue)
let malformed = FuelPriceFreshness.assessment(
priceUpdated: "not-a-date", priceUpdatedByGrade: nil, fuel: .diesel, now: now
)
XCTAssertEqual(malformed.status, .invalid)
XCTAssertFalse(malformed.isEligibleForBestValue)
}
func testStaleDisplayLabelDoesNotCallIneligiblePriceProvisional() {
let stale = station(priceUpdatedByFuel: [
.diesel: now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
])
let assessment = FuelPriceFreshness.assessment(for: stale, fuel: .diesel, now: now)
XCTAssertFalse(assessment.isEligibleForBestValue)
XCTAssertFalse(assessment.displayLabel.contains("provisional"))
XCTAssertTrue(assessment.displayLabel.contains("outdated"))
}
func testStaleAndFutureGradeTimestampsAreRejected() {
let stale = station(priceUpdatedByFuel: [
.diesel: now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
])
let staleAssessment = FuelPriceFreshness.assessment(for: stale, fuel: .diesel, now: now)
XCTAssertEqual(staleAssessment.status, .stale)
XCTAssertFalse(staleAssessment.isEligibleForBestValue)
XCTAssertTrue(staleAssessment.warning)
let future = station(priceUpdatedByFuel: [
.diesel: now.addingTimeInterval(FuelPriceFreshness.futureTolerance + 1).timeIntervalSince1970
])
let futureAssessment = FuelPriceFreshness.assessment(for: future, fuel: .diesel, now: now)
XCTAssertEqual(futureAssessment.status, .invalid)
XCTAssertFalse(futureAssessment.isEligibleForBestValue)
}
func testMalformedAndFutureSourceValuesDoNotCrashOrProduceNegativeAge() {
let malformed = FuelPriceFreshness.assessment(
priceUpdated: "2026-99-99T99:99:99Z",
priceUpdatedByGrade: ["DIESEL": "2026-99-99T99:99:99Z"],
fuel: .diesel,
now: now
)
XCTAssertNil(malformed.age)
XCTAssertFalse(malformed.isEligibleForBestValue)
let future = FuelPriceFreshness.assessment(
priceUpdated: now.addingTimeInterval(86_400).timeIntervalSince1970,
priceUpdatedByFuel: nil,
fuel: .diesel,
now: now
)
XCTAssertNil(future.age)
XCTAssertFalse(future.isEligibleForBestValue)
}
}
private extension ISO8601DateFormatter {
static func fuelBoardDate(_ string: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.date(from: string) ?? {
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: string)
}()
}
}
@@ -48,6 +48,42 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
// MARK: - 1. Unit conversion // MARK: - 1. Unit conversion
func testBudgetExamplesAcrossAllFuelTypes() throws {
for fuel in FuelType.allCases {
let base = FuelStation(id: "base", name: "Base", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 199.9], priceUpdated: nil)
let other = FuelStation(id: "other", name: "Other", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 129.9], priceUpdated: nil)
let settings = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 40, unit: .pounds))
for budget in [20.0, 40, 60, 73, 100] {
let result = FuelSavingsEstimator.availability(station: other, fuel: fuel, baseline: base,
settings: settings, amount: .init(value: budget, unit: .pounds),
journey: .init(miles: 8.3, basis: .roadRoute),
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
let volume = budget / 1.299
XCTAssertEqual(result?.fillLitres ?? 0, volume, accuracy: 1e-9)
XCTAssertEqual(result?.grossSavingGBP ?? 0, volume * 0.7, accuracy: 1e-9)
XCTAssertEqual(result?.netSavingGBP ?? 0, volume * 0.7 - 7.4 / 30 * 4.54609 * 1.299, accuracy: 1e-9)
}
}
}
func testCandidateBudgetAndEqualQuantitySavingsForEveryFuel() throws {
for fuel in FuelType.allCases {
let candidate = FuelStation(id: "candidate-\(fuel.rawValue)", name: "Candidate", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 129.9], priceUpdated: nil)
let baseline = FuelStation(id: "baseline-\(fuel.rawValue)", name: "Nearest", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 199.9], priceUpdated: nil)
let configured = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 60, unit: .pounds))
let money = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
amount: .init(value: 60, unit: .pounds), journey: .init(miles: 8.3, basis: .roadRoute),
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
let litres = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
amount: .init(value: 60 / 1.299, unit: .litres), journey: .init(miles: 8.3, basis: .roadRoute),
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
XCTAssertEqual(money?.fillLitres ?? 0, 60 / 1.299, accuracy: 1e-8, fuel.rawValue)
XCTAssertEqual(money?.grossSavingGBP ?? 0, litres?.grossSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
XCTAssertEqual(money?.netSavingGBP ?? 0, litres?.netSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
XCTAssertEqual(money?.netSavingGBP ?? 0, (money?.grossSavingGBP ?? 0) - (money?.extraDrivingCostGBP ?? 0), accuracy: 1e-8, fuel.rawValue)
}
}
func testImperialGallonIsUsedNotTheUSGallon() { func testImperialGallonIsUsedNotTheUSGallon() {
XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609) XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609)
XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784, XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784,
@@ -263,6 +299,23 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(FuelSavingsFormat.money(0), "£0.00") XCTAssertEqual(FuelSavingsFormat.money(0), "£0.00")
} }
func testIndependentlyRoundedFinancialComponentsMayDifferFromRoundedTotalByOnePenny() {
let gross = 1.234
let drivingCost = 0.005
let net = gross - drivingCost
XCTAssertEqual(FuelSavingsFormat.money(gross), "£1.23")
XCTAssertEqual(FuelSavingsFormat.money(drivingCost), "£0.01")
XCTAssertEqual(FuelSavingsFormat.money(net), "£1.23")
let independentlyRoundedSubtraction = 1.23 - 0.01
XCTAssertEqual(independentlyRoundedSubtraction, 1.22, accuracy: 1e-12)
XCTAssertNotEqual(
FuelSavingsFormat.money(independentlyRoundedSubtraction),
FuelSavingsFormat.money(net),
"the raw total remains authoritative; independently rounded components need not add back exactly"
)
}
func testSortingUsesTheUnroundedNetSoRoundedTiesStillOrderCorrectly() { func testSortingUsesTheUnroundedNetSoRoundedTiesStillOrderCorrectly() {
// Two rows that both DISPLAY "£0.18 worse off" but differ in the raw // Two rows that both DISPLAY "£0.18 worse off" but differ in the raw
// figure: the sort must use the raw value, never the shown one. // figure: the sort must use the raw value, never the shown one.
@@ -480,6 +533,6 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
)?.result )?.result
XCTAssertEqual(result?.status, .baseline) XCTAssertEqual(result?.status, .baseline)
XCTAssertEqual(result?.netSavingGBP, 0) XCTAssertEqual(result?.netSavingGBP, 0)
XCTAssertEqual(result?.headline(), "Closest station") XCTAssertEqual(result?.headline(), "Closest eligible station")
} }
} }
@@ -41,6 +41,20 @@ final class FuelSavingsEstimateTests: XCTestCase {
FuelSavingsJourney(miles: miles, basis: .straightLine) FuelSavingsJourney(miles: miles, basis: .straightLine)
} }
func testDifferentFuelNeverUsesAnotherGradeAsItsBaseline() {
let petrolOnly = FuelStation(id: "petrol", name: "Petrol", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: 199.9], priceUpdated: nil)
let dieselOnly = FuelStation(id: "diesel", name: "Diesel", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.diesel: 129.9], priceUpdated: nil)
let nearest = FuelSavingsEstimator.baselineStation([petrolOnly, dieselOnly], fuel: .diesel) { station in
self.straightLine(station.id == "petrol" ? 0.5 : 1)
}
XCTAssertEqual(nearest?.id, "diesel")
let unavailable = FuelSavingsEstimator.availability(station: dieselOnly, fuel: .diesel, baseline: petrolOnly,
settings: settings, amount: amount, journey: straightLine(1), baselineJourney: straightLine(0.5))
XCTAssertEqual(unavailable, .unavailable("No comparison station"))
}
func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() { func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() {
// Nearest station: 1 mile away at 145.9p. The station the user can see // Nearest station: 1 mile away at 145.9p. The station the user can see
// in the Cheapest tab: 3.5 miles away at 138.9p. // in the Cheapest tab: 3.5 miles away at 138.9p.
@@ -85,6 +99,24 @@ final class FuelSavingsEstimateTests: XCTestCase {
"unmeasured routes are reported as straight-line estimates") "unmeasured routes are reported as straight-line estimates")
} }
func testBaselineOutsideFilteredPoolStillRanksByNetSaving() {
let baseline = station("nearest-outside-radius", price: 199.9)
let cheap = station("z-cheap", price: 129.9)
let expensive = station("a-expensive", price: 198.9)
let ranked = FuelSavingsEstimator.ranked(
[expensive, cheap], fuel: .e10, baseline: baseline,
settings: settings, amount: amount,
journeyMiles: { s in self.straightLine(s.id == baseline.id ? 0.8 : 4.0) }
)
XCTAssertEqual(ranked.map(\.id), ["z-cheap", "a-expensive"])
let expected = FuelSavingsEstimator.availability(
station: cheap, fuel: .e10, baseline: baseline,
settings: settings, amount: amount,
journey: straightLine(4.0), baselineJourney: straightLine(0.8)
)?.result
XCTAssertGreaterThan(expected?.netSavingGBP ?? -1, 0)
}
func testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() { func testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() {
// 40 stations, but only the first one has a measured road route — the // 40 stations, but only the first one has a measured road route — the
// old implementation capped the whole ranking to 12 shortlisted // old implementation capped the whole ranking to 12 shortlisted
@@ -159,6 +191,56 @@ final class FuelSavingsEstimateTests: XCTestCase {
XCTAssertEqual(result?.distanceBasis, .straightLine) XCTAssertEqual(result?.distanceBasis, .straightLine)
} }
func testBestValueBaselineSkipsClosestStalePriceButKeepsThatStationVisible() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
}
let staleClosest = priced("stale-closest", 199.9, 27)
let eligibleClosest = priced("eligible-closest", 170.9, 1)
let eligibleFurther = priced("eligible-further", 150.9, 1)
let pool = [staleClosest, eligibleClosest, eligibleFurther]
let journeys: (FuelStation) -> FuelSavingsJourney? = { station in
self.straightLine(station.id == "stale-closest" ? 0.8 : station.id == "eligible-closest" ? 1.6 : 3.0)
}
let baseline = FuelSavingsEstimator.bestValueBaselineStation(pool, fuel: .e10,
journeyMiles: journeys, now: now)
XCTAssertEqual(baseline?.id, "eligible-closest")
XCTAssertEqual(FuelSavingsEstimator.baselineStation(pool, fuel: .e10, journeyMiles: journeys)?.id,
"stale-closest", "non-Best-Value closest behaviour must stay unchanged")
let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: baseline,
settings: briefSettings, amount: briefAmount, journeyMiles: journeys, now: now)
XCTAssertEqual(ranked.first?.id, "eligible-further")
XCTAssertTrue(ranked.contains(where: { $0.id == "stale-closest" }))
XCTAssertFalse(FuelSavingsEstimator.recommendationEligible(station: staleClosest, fuel: .e10,
baseline: baseline, now: now))
}
func testBestValueBaselineRequiresEligibleSelectedFuelAndUsableJourney() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let recent = now.addingTimeInterval(-86_400).timeIntervalSince1970
let stale = now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970
let stations = [
FuelStation(id: "wrong-fuel", name: "Wrong", brand: "", address: "", postcode: "", lat: 0, lng: 0,
prices: [.diesel: 140], priceUpdated: recent),
FuelStation(id: "stale", name: "Stale", brand: "", address: "", postcode: "", lat: 0, lng: 0,
prices: [.e10: 140], priceUpdated: stale),
FuelStation(id: "bad-journey", name: "Bad journey", brand: "", address: "", postcode: "", lat: 0, lng: 0,
prices: [.e10: 141], priceUpdated: recent),
FuelStation(id: "eligible", name: "Eligible", brand: "", address: "", postcode: "", lat: 0, lng: 0,
prices: [.e10: 145], priceUpdated: recent)
]
let journeys: (FuelStation) -> FuelSavingsJourney? = { station in
self.straightLine(station.id == "wrong-fuel" ? 0.1 : station.id == "stale" ? 0.2 :
station.id == "bad-journey" ? .infinity : 2)
}
XCTAssertEqual(FuelSavingsEstimator.bestValueBaselineStation(stations, fuel: .e10,
journeyMiles: journeys, now: now)?.id, "eligible")
XCTAssertNil(FuelSavingsEstimator.bestValueBaselineStation(Array(stations.prefix(2)), fuel: .e10,
journeyMiles: journeys, now: now))
}
func testBaselineStationUsesTheSameJourneyValuesAsSavings() { func testBaselineStationUsesTheSameJourneyValuesAsSavings() {
let geographicallyNearest = station("geographically-nearest", price: 145.9) let geographicallyNearest = station("geographically-nearest", price: 145.9)
let roadNearest = station("road-nearest", price: 146.9) let roadNearest = station("road-nearest", price: 146.9)
@@ -350,8 +432,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
) )
XCTAssertEqual(availability?.result?.status, .baseline) XCTAssertEqual(availability?.result?.status, .baseline)
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station") XCTAssertEqual(availability?.result?.headline(compact: true), "Closest eligible station")
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station") XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest eligible station")
XCTAssertEqual(availability?.result?.netSavingGBP, 0) XCTAssertEqual(availability?.result?.netSavingGBP, 0)
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0) XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
} }
@@ -403,7 +485,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
/// The exact strings the brief asks for. /// The exact strings the brief asks for.
func testHeadlineWordingMatchesTheBrief() { func testHeadlineWordingMatchesTheBrief() {
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station") XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest eligible station")
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even") XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
let saving = FuelSavingsResult(fillLitres: 34.9, let saving = FuelSavingsResult(fillLitres: 34.9,
@@ -427,10 +509,87 @@ final class FuelSavingsEstimateTests: XCTestCase {
/// 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
/// win when the cheaper one is far enough away. /// win when the cheaper one is far enough away.
func testBestValueAlertUsesClosestEligiblePriceAsBaseline() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
}
let stations = [priced("stale-closest", 199.9, 27), priced("eligible-closest", 170.9, 1),
priced("eligible-cheaper", 150.9, 1)]
let settings = FuelSavingsSettings(enabled: true, mpg: 30,
preferredAmount: .init(value: 60, unit: .pounds))
let alert = FuelSavingsCalculator.bestValueAlertCandidate(
stations: stations, fuel: .e10, settings: settings, amount: settings.preferredAmount,
minimumNetSavingGBP: 1,
journeyMiles: { self.straightLine($0.id == "stale-closest" ? 0.8 : $0.id == "eligible-closest" ? 1.6 : 3) },
now: now)
XCTAssertEqual(alert?.station.id, "eligible-cheaper")
}
func testBestValueAlertDoesNotRecommendStaleCandidateOrStaleBaseline() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
}
let base = priced("base", 199.9, 1)
let stale = priced("stale", 129.9, 40)
let fresh = priced("fresh", 159.9, 1)
let settings = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 60, unit: .pounds))
let choice = FuelSavingsCalculator.bestValueAlertCandidate(
stations: [base, stale, fresh], fuel: .e10, settings: settings,
amount: .init(value: 60, unit: .pounds), minimumNetSavingGBP: 1,
journeyMiles: { station in self.straightLine(station.id == "base" ? 0.8 : 2) }, now: now)
XCTAssertEqual(choice?.station.id, "fresh")
let noTrustedBaseline = FuelSavingsCalculator.bestValueAlertCandidate(
stations: [priced("base", 199.9, 40), fresh], fuel: .e10, settings: settings,
amount: .init(value: 60, unit: .pounds), minimumNetSavingGBP: 1,
journeyMiles: { station in self.straightLine(station.id == "base" ? 0.8 : 2) }, now: now)
XCTAssertNil(noTrustedBaseline)
}
func testStalePriceCannotLeadBestValueButRemainsVisible() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let baseline = FuelStation(id: "base", name: "Baseline", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: 199.9], priceUpdated: now.addingTimeInterval(-86_400).timeIntervalSince1970)
let stale = FuelStation(id: "stale-cheap", name: "Stale", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: 129.9], priceUpdated: now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970)
let recent = FuelStation(id: "recent", name: "Recent", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: 170.9], priceUpdated: now.addingTimeInterval(-86_400).timeIntervalSince1970)
let ranked = FuelSavingsEstimator.ranked([stale, recent], fuel: .e10, baseline: baseline,
settings: briefSettings, amount: briefAmount,
journeyMiles: { _ in self.straightLine(0.8) }, now: now)
XCTAssertEqual(ranked.map(\.id), ["recent", "stale-cheap"])
}
func testNoFreshPriceComparisonUsesExplicitCheapestPriceFallback() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let staleTime = now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970
let baseline = FuelStation(id: "baseline", name: "Baseline", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.diesel: 199.9], priceUpdated: staleTime)
let cheapest = FuelStation(id: "cheapest", name: "Cheapest", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.diesel: 129.9], priceUpdated: staleTime)
let near = FuelStation(id: "near", name: "Near", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.diesel: 170.9], priceUpdated: staleTime)
let ordered = FuelSavingsEstimator.ranked([near, baseline, cheapest], fuel: .diesel,
baseline: baseline, settings: briefSettings, amount: briefAmount,
journeyMiles: { station in self.straightLine(station.id == "cheapest" ? 250 : 1) }, now: now)
XCTAssertEqual(ordered.map(\.id), ["cheapest", "near", "baseline"])
XCTAssertFalse(FuelSavingsEstimator.recommendationEligible(station: ordered[0], fuel: .diesel,
baseline: baseline, now: now))
}
func testSortingRanksByNetSavingNotPumpPrice() { func testSortingRanksByNetSavingNotPumpPrice() {
let baseline = station("baseline", price: 171.9) let now = Date(timeIntervalSince1970: 1_800_000_000)
let cheapButFar = station("cheap-but-far", price: 167.9) func pricedStation(_ id: String, price: Double) -> FuelStation {
let pricierButNear = station("pricier-but-near", price: 169.9) FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
lat: 51.5, lng: -0.1, prices: [.e10: price],
priceUpdated: nil, priceUpdatedByFuel: [.e10: now.timeIntervalSince1970])
}
let baseline = pricedStation("baseline", price: 171.9)
let cheapButFar = pricedStation("cheap-but-far", price: 167.9)
let pricierButNear = pricedStation("pricier-but-near", price: 169.9)
let ranked = FuelSavingsEstimator.ranked( let ranked = FuelSavingsEstimator.ranked(
[baseline, cheapButFar, pricierButNear], [baseline, cheapButFar, pricierButNear],
@@ -444,7 +603,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
case "cheap-but-far": return self.straightLine(7.6) case "cheap-but-far": return self.straightLine(7.6)
default: return self.straightLine(1.2) default: return self.straightLine(1.2)
} }
} }, now: now
) )
XCTAssertEqual(ranked.first?.id, "pricier-but-near", XCTAssertEqual(ranked.first?.id, "pricier-but-near",
@@ -454,6 +613,25 @@ final class FuelSavingsEstimateTests: XCTestCase {
// MARK: Row copy // MARK: Row copy
func testCompactComparisonLabelUsesClosestEligibleStationWording() {
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
"vs closest eligible station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
"vs closest eligible station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .breakEven),
"vs closest eligible station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .baseline),
"your baseline"
)
}
/// The row's extra-driving caption must follow the DISPLAY unit: the stored /// The row's extra-driving caption must follow the DISPLAY unit: the stored
/// figure is always miles, and converting by the current unit instead would /// figure is always miles, and converting by the current unit instead would
/// leave a km user's figure unlabelled and 1.6× too large. /// leave a km user's figure unlabelled and 1.6× too large.
@@ -475,7 +653,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
} }
/// The baseline capsule has to say what it IS: it previously repeated the /// The baseline capsule has to say what it IS: it previously repeated the
/// status word above it ("Closest station" twice) and never explained that /// status word above it ("Closest eligible station" twice) and never explained that
/// the savings are measured against it. /// the savings are measured against it.
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() { func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
let baseline = FuelSavingsResult( let baseline = FuelSavingsResult(
@@ -486,7 +664,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: 0, netSavingGBP: 0,
status: .baseline status: .baseline
) )
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station · your baseline") XCTAssertEqual(baseline.capsuleHeadline(), "Closest eligible station")
let saving = FuelSavingsResult( let saving = FuelSavingsResult(
fillLitres: 30, fillLitres: 30,
@@ -110,10 +110,11 @@ final class FuelSavingsCalculatorTests: XCTestCase {
} }
func testBestValueAlertCandidatePicksHighestNetSavingAboveThreshold() throws { func testBestValueAlertCandidatePicksHighestNetSavingAboveThreshold() throws {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let stations = [ let stations = [
station("baseline", lat: 0, lng: 0, price: 160), station("baseline", lat: 0, lng: 0, price: 160, priceUpdated: now.timeIntervalSince1970),
station("small", lat: 0, lng: 0, price: 154), station("small", lat: 0, lng: 0, price: 154, priceUpdated: now.timeIntervalSince1970),
station("best", lat: 0, lng: 0, price: 148), station("best", lat: 0, lng: 0, price: 148, priceUpdated: now.timeIntervalSince1970),
] ]
let journeys = ["baseline": 2.0, "small": 4.0, "best": 6.0] let journeys = ["baseline": 2.0, "small": 4.0, "best": 6.0]
let settings = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)) let settings = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
@@ -123,7 +124,8 @@ final class FuelSavingsCalculatorTests: XCTestCase {
settings: settings, settings: settings,
amount: settings.preferredAmount, amount: settings.preferredAmount,
minimumNetSavingGBP: 1.0, minimumNetSavingGBP: 1.0,
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } } journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } },
now: now
) )
XCTAssertEqual(candidate?.station.id, "best") XCTAssertEqual(candidate?.station.id, "best")
XCTAssertEqual(candidate?.result.status, .worthTheDrive) XCTAssertEqual(candidate?.result.status, .worthTheDrive)
@@ -137,12 +139,23 @@ final class FuelSavingsCalculatorTests: XCTestCase {
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer") XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer")
} }
private func station(_ id: String, lat: Double, lng: Double, price: Double) -> FuelStation { private func station(_ id: String, lat: Double, lng: Double, price: Double,
FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil) priceUpdated: TimeInterval? = nil) -> FuelStation {
FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng,
prices: [.e10: price], priceUpdated: priceUpdated)
} }
} }
final class FuelSavingsSettingsTests: XCTestCase { final class FuelSavingsSettingsTests: XCTestCase {
func testSwitchingFillUnitStartsAnExplicitNewComparison() {
let budget = FuelSavingsFillAmount(value: 60, unit: .pounds)
let litres = FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .litres)
XCTAssertEqual(litres, FuelSavingsFillAmount(value: 30, unit: .litres))
XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: litres, to: .pounds),
FuelSavingsFillAmount(value: 40, unit: .pounds))
XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .pounds), budget)
}
func testSettingsValidationRequiresEnabledMPGAndFillAmount() { func testSettingsValidationRequiresEnabledMPGAndFillAmount() {
XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured) XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured) XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
+1 -1
View File
@@ -51,7 +51,7 @@ private enum WatchDistanceUnit: String, Codable {
func format(km: Double) -> String { func format(km: Double) -> String {
switch self { switch self {
case .miles: case .miles:
return String(format: "%.1f mi", km * 0.621371) return String(format: "%.1f mi", km / 1.609344)
case .kilometers: case .kilometers:
return String(format: "%.1f km", km) return String(format: "%.1f km", km)
} }
+73 -8
View File
@@ -95,7 +95,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
data: FuelPriceWidgetEntryData( data: FuelPriceWidgetEntryData(
date: Date(), stations: Array(sample.prefix(8)), date: Date(), stations: Array(sample.prefix(8)),
fuel: .e10, sort: .cheapest, isFavourites: false, fuel: .e10, sort: .cheapest, isFavourites: false,
location: nil, locationSource: "none", unit: unit location: nil, locationSource: "none", unit: unit,
bestValueResult: nil
) )
) )
} }
@@ -146,8 +147,9 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
// Per-widget config: fuel + sort + distance come from THIS widget instance. // Per-widget config: fuel + sort + distance come from THIS widget instance.
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10 let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
let isFavourites = configuration.sort == .favourites let isFavourites = configuration.sort == .favourites
let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest let sort: SortMode = configuration.sort == .closest ? .closest :
let radiusKM = Double(configuration.distance.miles) * 1.60934 (configuration.sort == .worthTheDrive ? .worthTheDrive : .cheapest)
let radiusKM = Double(configuration.distance.miles) * FuelSavingsCalculator.kilometresPerMile
// 1) Try a fresh location fix (bounded to a few seconds). The fix's own // 1) Try a fresh location fix (bounded to a few seconds). The fix's own
// accuracy is kept: the extension runs its own CLLocationManager and is // accuracy is kept: the extension runs its own CLLocationManager and is
@@ -200,6 +202,9 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
// 3) Load + order stations per widget mode. // 3) Load + order stations per widget mode.
let ordered: [FuelStation] let ordered: [FuelStation]
var bestValueResult: FuelSavingsResult?
var bestValuePriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var bestValuePriceWarning: String?
if isFavourites { if isFavourites {
let favourites = FuelStore.loadFavourites() let favourites = FuelStore.loadFavourites()
.filter { $0.station.prices[$0.fuel] != nil } .filter { $0.station.prices[$0.fuel] != nil }
@@ -265,7 +270,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
let filtered = stations.filter { $0.prices[fuel] != nil } let filtered = stations.filter { $0.prices[fuel] != nil }
switch sort { switch sort {
case .closest: case .closest:
// Nearest first; price only breaks ties. // Closest first; price only breaks ties.
ordered = filtered.sorted { lhs, rhs in ordered = filtered.sorted { lhs, rhs in
if let origin { if let origin {
let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng) let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng)
@@ -274,9 +279,67 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
} }
return lhs.prices[fuel]! < rhs.prices[fuel]! return lhs.prices[fuel]! < rhs.prices[fuel]!
} }
case .cheapest, .worthTheDrive: case .worthTheDrive:
// Cheapest first; distance only breaks ties. Worth-the-Drive is app-only; guard let origin else {
// widgets fall back to cheapest ordering if a stored config ever carries it. ordered = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
break
}
let journey: (FuelStation) -> FuelSavingsJourney? = { station in
if let meters = FuelStore.roadJourneyMeters(for: station, userLat: origin.lat, userLng: origin.lng) {
let confidence: JourneyConfidence = FuelStore.hasMeasuredReturnRoadDistance(
for: station, userLat: origin.lat, userLng: origin.lng
) ? .confirmed : .estimated
return FuelSavingsJourney(
miles: meters / FuelSavingsCalculator.metresPerMile,
basis: .roadRoute,
confidence: confidence
)
}
return FuelSavingsJourney(
miles: FuelStore.displayDistanceKM(station: station, userLat: origin.lat, userLng: origin.lng) /
FuelSavingsCalculator.kilometresPerMile * 2,
basis: .straightLine,
confidence: .estimated
)
}
let settings = FuelStore.loadFuelSavingsSettings()
guard settings.isConfigured else {
ordered = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
break
}
let amount = settings.preferredAmount
let baseline = FuelSavingsEstimator.bestValueBaselineStation(filtered, fuel: fuel,
journeyMiles: journey)
let ranked = FuelSavingsEstimator.ranked(
filtered,
fuel: fuel,
baseline: baseline,
settings: settings,
amount: amount,
journeyMiles: journey
)
ordered = ranked
if let first = ranked.first, let baseline {
bestValuePriceConfidence = FuelPriceFreshness.comparisonConfidence(
station: first, fuel: fuel, baseline: baseline)
bestValuePriceWarning = FuelPriceFreshness.comparisonWarning(
station: first, fuel: fuel, baseline: baseline)
}
if let first = ranked.first, let baseline,
bestValuePriceConfidence != .ineligible {
let baselineJourney = journey(baseline)
let result = FuelSavingsEstimator.availability(
station: first,
fuel: fuel,
baseline: baseline,
settings: settings,
amount: amount,
journey: journey(first),
baselineJourney: baselineJourney
)
bestValueResult = result?.result
}
case .cheapest:
ordered = filtered.sorted { lhs, rhs in ordered = filtered.sorted { lhs, rhs in
let lPrice = lhs.prices[fuel]! let lPrice = lhs.prices[fuel]!
let rPrice = rhs.prices[fuel]! let rPrice = rhs.prices[fuel]!
@@ -295,7 +358,9 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
date: Date(), stations: Array(ordered.prefix(8)), date: Date(), stations: Array(ordered.prefix(8)),
fuel: fuel, sort: sort, isFavourites: isFavourites, fuel: fuel, sort: sort, isFavourites: isFavourites,
location: origin, locationSource: source, location: origin, locationSource: source,
unit: unit unit: unit, bestValueResult: bestValueResult,
bestValuePriceConfidence: bestValuePriceConfidence,
bestValuePriceWarning: bestValuePriceWarning
) )
), originSource) ), originSource)
} }
+13 -3
View File
@@ -30,6 +30,7 @@ enum WidgetFuel: String, AppEnum, CaseIterable, Codable {
enum WidgetSort: String, AppEnum, CaseIterable, Codable { enum WidgetSort: String, AppEnum, CaseIterable, Codable {
case cheapest case cheapest
case closest case closest
case worthTheDrive
case favourites case favourites
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Sort by" static var typeDisplayRepresentation: TypeDisplayRepresentation = "Sort by"
@@ -37,6 +38,7 @@ enum WidgetSort: String, AppEnum, CaseIterable, Codable {
static var caseDisplayRepresentations: [WidgetSort: DisplayRepresentation] = [ static var caseDisplayRepresentations: [WidgetSort: DisplayRepresentation] = [
.cheapest: "Cheapest", .cheapest: "Cheapest",
.closest: "Closest", .closest: "Closest",
.worthTheDrive: "Best Value",
.favourites: "Favourites", .favourites: "Favourites",
] ]
} }
@@ -112,9 +114,17 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConf
\.$sort \.$sort
} }
} otherwise: { } otherwise: {
Summary("Show \(\.$fuel) by \(\.$sort)") { When(\.$sort, .equalTo, WidgetSort.worthTheDrive) {
\.$fuel Summary("Show \(\.$fuel) by \(\.$sort) within \(\.$distance)") {
\.$sort \.$fuel
\.$sort
\.$distance
}
} otherwise: {
Summary("Show \(\.$fuel) by \(\.$sort)") {
\.$fuel
\.$sort
}
} }
} }
} }
+189
View File
@@ -0,0 +1,189 @@
import Foundation
enum FuelPriceTimestamp {
static func parse(_ value: String?) -> Date? {
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.date(from: value) ?? {
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: value)
}()
}
}
enum FuelPriceFreshness {
static let warningAge: TimeInterval = 7 * 24 * 60 * 60
static let staleAge: TimeInterval = 30 * 24 * 60 * 60
static let futureTolerance: TimeInterval = 5 * 60
enum Source: Equatable { case gradeSpecific, stationWide, unknown }
enum Status: Equatable { case fresh, warning, stale, unknown, invalid }
enum Confidence: Equatable { case verified, provisional, unavailable }
enum RecommendationEligibility: Equatable { case eligible, ineligible }
struct Assessment: Equatable {
let source: Source
let status: Status
let age: TimeInterval?
let confidence: Confidence
let recommendationEligibility: RecommendationEligibility
let warning: Bool
var isEligibleForBestValue: Bool {
recommendationEligibility == .eligible
}
var stationReportAgeLabel: String? {
guard source == .stationWide, let age else { return nil }
return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"),
Self.ageText(age))
}
var selectedFuelUpdateLabel: String {
guard source == .gradeSpecific, let age else {
return String(localized: "Not provided", comment: "Fuel-specific update time is missing")
}
return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"),
Self.ageText(age))
}
var displayLabel: String {
switch (source, status, age) {
case (_, .unknown, _): return "Price age unknown"
case (_, .invalid, _): return "Price timestamp invalid"
case (_, .stale, let age?): return "Price updated \(Self.ageText(age)) ago · may be outdated"
case (.stationWide, _, let age?): return "Price updated \(Self.ageText(age)) ago · station-wide · provisional"
case (_, .warning, let age?): return "Price updated \(Self.ageText(age)) ago"
case (.gradeSpecific, .fresh, let age?): return "Price updated \(Self.ageText(age)) ago"
default: return "Price age unknown"
}
}
private static func ageText(_ age: TimeInterval) -> String {
let minutes = max(0, Int(age / 60))
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
if hours < 48 { return "\(hours)h" }
return "\(hours / 24)d"
}
}
static func assessment(for station: FuelStation, fuel: FuelType, now: Date = Date()) -> Assessment {
if station.priceUpdatedByFuelRaw?[fuel] != nil && station.priceUpdatedByFuel?[fuel] == nil {
return invalid(source: .gradeSpecific)
}
if let timestamp = station.priceUpdatedByFuel?[fuel] {
return assess(timestamp: timestamp, source: .gradeSpecific, now: now)
}
guard let timestamp = station.priceUpdated else { return unknown() }
return assess(timestamp: timestamp, source: .stationWide, now: now)
}
/// Recommendation gate for Best Value. A station-wide timestamp under
/// seven days can support a provisional estimate. Older station-wide
/// reports cannot establish grade recency; grade-specific timestamps may
/// remain eligible up to thirty days, but carry an age warning.
static func isEligibleForBestValue(station: FuelStation, fuel: FuelType,
now: Date = Date()) -> Bool {
assessment(for: station, fuel: fuel, now: now).isEligibleForBestValue
}
static func assessment(priceUpdated: String?, priceUpdatedByGrade: [String: String]?,
fuel: FuelType, now: Date = Date()) -> Assessment {
if let raw = priceUpdatedByGrade?[fuel.rawValue] {
guard let date = FuelPriceTimestamp.parse(raw) else { return invalid(source: .gradeSpecific) }
return assess(timestamp: date.timeIntervalSince1970, source: .gradeSpecific, now: now)
}
guard let raw = priceUpdated else { return unknown() }
guard let date = FuelPriceTimestamp.parse(raw) else { return invalid() }
return assess(timestamp: date.timeIntervalSince1970, source: .stationWide, now: now)
}
static func assessment(priceUpdated: TimeInterval?, priceUpdatedByFuel: [FuelType: TimeInterval]?,
fuel: FuelType, now: Date = Date()) -> Assessment {
if let timestamp = priceUpdatedByFuel?[fuel] {
return assess(timestamp: timestamp, source: .gradeSpecific, now: now)
}
guard let timestamp = priceUpdated else { return unknown() }
return assess(timestamp: timestamp, source: .stationWide, now: now)
}
/// Confidence for a monetary comparison requires both selected-grade prices.
/// Keep the eligibility and label decision together for app and widgets.
enum ComparisonConfidence: Equatable {
case verified, provisional, ineligible
static func assess(station: FuelStation, fuel: FuelType, baseline: FuelStation?, now: Date = Date()) -> Self {
FuelPriceFreshness.comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now)
}
var warningText: String? {
switch self {
case .verified: return nil
case .provisional: return String(localized: "Price not individually dated — saving is an estimate.")
case .ineligible: return String(localized: "Price age unavailable or too old — not a Best Value recommendation.")
}
}
}
static func comparisonWarning(station: FuelStation, fuel: FuelType,
baseline: FuelStation?, now: Date = Date()) -> String? {
let confidence = comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now)
guard confidence == .provisional, let baseline else { return confidence.warningText }
let candidate = assessment(for: station, fuel: fuel, now: now)
let reference = assessment(for: baseline, fuel: fuel, now: now)
let candidateNeedsWarning = candidate.confidence != .verified || candidate.status != .fresh
let cause = candidateNeedsWarning ? candidate : reference
if cause.source == .stationWide {
let format = candidateNeedsWarning
? String(localized: "Estimated saving · %@ price not individually dated")
: String(localized: "Estimated saving · closest eligible station's %@ price not individually dated")
return String(format: format, fuel.displayName)
}
let format = candidateNeedsWarning
? String(localized: "Estimated saving · %@ price over 7 days old")
: String(localized: "Estimated saving · closest eligible station's %@ price over 7 days old")
return String(format: format, fuel.displayName)
}
static func comparisonConfidence(station: FuelStation, fuel: FuelType,
baseline: FuelStation?, now: Date = Date()) -> ComparisonConfidence {
guard let baseline,
let candidatePrice = station.prices[fuel], candidatePrice.isFinite, candidatePrice > 0,
let baselinePrice = baseline.prices[fuel], baselinePrice.isFinite, baselinePrice > 0
else { return .ineligible }
let candidate = assessment(for: station, fuel: fuel, now: now)
let reference = assessment(for: baseline, fuel: fuel, now: now)
guard candidate.isEligibleForBestValue, reference.isEligibleForBestValue else { return .ineligible }
if candidate.confidence == .verified, reference.confidence == .verified,
candidate.status == .fresh, reference.status == .fresh { return .verified }
return .provisional
}
private static func unknown() -> Assessment {
Assessment(source: .unknown, status: .unknown, age: nil,
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
}
private static func invalid(source: Source = .unknown) -> Assessment {
Assessment(source: source, status: .invalid, age: nil,
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
}
private static func assess(timestamp: TimeInterval, source: Source, now: Date) -> Assessment {
let age = now.timeIntervalSince1970 - timestamp
guard age >= -futureTolerance else {
return Assessment(source: source, status: .invalid, age: nil,
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
}
let boundedAge = max(0, age)
let status: Status = boundedAge > staleAge ? .stale : boundedAge > warningAge ? .warning : .fresh
let confidence: Confidence = source == .gradeSpecific ? .verified : .provisional
let eligible = status == .fresh || (source == .gradeSpecific && status == .warning)
return Assessment(source: source, status: status, age: boundedAge,
confidence: confidence,
recommendationEligibility: eligible ? .eligible : .ineligible,
warning: source != .gradeSpecific || status != .fresh)
}
}
+47 -1
View File
@@ -38,7 +38,9 @@ enum FuelPriceProvider {
lat: relay.lat ?? 0, lat: relay.lat ?? 0,
lng: relay.lng ?? 0, lng: relay.lng ?? 0,
prices: relay.allPrices, prices: relay.allPrices,
priceUpdated: nil priceUpdated: relay.priceUpdatedDate?.timeIntervalSince1970,
priceUpdatedByFuel: relay.priceUpdatedByFuel,
priceUpdatedByFuelRaw: relay.priceUpdatedByFuelRaw
) )
} }
} }
@@ -65,6 +67,15 @@ enum FuelPriceProvider {
return result return result
} }
static func fuelType(for grade: String) -> FuelType? {
switch grade.uppercased() {
case "E10": return .e10
case "E5": return .e5
case "DIESEL", "B7", "B7S", "B7P", "B10": return .diesel
default: return nil
}
}
/// Decodes mirror envelope metadata (source, dataset update time) — the /// Decodes mirror envelope metadata (source, dataset update time) — the
/// About section shows these so the user can see which data source is /// About section shows these so the user can see which data source is
/// live and how fresh the GOV.UK data itself is. The fields are additive /// live and how fresh the GOV.UK data itself is. The fields are additive
@@ -106,6 +117,41 @@ private struct MirrorResponse: Codable {
let lng: Double? let lng: Double?
let price: Double? let price: Double?
let prices: [String: Double]? let prices: [String: Double]?
let priceUpdated: String?
let priceUpdatedByGrade: [String: String]?
let priceUpdatedByFuelRawValues: [String: String]?
enum CodingKeys: String, CodingKey {
case id, name, brand, address, postcode, lat, lng, price, prices
case priceUpdated = "price_updated"
case priceUpdatedByGrade = "price_updated_by_grade"
case priceUpdatedByFuelRawValues = "price_updated_by_fuel"
}
var priceUpdatedDate: Date? {
FuelPriceTimestamp.parse(priceUpdated)
}
var priceUpdatedByFuelRaw: [FuelType: String]? {
let raw = priceUpdatedByGrade ?? priceUpdatedByFuelRawValues
guard let raw else { return nil }
let parsed = raw.reduce(into: [FuelType: String]()) { result, item in
guard let fuel = FuelPriceProvider.fuelType(for: item.key) else { return }
result[fuel] = item.value
}
return parsed.isEmpty ? nil : parsed
}
var priceUpdatedByFuel: [FuelType: TimeInterval]? {
let raw = priceUpdatedByGrade ?? priceUpdatedByFuelRawValues
guard let raw else { return nil }
let parsed = raw.reduce(into: [FuelType: TimeInterval]()) { result, item in
guard let fuel = FuelPriceProvider.fuelType(for: item.key),
let date = FuelPriceTimestamp.parse(item.value) else { return }
result[fuel] = date.timeIntervalSince1970
}
return parsed.isEmpty ? nil : parsed
}
/// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the /// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
/// station sells — one fetch populates every fuel tab. Defensive guard: /// station sells — one fetch populates every fuel tab. Defensive guard:
+46 -7
View File
@@ -29,11 +29,17 @@ struct FuelPriceWidgetEntryData {
let date: Date let date: Date
let stations: [FuelStation] // already filtered + sorted per config let stations: [FuelStation] // already filtered + sorted per config
let fuel: FuelType let fuel: FuelType
let sort: SortMode // per-widget: cheapest | closest let sort: SortMode // per-widget: cheapest | closest | Best Value
let isFavourites: Bool // favourites mode: pinned stations, cheapest-first let isFavourites: Bool // favourites mode: pinned stations
let location: Coordinate? let location: Coordinate?
let locationSource: String // "live" | "cached" | "none" let locationSource: String // "live" | "cached" | "none"
let unit: DistanceUnit // user's display unit for distances let unit: DistanceUnit // user's display unit for distances
let bestValueResult: FuelSavingsResult? // only populated for the small Best Value face
/// Both selected-fuel prices are assessed by the shared freshness policy.
var bestValuePriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
/// Candidate-aware warning copy (names the fuel, and the baseline when the
/// baseline is the weak link). Falls back to the confidence's own text.
var bestValuePriceWarning: String? = nil
} }
/// The widget face, parameterised by family so the app can preview it. /// The widget face, parameterised by family so the app can preview it.
@@ -67,9 +73,11 @@ struct FuelPriceWidgetContent: View {
private var heading: String { private var heading: String {
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" } if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
return entry.sort == .closest switch entry.sort {
? "Closest \(entry.fuel.displayName)" case .closest: return "Closest \(entry.fuel.displayName)"
: "Cheapest \(entry.fuel.displayName)" case .worthTheDrive: return "Best Value \(entry.fuel.displayName)"
case .cheapest: return "Cheapest \(entry.fuel.displayName)"
}
} }
private var singleStation: some View { private var singleStation: some View {
@@ -85,7 +93,36 @@ struct FuelPriceWidgetContent: View {
Text(station.name) Text(station.name)
.font(.headline) .font(.headline)
.lineLimit(1) .lineLimit(1)
if let price = station.prices[entry.fuel] { if entry.sort == .worthTheDrive, let result = entry.bestValueResult {
Text(result.headline(compact: true))
.font(.title3.weight(.bold))
.lineLimit(1)
Text(result.status == .baseline ? "your baseline" : "vs closest eligible station")
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
if let warning = entry.bestValuePriceWarning ?? entry.bestValuePriceConfidence.warningText {
Text(warning)
.font(.caption2)
.foregroundStyle(.orange)
.lineLimit(2)
}
} else if entry.sort == .worthTheDrive {
if let price = station.prices[entry.fuel] {
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
}
if entry.location == nil {
Text("Best Value unavailable · location needed")
.font(.caption2)
.foregroundStyle(.orange)
.lineLimit(2)
} else {
Text("Best Value unavailable · price confidence insufficient")
.font(.caption2)
.foregroundStyle(.orange)
.lineLimit(2)
}
} else if let price = station.prices[entry.fuel] {
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green) FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
} }
if let location = entry.location { if let location = entry.location {
@@ -126,7 +163,9 @@ struct FuelPriceWidgetContent: View {
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer()
Text(entry.isFavourites ? "cheapest first" Text(entry.sort == .worthTheDrive && entry.bestValuePriceConfidence == .ineligible
? "price order · Best Value unavailable"
: entry.isFavourites ? "your order"
: (entry.locationSource == "none" ? "by price" : "near you")) : (entry.locationSource == "none" ? "by price" : "near you"))
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
+86 -28
View File
@@ -45,7 +45,7 @@ enum FuelSavingsStatus: String, Codable, Equatable {
case breakEven case breakEven
case notWorthTheDrive case notWorthTheDrive
/// The comparison baseline itself — the station every other saving is /// The comparison baseline itself — the station every other saving is
/// measured against. Shown as "Closest station", never "Break even". /// measured against. Shown as "Closest eligible station", never "Break even".
case baseline case baseline
var displayName: String { var displayName: String {
@@ -143,6 +143,13 @@ enum FuelSavingsFormat {
: "\(distanceText) extra return travel" : "\(distanceText) extra return travel"
} }
/// Compact comparison copy kept outside the coloured verdict capsule.
/// "Closest" refers to the existing shortest-journey baseline selection;
/// it does not claim a straight-line comparison.
static func comparisonLabel(status: FuelSavingsStatus) -> String {
status == .baseline ? "your baseline" : "vs closest eligible station"
}
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix /// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
/// are spoken as words ("Approximately 6.4 miles extra return travel") because /// are spoken as words ("Approximately 6.4 miles extra return travel") because
/// synthesizers render "Approx." and "mi" inconsistently. /// synthesizers render "Approx." and "mi" inconsistently.
@@ -167,7 +174,7 @@ enum FuelSavingsFormat {
extension FuelSavingsResult { extension FuelSavingsResult {
/// Baseline row label — required wording. /// Baseline row label — required wording.
static let baselineHeadline = "Closest station" static let baselineHeadline = "Closest eligible station"
/// Neutral result label — required wording. /// Neutral result label — required wording.
static let breakEvenHeadline = "Break even" static let breakEvenHeadline = "Break even"
@@ -188,7 +195,7 @@ extension FuelSavingsResult {
} }
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after /// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
/// travel", "Break even", "Closest station". /// travel", "Break even", "Closest eligible station".
/// ///
/// Estimated comparisons are prefixed "Approx. " for positive/negative money /// Estimated comparisons are prefixed "Approx. " for positive/negative money
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive /// results — the WORD, never a "~": a tilde reads as decoration (and assistive
@@ -215,13 +222,11 @@ extension FuelSavingsResult {
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ") headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
} }
/// Row capsule copy: the headline, plus what the baseline row IS. Without the /// Row capsule copy. The baseline relationship is shown as secondary text
/// suffix the baseline capsule just repeated the status word above it /// beside the capsule in the station listing and detail summary, so the
/// ("Closest station" twice) and said nothing about the saving being measured /// capsule itself contains only the verdict.
/// against it.
func capsuleHeadline() -> String { func capsuleHeadline() -> String {
let headline = headline(compact: true) headline(compact: true)
return status == .baseline ? "\(headline) · your baseline" : headline
} }
} }
@@ -338,6 +343,19 @@ enum FuelSavingsCalculator {
} }
} }
/// Changing between a per-candidate monetary budget and a fixed litre
/// quantity has no lossless conversion across all stations. Start a new
/// comparison at that mode's stated default; the UI must tell the user
/// explicitly that the comparison basis changed and invite adjustment.
static func newComparisonAmount(afterSwitching amount: FuelSavingsFillAmount,
to unit: FuelSavingsFillUnit) -> FuelSavingsFillAmount {
guard amount.unit != unit else { return amount }
switch unit {
case .pounds: return FuelSavingsFillAmount(value: 40, unit: .pounds)
case .litres: return FuelSavingsFillAmount(value: 30, unit: .litres)
}
}
static func convert(amount: FuelSavingsFillAmount, static func convert(amount: FuelSavingsFillAmount,
to unit: FuelSavingsFillUnit, to unit: FuelSavingsFillUnit,
referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount { referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount {
@@ -434,21 +452,24 @@ enum FuelSavingsCalculator {
settings: FuelSavingsSettings, settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount, amount: FuelSavingsFillAmount,
minimumNetSavingGBP: Double, minimumNetSavingGBP: Double,
journeyMiles: (FuelStation) -> FuelSavingsJourney? journeyMiles: (FuelStation) -> FuelSavingsJourney?,
now: Date = Date()
) -> BestValueAlertCandidate? { ) -> BestValueAlertCandidate? {
guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil } guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil }
let sellers = stations.filter { $0.prices[fuel] != nil } let sellers = stations.filter { $0.prices[fuel] != nil }
guard !sellers.isEmpty else { return nil } guard !sellers.isEmpty else { return nil }
let baseline = FuelSavingsEstimator.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles) let baseline = FuelSavingsEstimator.bestValueBaselineStation(sellers, fuel: fuel,
journeyMiles: journeyMiles, now: now)
guard let baseline else { return nil }
var best: BestValueAlertCandidate? var best: BestValueAlertCandidate?
var bestDistance = Double.greatestFiniteMagnitude var bestDistance = Double.greatestFiniteMagnitude
var bestPrice = Double.infinity var bestPrice = Double.infinity
var baselineJourneyCache: FuelSavingsJourney? var baselineJourneyCache: FuelSavingsJourney?
var baselineJourneyLoaded = false var baselineJourneyLoaded = false
for station in sellers { for station in sellers {
guard FuelPriceFreshness.isEligibleForBestValue(station: station, fuel: fuel, now: now) else { continue }
let journey = journeyMiles(station) let journey = journeyMiles(station)
let baselineJourney: FuelSavingsJourney? = { let baselineJourney: FuelSavingsJourney? = {
guard let baseline else { return nil }
if baseline.id == station.id { return journey } if baseline.id == station.id { return journey }
if !baselineJourneyLoaded { if !baselineJourneyLoaded {
baselineJourneyCache = journeyMiles(baseline) baselineJourneyCache = journeyMiles(baseline)
@@ -492,12 +513,8 @@ enum FuelSavingsCalculator {
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking /// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
/// rules are unit tested directly. /// rules are unit tested directly.
enum FuelSavingsEstimator { enum FuelSavingsEstimator {
/// Baseline station for Best Value: the station with the shortest journey /// Closest priced station, irrespective of evidence age. Used for ordinary
/// among stations that sell the selected fuel, using the SAME journey source /// Closest sorting, which must continue to include older prices.
/// the savings calculation will use. This keeps the row labelled "Closest
/// station" and every "extra return travel" value tied to one
/// reference, instead of mixing a separate straight-line baseline with
/// road-route savings.
static func baselineStation(_ stations: [FuelStation], static func baselineStation(_ stations: [FuelStation],
fuel: FuelType, fuel: FuelType,
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? { journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? {
@@ -515,6 +532,17 @@ enum FuelSavingsEstimator {
return bestStation return bestStation
} }
/// Closest selected-fuel seller with price evidence eligible for a Best Value
/// comparison. The ordinary Closest sort still includes every priced station.
static func bestValueBaselineStation(_ stations: [FuelStation],
fuel: FuelType,
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
now: Date = Date()) -> FuelStation? {
baselineStation(stations.filter {
FuelPriceFreshness.comparisonConfidence(station: $0, fuel: fuel, baseline: $0, now: now) != .ineligible
}, fuel: fuel, journeyMiles: journeyMiles)
}
/// Savings for a single station against `baseline`, or nil when there is /// Savings for a single station against `baseline`, or nil when there is
/// nothing to show at all (feature off). /// nothing to show at all (feature off).
static func availability(station: FuelStation, static func availability(station: FuelStation,
@@ -556,20 +584,26 @@ enum FuelSavingsEstimator {
} }
} }
/// Ranks a whole pool for the Best Value sort. /// Recommendation gate shared by Best Value order, widgets and alerts.
/// /// The baseline and candidate must both pass the selected-fuel freshness
/// EVERY priced station in the pool gets an estimate, not just the handful /// policy. A recent station-wide timestamp remains provisional; missing,
/// whose road routes happen to have been measured: journeys that have no /// invalid and over-threshold timestamps cannot lead a recommendation.
/// measured route fall back to a straight-line out-and-back estimate (see static func recommendationEligible(station: FuelStation, fuel: FuelType,
/// `FuelSavingsDistanceBasis`). Capping this to a shortlist dropped cheaper baseline: FuelStation?, now: Date = Date()) -> Bool {
/// stations that were a few miles further away — the exact stations the FuelPriceFreshness.comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now) != .ineligible
/// sort exists to surface — and left later pages with no savings at all. }
/// Ranks the whole station pool by valid full-precision savings. Stations
/// with an ineligible price stay visible behind eligible candidates; if
/// none qualify, the fallback order is selected-fuel pump price (then id),
/// not an untrustworthy savings order. The caller labels the fallback.
static func ranked(_ stations: [FuelStation], static func ranked(_ stations: [FuelStation],
fuel: FuelType, fuel: FuelType,
baseline: FuelStation?, baseline: FuelStation?,
settings: FuelSavingsSettings, settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount, amount: FuelSavingsFillAmount,
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> [FuelStation] { journeyMiles: (FuelStation) -> FuelSavingsJourney?,
now: Date = Date()) -> [FuelStation] {
var journeys: [String: FuelSavingsJourney] = [:] var journeys: [String: FuelSavingsJourney] = [:]
journeys.reserveCapacity(stations.count) journeys.reserveCapacity(stations.count)
for station in stations { for station in stations {
@@ -577,7 +611,21 @@ enum FuelSavingsEstimator {
journeys[station.id] = journey journeys[station.id] = journey
} }
} }
let baselineJourney = baseline.flatMap { journeys[$0.id] } let baselineJourney = baseline.flatMap { journeys[$0.id] ?? journeyMiles($0) }
let hasEligibleComparison = stations.contains {
recommendationEligible(station: $0, fuel: fuel, baseline: baseline, now: now)
}
if !hasEligibleComparison {
// A net-savings order cannot be offered without trustworthy price
// evidence. Keep every station visible in an explicit price order;
// the caller labels the fallback as unavailable.
return stations.sorted { lhs, rhs in
let left = lhs.prices[fuel] ?? .infinity
let right = rhs.prices[fuel] ?? .infinity
if left != right { return left < right }
return lhs.id < rhs.id
}
}
let ranked = stations.map { station -> FuelSavingsRankedStation in let ranked = stations.map { station -> FuelSavingsRankedStation in
let journey = journeys[station.id] let journey = journeys[station.id]
let availability = availability(station: station, let availability = availability(station: station,
@@ -600,7 +648,17 @@ enum FuelSavingsEstimator {
order[rankedStation.stationID] = offset order[rankedStation.stationID] = offset
} }
} }
// Compute freshness once per station, never in the O(n log n)
// comparator over the full UK pool.
var eligibility: [String: Bool] = [:]
eligibility.reserveCapacity(stations.count)
for station in stations {
eligibility[station.id] = recommendationEligible(station: station, fuel: fuel, baseline: baseline, now: now)
}
return stations.sorted { lhs, rhs in return stations.sorted { lhs, rhs in
let leftEligible = eligibility[lhs.id] ?? false
let rightEligible = eligibility[rhs.id] ?? false
if leftEligible != rightEligible { return leftEligible }
let left = order[lhs.id] ?? Int.max let left = order[lhs.id] ?? Int.max
let right = order[rhs.id] ?? Int.max let right = order[rhs.id] ?? Int.max
if left != right { return left < right } if left != right { return left < right }
+126 -32
View File
@@ -183,13 +183,13 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
/// shown in this unit, rounded to a whole number for picker labels: /// shown in this unit, rounded to a whole number for picker labels:
/// 5 miles -> "5" (miles unit) or "8" (km unit). /// 5 miles -> "5" (miles unit) or "8" (km unit).
func displayMiles(_ miles: Int) -> Int { func displayMiles(_ miles: Int) -> Int {
Int(fromKM(Double(miles) * 1.60934).rounded()) Int(fromKM(Double(miles) * FuelSavingsCalculator.kilometresPerMile).rounded())
} }
/// Convert a value expressed in this unit to km. /// Convert a value expressed in this unit to km.
func toKM(_ value: Double) -> Double { func toKM(_ value: Double) -> Double {
switch self { switch self {
case .miles: return value * 1.60934 case .miles: return value * FuelSavingsCalculator.kilometresPerMile
case .kilometers: return value case .kilometers: return value
} }
} }
@@ -197,7 +197,7 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
/// Convert a km value to this unit. /// Convert a km value to this unit.
func fromKM(_ km: Double) -> Double { func fromKM(_ km: Double) -> Double {
switch self { switch self {
case .miles: return km * 0.621371 case .miles: return km / FuelSavingsCalculator.kilometresPerMile
case .kilometers: return km case .kilometers: return km
} }
} }
@@ -275,6 +275,73 @@ struct FuelStation: Identifiable, Codable, Equatable {
let prices: [FuelType: Double] let prices: [FuelType: Double]
/// Updated timestamp (seconds since 1970) — nil for sample data. /// Updated timestamp (seconds since 1970) — nil for sample data.
let priceUpdated: TimeInterval? let priceUpdated: TimeInterval?
/// Per-grade timestamps when the source provides them. A station-wide
/// `price_updated` must not be copied into this map: it does not prove that
/// every grade changed at that time.
let priceUpdatedByFuel: [FuelType: TimeInterval]?
/// Original per-grade timestamp text, retained so malformed source values
/// cannot silently fall back to a station-wide timestamp.
let priceUpdatedByFuelRaw: [FuelType: String]?
init(id: String, name: String, brand: String, address: String, postcode: String,
lat: Double, lng: Double, prices: [FuelType: Double],
priceUpdated: TimeInterval?, priceUpdatedByFuel: [FuelType: TimeInterval]? = nil,
priceUpdatedByFuelRaw: [FuelType: String]? = nil) {
self.id = id
self.name = name
self.brand = brand
self.address = address
self.postcode = postcode
self.lat = lat
self.lng = lng
self.prices = prices
self.priceUpdated = priceUpdated
self.priceUpdatedByFuel = priceUpdatedByFuel
self.priceUpdatedByFuelRaw = priceUpdatedByFuelRaw
}
private enum CodingKeys: String, CodingKey {
case id, name, brand, address, postcode, lat, lng, prices
case priceUpdated = "priceUpdated"
case priceUpdatedSnake = "price_updated"
case priceUpdatedByFuel = "priceUpdatedByFuel"
case priceUpdatedByFuelSnake = "price_updated_by_fuel"
case priceUpdatedByFuelRaw = "priceUpdatedByFuelRaw"
case priceUpdatedByFuelRawSnake = "price_updated_by_fuel_raw"
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
name = try values.decode(String.self, forKey: .name)
brand = try values.decode(String.self, forKey: .brand)
address = try values.decode(String.self, forKey: .address)
postcode = try values.decode(String.self, forKey: .postcode)
lat = try values.decode(Double.self, forKey: .lat)
lng = try values.decode(Double.self, forKey: .lng)
prices = try values.decode([FuelType: Double].self, forKey: .prices)
priceUpdated = try values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdated)
?? values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdatedSnake)
priceUpdatedByFuel = try values.decodeIfPresent([FuelType: TimeInterval].self, forKey: .priceUpdatedByFuel)
?? values.decodeIfPresent([FuelType: TimeInterval].self, forKey: .priceUpdatedByFuelSnake)
priceUpdatedByFuelRaw = try values.decodeIfPresent([FuelType: String].self, forKey: .priceUpdatedByFuelRaw)
?? values.decodeIfPresent([FuelType: String].self, forKey: .priceUpdatedByFuelRawSnake)
}
func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(id, forKey: .id)
try values.encode(name, forKey: .name)
try values.encode(brand, forKey: .brand)
try values.encode(address, forKey: .address)
try values.encode(postcode, forKey: .postcode)
try values.encode(lat, forKey: .lat)
try values.encode(lng, forKey: .lng)
try values.encode(prices, forKey: .prices)
try values.encodeIfPresent(priceUpdated, forKey: .priceUpdated)
try values.encodeIfPresent(priceUpdatedByFuel, forKey: .priceUpdatedByFuel)
try values.encodeIfPresent(priceUpdatedByFuelRaw, forKey: .priceUpdatedByFuelRaw)
}
/// Cheap haversine distance to a location, in km. /// Cheap haversine distance to a location, in km.
func distanceKM(to lat2: Double, lng2: Double) -> Double { func distanceKM(to lat2: Double, lng2: Double) -> Double {
@@ -979,7 +1046,7 @@ struct FuelStore {
/// "Follow search" caps the geofence at 8 miles so a 10/15-mile Stations /// "Follow search" caps the geofence at 8 miles so a 10/15-mile Stations
/// search never creates huge region-monitoring circles. /// search never creates huge region-monitoring circles.
static let alertFollowCapKM: Double = 8 * 1.60934 static let alertFollowCapKM: Double = 8 * FuelSavingsCalculator.kilometresPerMile
/// The effective alert radius: the manual radius, or — when alerts follow /// The effective alert radius: the manual radius, or — when alerts follow
/// the Stations-tab search — that distance (miles, converted via the /// the Stations-tab search — that distance (miles, converted via the
@@ -1618,10 +1685,22 @@ struct FuelStore {
// what the rows show. // what the rows show.
if accurateDistancesEnabled, if accurateDistancesEnabled,
accurateDistancesReady(userLat: userLat, userLng: userLng), accurateDistancesReady(userLat: userLat, userLng: userLng),
let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) { let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) {
return StationDistanceDisplay(km: meters / 1000.0, isApproximate: false) let returnKM = (entry.meters + (entry.returnMeters ?? entry.meters)) / 1000.0
return StationDistanceDisplay(
km: entry.meters / 1000.0,
isApproximate: false,
returnKM: returnKM,
returnIsApproximate: entry.returnMeters == nil
)
} }
return StationDistanceDisplay(km: station.distanceKM(to: userLat, lng2: userLng), isApproximate: true) let km = station.distanceKM(to: userLat, lng2: userLng)
return StationDistanceDisplay(
km: km,
isApproximate: true,
returnKM: km * 2,
returnIsApproximate: true
)
} }
/// Radius test on the SAME distance the UI shows. Filtering straight-line /// Radius test on the SAME distance the UI shows. Filtering straight-line
@@ -1710,35 +1789,50 @@ struct RoadDistanceDiagnostics: Equatable {
/// road distance or the straight-line fallback. The confidence travels WITH the /// road distance or the straight-line fallback. The confidence travels WITH the
/// value, so a label can never claim a confidence the figure doesn't have. /// value, so a label can never claim a confidence the figure doesn't have.
struct StationDistanceDisplay: Equatable { struct StationDistanceDisplay: Equatable {
let km: Double let km: Double
let isApproximate: Bool let isApproximate: Bool
let returnKM: Double
let returnIsApproximate: Bool
/// Row/sheet label: a straight-line fallback is marked "Approx." so a figure init(km: Double,
/// that is NOT a routed road distance can never be read as one, and a cached isApproximate: Bool,
/// road distance stays a bare figure. It updates by itself once routing lands, returnKM: Double? = nil,
/// because `FuelStore.stationDistanceDisplay` re-reads the cache every call. returnIsApproximate: Bool? = nil) {
func label(unit: DistanceUnit) -> String { self.km = km
let text = unit.format(km) self.isApproximate = isApproximate
return isApproximate ? "Approx. \(text)" : text self.returnKM = returnKM ?? km * 2
} self.returnIsApproximate = returnIsApproximate ?? isApproximate
}
/// Row/sheet label that keeps the one-way station distance primary, then /// Row/sheet label: a straight-line fallback is marked "Approx." so a figure
/// adds the out-and-back distance in brackets. This matches Best Value's /// that is NOT a routed road distance can never be read as one, and a cached
/// "extra return travel" basis without changing the actual distance model. /// road distance stays a bare figure. It updates by itself once routing lands,
func labelWithReturn(unit: DistanceUnit) -> String { /// because `FuelStore.stationDistanceDisplay` re-reads the cache every call.
"\(label(unit: unit)) (\(unit.format(km * 2)) return)" func label(unit: DistanceUnit) -> String {
} let text = unit.format(km)
return isApproximate ? "Approx. \(text)" : text
}
/// VoiceOver form of `label(unit:)`. /// Row/sheet label that keeps the one-way station distance primary, then
func spokenLabel(unit: DistanceUnit) -> String { /// adds the independently measured or estimated out-and-back distance.
let text = unit.spoken(km) func labelWithReturn(unit: DistanceUnit) -> String {
return isApproximate ? "Approximately \(text)" : text let returnText = unit.format(returnKM)
} let returnLabel = returnIsApproximate ? "Approx. \(returnText)" : returnText
return "\(label(unit: unit)) (\(returnLabel) return)"
}
/// VoiceOver form of `labelWithReturn(unit:)`. /// VoiceOver form of `label(unit:)`.
func spokenLabelWithReturn(unit: DistanceUnit) -> String { func spokenLabel(unit: DistanceUnit) -> String {
"\(spokenLabel(unit: unit)), \(unit.spoken(km * 2)) return" let text = unit.spoken(km)
} return isApproximate ? "Approximately \(text)" : text
}
/// VoiceOver form of `labelWithReturn(unit:)`.
func spokenLabelWithReturn(unit: DistanceUnit) -> String {
let returnText = unit.spoken(returnKM)
let returnLabel = returnIsApproximate ? "Approximately \(returnText)" : returnText
return "\(spokenLabel(unit: unit)), \(returnLabel) return"
}
} }
/// Cached Apple-Maps road distances for nearby stations (see /// Cached Apple-Maps road distances for nearby stations (see
+1 -1
View File
@@ -21,7 +21,7 @@ enum SiriCheapestLookup {
lng: Double, lng: Double,
withinMiles: Double withinMiles: Double
) -> FuelStation? { ) -> FuelStation? {
let radiusKM = withinMiles * 1.60934 let radiusKM = withinMiles * FuelSavingsCalculator.kilometresPerMile
// Same radius rule as the app list: the distance the answer would SHOW // Same radius rule as the app list: the distance the answer would SHOW
// (cached road route where there is one). // (cached road route where there is one).
return stations return stations