Compare commits

...
Author SHA1 Message Date
FuelBoard Contributor 5900a81473 Station detail: price above its age, and the verdict caption on its own line
- More info lists the fuel price first, with "Price updated" beneath it:
  the price is what the row is about, the age qualifies it.
- The detail sheet stacks "vs closest eligible station" under the verdict
  capsule, matching the listing rows, so the caption no longer shares a
  line with the verdict it describes.
2026-10-15 22:48:07 +01:00
FuelBoard Contributor ed3a5137cd Best Value rows: capsule on its own line, comparison caption beneath
The verdict capsule shared a line with "vs closest eligible station"; the
caption's layoutPriority squeezed the capsule until it truncated
("Approx. £2.75 better off after tra…"), and in this sort mode the verdict
is the point of the row.

- The capsule now owns the line and the caption sits under it.
- The capsule text also shrinks rather than truncating at large Dynamic
  Type sizes, since nothing competes with it for width.
- The detail sheet keeps its side-by-side layout: it has the room, and the
  verdict there is the sheet's headline.
2026-10-15 22:25:53 +01:00
FuelBoard Contributor 5f3051aed7 Best Value: one price age instead of a source breakdown
Dated pricing had crowded More info and the station rows, and most of the
extra copy described a distinction the data cannot make: every station
carries a single station-wide timestamp, so "grade-specific vs
station-wide" never varied in practice.

- More info shows one row, "Price updated 28h ago" (or "Not provided"),
  replacing the separate station-report and fuel-update-time rows and the
  blanket "price freshness is not confirmed" label.
- Rows and the detail sheet stay quiet when a saving is shown; only an
  ineligible comparison explains itself. Provisional savings already read
  as estimates from the capsule, so the fuel-named warning lines are gone.
- Freshness/eligibility logic is unchanged: it still gates the baseline and
  still supports grade-specific timestamps for when the relay emits them.
- Tests updated; localisation keys for the removed copy dropped.
2026-10-15 22:00:48 +01:00
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 6ea675499e Best Value detail sheet: keep the verdict capsule left-aligned
The capsule was right-aligned on its own line; the user asked for it back at
the left margin, leaving the Total row as it is.
2026-10-14 11:31:59 +01:00
FuelBoard Contributor 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
FuelBoard Contributor 3c424ab397 Best Value: candidate-priced fills, key copy, detail total 2026-10-14 10:43:12 +01:00
FuelBoard Contributor 2ed9cbf4e8 Show return distance on station rows 2026-10-14 08:19:14 +01:00
FuelBoard Contributor 4811b1cfa5 Gate detail diagnostics behind debug trigger 2026-10-14 07:54:32 +01:00
FuelBoard Contributor 6f177837a5 Stop Approx. distances beside routed values; add Best Value alert mode
Distances (the "Approx. 1.5 mi" report):
- Refuse junk CoreLocation fixes (invalid accuracy, or a coarse >300 m fix
  within 3 km of the position already held; a coarse fix in another city is
  real travel and is still accepted).
- Resolve ONE display basis per render through FuelStore.distanceDisplayOrigin,
  used by rows, radius filter, sorting, savings figures and the Live Activity,
  so a stray fix can no longer re-base the list outside the route cache's 600 m
  ring while the routed values sit unused in the cache.
- Treat an incomplete route set as a state: retry it after 30 s rather than the
  full 10-minute throttle, and repair a stale incomplete flag once every
  in-radius station has a route (nothing left to route, flag stuck down).
- Widget extension resolves the same origin for fetch, radius, ordering and
  labels, and keeps its own fix in fuelboard.widgetLocation instead of
  overwriting the app's GPS fix.

Alerts:
- Best Value as a distinct alert mode on the existing geofence/notification
  pipeline, with a persisted threshold; reverts to cheapest when the feature is
  unconfigured and is not selectable while disabled.

Diagnostics (DEBUG, gated): FB_DIST_FOCUS now prints FB-DIST-ORIGIN (candidate,
accuracy, anchor, offset, resolved branch, served origin), FB_FAKE_FIX feeds a
synthetic fix through the real guard, FB_STALE_INCOMPLETE forces the stale-flag
state, and the route-fill status is surfaced in Settings behind debug mode.
2026-10-13 22:04:00 +01:00
FuelBoard Contributor 2a2823c180 Accurate distances toggle, Approx. distance markers, and Closest-mode speedup
Distance confidence
- Every distance surface takes the figure AND its "Approx." marker from one
  FuelStore.stationDistanceDisplay call (station rows, detail sheet, widget,
  Live Activity, watch, Siri), so a surface can never mark one figure approximate
  while showing a routed one. Radius filters and Closest ordering test that same
  displayed distance, so no row can read a distance outside the radius that
  filtered it (the reported "14.6 mi inside a 10-mile list").

Accurate distances (Settings -> Distances, default off)
- Off: the browse layer serves the straight line, marked Approx., for every row,
  so two installs agree to the metre and the app makes no route requests at all.
- On: routed distances for the whole search area, unmarked, served only from a
  COMPLETE set anchored within 600 m — a partial fill reverts every row at once
  rather than mixing road and straight-line bases.
- Best Value maths sits deliberately outside the toggle: it keeps using measured
  road routes and its own confidence marking, so money figures never move with
  the switch.
- Fills run only while the toggle is on and the app is frontmost; capacity raised
  to 600 stations per pass; completeness tolerates pins Apple Maps has no route
  for, which otherwise left an area permanently incomplete and the whole list
  stuck on estimates.

Performance (the reported slowdown switching to Closest / launching with it)
- FuelStore.accurateDistancesEnabled was a UserDefaults(suiteName:) read per
  call: a synchronous cfprefsd round-trip measured at ~1.9 us, ~200k calls per
  Closest sort. Memoised, with the setter keeping it in step.
- Both keyed sorts decorate their keys — one distance per station, carried as an
  index instead of copying whole stations into the decorated array — and the
  comparison baseline plus per-station journeys now come from ONE pass instead of
  two full scans per tab. The Stations tab also reuses its single sorted list for
  the count and the TOP badge instead of sorting the country three times.
- Measured on iPhone SE (7,914-station Closest pool): sort 36.6 ms -> 17.1 ms per
  list, Cheapest 6 ms, Best Value 11 ms.
- DEBUG probes: FB_PERF list timing, FB_PERF_BENCH toggle-read benchmark, and
  FB_DIST_FOCUS now reports the served value, marker and mode (and re-prints when
  a fill lands). FB_ACCURATE_DISTANCES / FB_SORT_MODE env forms for devices, where
  devicectl eats a leading-dash argument.

Tests: 198, 0 failures. Device and simulator Debug builds green.
2026-10-13 18:01:45 +01:00
FuelBoard Contributor e0a997ecb0 Fix Release archive build for Best Value diagnostics; bump build to 26 2026-10-13 13:16:52 +01:00
FuelBoard Contributor 8436e87398 Avoid duplicate Best Value rank crashes 2026-10-13 12:15:00 +01:00
FuelBoard Contributor 9aa8a2ede1 Clarify Best Value route confidence 2026-10-13 10:30:41 +01:00
FuelBoard Contributor d8a85b7aa0 Bump release version to 1.1.0 2026-10-13 09:15:07 +01:00
FuelBoard Contributor d5e7ff737f Best Value page: the button says Continue in both states
The declined state had its own "Not now" label, on the theory that declining
should read as a decision rather than a skip. In practice the toggle above it
already shows which answer is being given, so the second label only added a
second thing to read. The label is now an unconditional Continue for both
states, which also let primaryTitle drop the settings value it no longer
consulted.

Nothing else about the page changed: the toggle still hides the sliders when
off, the "can be turned on in Settings" note still appears when declined, and
the settings still save as the controls move.

Verified on the simulator (OCR of the rendered screens): the announcement page,
and the onboarding page with the toggle both off and on, all show Continue as
the bottom button. No "Not now" remains in the app's source. 180 tests,
0 failures.
2026-10-11 21:08:46 +01:00
FuelBoard Contributor bfa9209d68 Best Value: announce the page to existing users, once per announcement version
Users who finished onboarding before Best Value existed are "completed, not
fresh" forever, so the first-run gate never fires again for them and the feature
stayed invisible unless they went looking in Settings. They now get the Best
Value page on its own, once per announcement version — deliberately NOT a replay
of the onboarding flow, which would re-run permission pages they answered long
ago and has no skip.

- FuelStore: "fuelboard.bestValueIntroVersion", stored through the same
  saveString/loadString path as the onboarding flag and compared against a
  CODE-SIDE "currentBestValueIntroVersion". Deliberately not
  CFBundleShortVersionString: keying on the bundle version would re-announce on
  every bugfix release. Bump the constant to re-announce on purpose.
- FuelStore.shouldAnnounceBestValue(settings:hasSeen:onboardingDue:isSuppressed:)
  holds the rule, with every fact injectable so it is unit-tested instead of
  being driven through a simulator. It declines when onboarding is due or on
  screen, when this announcement version has been seen, and when the user has
  already configured Best Value themselves.
- BestValueAnnouncementHost wraps the app's content and owns the presentation
  state, so ContentView's change is a single wrapper and the whole feature lives
  in the new file. It reloads the stored settings on dismiss, because the page
  writes straight to the store rather than through ContentView's binding.
- The page is now ONE view, BestValueIntroContent, used by first-run onboarding
  (page 5 of 6) and by the announcement, so the copy, the sliders and the
  "declining is a decision" rule cannot drift. OnboardingView drops its private
  duplicate of the page, its bindings and its read-outs, and finish() marks the
  announcement seen so a new user never sees the page twice.

QA hooks (DEBUG): -showBestValueIntro forces the announcement, and -skipOnboarding
suppresses it. Worth knowing now that a returning install announces on a PLAIN
launch, so tab screenshots need -skipOnboarding.

Verified on the simulator (OCR of the rendered screens): a plain launch of an
install that has completed onboarding shows the single announcement page — Best
Value title, explanation, "Turn on Best Value", the "Settings, under Fuel
Savings" note and a "Not now" button, with no page dots; -skipOnboarding shows
no cover at all; -showBestValueIntro forces it. 180 tests, 0 failures (8 new).
2026-10-11 20:53:09 +01:00
FuelBoard Contributor a69ee14e07 Onboarding: add a Best Value page before the final screen
New page 5 of 6, sitting between "Prices, ready when you are" and "You're all
set":

- Explains the feature: the cheapest pump isn't always the cheapest fill-up.
- Asks whether to switch it on, with a toggle.
- Switched ON, it asks for the two figures the ranking needs — average fuel
  economy and typical fill-up — using exactly the Settings slider pattern
  (read-out above a slider, £/Litres picker, no text entry anywhere).
- Switched OFF, it says where to switch Best Value on later ("Settings, under
  Fuel Savings") instead of showing controls the user has just declined, and
  the button reads "Not now" rather than "Continue" so declining reads as a
  decision.

Chosen figures are written to the store as the controls move — the same
immediate-persist rule as Settings — so swiping past the page cannot lose a
choice. ContentView now reloads the stored savings settings when onboarding
dismisses: onboarding is a full-screen cover writing straight to the store
rather than through ContentView's binding, so without the reload the choice
would not take effect until the next launch.

QA hooks (DEBUG only, alongside -tab / -skipOnboarding): -showOnboarding forces
the cover up (the app-group onboarding flag survives an app delete, so a
reinstall alone won't bring it back), -onboardingPage N opens straight on a
page, -onboardingBestValue on|off forces the page's choice.

Verified on the simulator: with the toggle off the page shows the title,
explanation, toggle, the "set it up in Settings" note and a "Not now" button;
with it on it shows the MPG slider (45 MPG), the £/Litres picker, the fill-up
slider (£40), the "change these in Settings" note and "Continue". Page 5 is
still "You're all set". Checked on an iPhone 17 Pro and on an iPhone SE (3rd
gen) screen — nothing clips on the small one.

172 tests, 0 failures.
2026-10-11 20:33:30 +01:00
FuelBoard Contributor f1d9e9591d Settings: Fuel Savings moves to the first section
It was the third block in the List (after Units and Tap action), so the feature's
own controls — and the MPG/fill-up figures that drive the Best Value ranking —
sat below unrelated settings. The section (header, toggle, sliders, footer) is now
the first thing in the List; Units and Tap action follow in their existing order,
unchanged.

The move is a pure reorder: no copy, no binding, no gating changed. The section
still greys its controls out when Best Value is switched off, and the footer
narrative is unchanged.

Verified on the simulator: Settings opens with Fuel Savings -> Best Value ->
Average fuel economy / 45 MPG, with Units and Tap action below.
2026-10-11 20:20:37 +01:00
FuelBoard Contributor d348157a45 Best Value: rename the remaining user-facing copy, hide the sheet section when off
Following the sort-segment rename, the rest of the user-facing "Worth the Drive"
copy moves to "Best Value".

- Settings: the toggle is now just "Best Value" (was "Enable Worth the Drive"),
  and the narrative footer opens "Best Value estimates fuel cost only, …".
- More info sheet: the section header is "Best Value" (was "Worth the drive?"),
  and the section itself is now conditional. `fuelSavings` is nil whenever the
  feature is off — `savingsLookup` returns nil when it is not configured — so
  the heading is never left dangling over an empty section while the feature is
  disabled.
- The unavailable text reads "Best Value unavailable: …".
- Comments and doc comments that named the sort were updated so they match the
  UI instead of contradicting it.

Deliberately unchanged: the enum's raw value stays `worthTheDrive`, so stored
sort preferences and widget configs keep decoding, and FuelSavingsStatus keeps
its verdict wording ("Worth the drive" / "Not worth the drive") — that describes
a station's verdict rather than the feature, and is not shown in the UI.

Verified on the simulator: with the feature OFF the more info sheet shows
Address / Distance / Directions and no heading at all; with it ON the sheet
shows the "Best Value" section; Settings reads "Fuel Savings" / "Best Value"
with no "Enable" anywhere. 172 tests, 0 failures.
2026-10-11 20:11:57 +01:00
FuelBoard Contributor 801f969644 Stations sort: "Worth the Drive" segment is now "Best Value"
The third sort segment, the caption line it drives on the Stations tab, and the
Settings sentence about sorting now read "Best Value" — that is
SortMode.worthTheDrive.displayName plus the two copy sites that name the sort.

The feature that backs it keeps the name "Worth the Drive": the Settings toggle
("Enable Worth the Drive"), the Fuel Savings section header and footer, the sheet
header "Worth the drive?", the "Worth the Drive unavailable: …" text and
FuelSavingsStatus.worthTheDrive's own wording are all unchanged.

The enum's raw value stays `worthTheDrive`, so stored sort preferences and widget
configs keep decoding — only the label moved.

3 new tests pin the three sort names, the stability of the raw values, and the
fact that the feature's own wording did not change (172 total, 0 failures).

Verified on the simulator: the Stations segmented control reads
Closest | Cheapest | Best Value.
2026-10-11 20:01:32 +01:00
FuelBoard Contributor 79c012b405 More info sheet: drop the Name row — the header already shows it
The sheet's header (navigationTitle, .inline) shows the station name, and the
address row no longer leads with the name either, so the Name row restated the
header immediately above the address. The section now opens straight with
Address; distance, Worth the drive and the buttons are untouched.

Verified on the simulator: the sheet reads "Sainsbury's Halifax" in the header
with rows "Address / WADE STREET, HX1 1SB" and "Distance / 0.5 mi", and no Name
row. No other surface in the app prints a Name row.
2026-10-11 19:40:11 +01:00
FuelBoard Contributor bb053de8b1 Listing rows: verdict text hugs its icon, and the address stops repeating the name
Two things the listing row was saying twice, or too loosely.

- The verdict capsule used a Label, and a Label lays its icon and title out
  across the width it is given: in a listing row that left 22pt of empty space
  between the symbol and "Save £1.07 after travel". It is now an HStack with
  an explicit 3pt spacing (5pt in the detail sheet), so the text sits beside its
  icon and the capsule background hugs the pair. Measured on the same row: the
  capsule text moved x 412 -> 341 and the pill 270-831 -> 270-691.

- The address line printed the station name a second time. The official Fuel
  Finder address field usually opens with the station's own name ("CHIDSWELL
  SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW" for "Jet Chidswell"): 14% of
  the dump's addresses start with the exact name, and 13% also end with the
  postcode the row appended again. StationAddressLine (new, in Shared/, so the
  row and the detail sheet share one rule) drops a leading repeat of the name
  and writes the postcode exactly once.

The rules are deliberately conservative: a leading segment carrying a house
number is a real street address and is left alone ("41 ANNACLOY RD ANNACLOY"),
and a partial-word match ("Jet" against "JETSON FILLING STATION") never cuts
into a word. 14 new tests (169 total, 0 failures) pin the shapes found in the
real dump as well as the examples above.

Verified on the simulator: the first listing row's second line went from
"CHIDSWELL SERVICE STA..." to "LEEDS ROAD, DEWSBURY...", and the detail sheet
reads "Sainsbury's Halifax / WADE STREET, HX1 1SB".
2026-10-11 19:33:26 +01:00
FuelBoard Contributor 85f601fffd Fuel Savings: the feature's own settings grey out when it is switched off
With "Enable Worth the Drive" off, the MPG slider, the fill-up unit picker
and the fill-up slider stayed live — editable settings for a switched-off
feature. They now sit in a Group disabled by `enabled`, so they grey out but
stay on screen and keep their values (the same rule as the distance picker
in Closest mode), and re-enabling restores exactly what was there.

The surfaces that USE the feature were already gated on `isConfigured`
(which includes `enabled`), so the in-list fill-up control and the Worth the
Drive sort option still disappear entirely rather than greying out.

Verified on the simulator with the toggle off vs forced on: the MPG slider
row and the unit picker row both measurably dim — MPG row mean RGB
33.06/35.62/39.49 off vs 37.61/42.70/48.43 on; picker 40.00/40.00/42.62 vs
53.35/53.36/56.82 — while the toggle row itself changes as expected
(54.85/64.77/58.96 with the switch on).
2026-10-11 19:15:26 +01:00
FuelBoard Contributor cf3abb3fa7 Fuel Savings: MPG and fill-up are sliders, not typed fields
The two Fuel Savings figures and the in-list fill-up control were text
fields. A typed figure could sit outside the slider's own bounds, so the
control and the number it claimed to show could disagree, and the decimal
pad needed a Done button to escape.

- Settings: "Average fuel economy" and "Typical fill-up" are now sliders
  with a read-out ("45 MPG", "£40" / "40 L") in place of the field. The
  warning copy reads "Set a realistic..." rather than "Enter...".
- FuelSavingsAdjustmentView (shared by the Stations Worth-the-Drive header
  and the Favourites tab) drops its text field and gains the same read-out;
  the typed-value machinery and the -focusFillAmount QA hook go with it.
- Slider bounds live in FuelSavingsCalculator (fillAmountSliderRange/Step,
  mpgSliderRange/Step) so the Settings control and the in-list control can
  never offer different ranges, and the fill-up slider range stays inside
  the calculator's validity ceiling (asserted by test).
- Two tests pin those invariants: every slider position is a valid amount,
  and both MPG bounds are valid MPG values. 155 tests, 0 failures.
- Comments that described the removed fields were updated; the keyboard
  Done-bar plumbing is kept (no numeric field remains in the app, so it can
  no longer fire) rather than deleted, so any field added later still has an
  escape route.

Verified on the simulator: Settings shows "Average fuel economy / 45 MPG"
with no field, and the Worth-the-Drive fill-up control shows the unit picker
with a "£40" read-out and a slider. The Settings "Typical fill-up" row sits
below the fold on every simulator available here (and synthetic input cannot
scroll it), so those pixels are unverified.
2026-10-11 17:41:53 +01:00
FuelBoard Contributor d0a89d97d5 Worth the Drive: calculation audit + listing/detail polish
Calculation audit (brief config: 30 MPG imperial, £60 fill-up, baseline
Rontec Shelf 171.9p at 0.4 mi):

- One mile<->kilometre constant pair (kilometresPerMile 1.609344,
  metresPerMile 1609.344) replaces the mixed 1609.34 / 0.621371 / 1.60934
  used in the savings path. Effect ~2 ppm (invisible), but the conversions
  are now auditable in one place.
- New FuelSavingsCalculationAuditTests (20 deterministic tests, no live
  MKDirections) pins the brief's three stations - Tesco 170.9p/2.0 mi,
  MFG Hare & Hounds 172.9p/2.8 mi, PGG Napoleon 171.9p/2.8 mi - plus
  imperial-vs-US gallon, MPG and budget scaling, the break-even tolerance
  boundary, the nearer-candidate clamp, route-unavailable, mixed-basis
  estimates and display-rounding boundaries.
- Formula unchanged, and verified correct: fixed quantity (budget / baseline
  price), pump saving, round-trip extra distance clamped at zero, travel fuel
  priced at the candidate station, +/-5p break even, baseline labelled
  "Closest station", sorting on the unrounded net saving. The brief's "£0.18"
  for Tesco is the rounded 1 dp extra-distance caption (2.02-2.05 mi reads as
  "2.0 mi"), not a rounding bug in the money.

UI (uncommitted since d7a0ecb):

- Done bar drawn in the bottom safe area from keyboard notifications:
  .toolbar(placement: .keyboard) is device-dependent (present on the 15 Pro,
  absent on the SE on the same iOS build).
- Favourite star in the More-info heading, wired to the same fuel-scoped
  toggle as the listing rows at all three call sites.
- CHEAPEST/TOP badge moved above the price so it reads as part of it.
- Listing rows carry the real session fill-up amount instead of a hard zero
  (the compact banner does not print it today; a future row layout would).

153 tests, 0 failures. Simulator and device builds succeed.
2026-10-11 17:19:10 +01:00
FuelBoard Contributor d7a0ecb79a Add Worth the Drive fuel savings with road-routed estimates
Optional Fuel Savings feature (off by default): vehicle MPG in UK imperial gallons, fill amount in pounds or litres, persisted through the existing keychain/app-group store. Ranks the WHOLE station pool by estimated net saving (fuel-price saving at each pump minus the extra return-trip driving cost) using MapKit road distances with a straight-line fallback marked (est.).

The closest station with a valid price for the selected fuel is the comparison baseline and is labelled "Closest station"; other rows read "Save £X.XX after travel" / "£X.XX worse off after travel" / "Break even". Labels live in the shared model so they are unit-tested. The TOP badge still marks the cheapest pump price and reads "CHEAPEST" when the list is ranked by net saving.

Road routing now prioritises the Worth-the-Drive candidates plus the baseline and merges cache entries instead of replacing them, so far-out stations get real routes rather than permanent straight-line estimates. Numeric fields gain a Done button above the keypad (decimalPad has no Return key); the fill-up control is hidden unless the feature is configured; Favourites shares one session fill amount with the station list. 131 tests.
2026-10-11 14:34:53 +01:00
FuelBoard Contributor 1044d009ae Refine onboarding permission flow 2026-10-11 09:44:02 +01:00
FuelBoard Contributor 230f9c751b Refresh bundled fuel dump 2026-10-01 16:43:12 +01:00
FuelBoard Contributor 13d84eb32d Localize alerts and favourites copy 2026-10-01 15:25:08 +01:00
FuelBoard Contributor 824923b2ac Localize onboarding copy 2026-10-01 12:54:43 +01:00
FuelBoard Contributor 50c543c776 Harden App Store build for review 2026-10-01 12:19:05 +01:00
FuelBoard Contributor c7731d2b36 Fix App Store background location mode 2026-10-01 07:44:40 +01:00
FuelBoard Contributor 7633a8df90 Add FuelBoard device and IPA helpers 2026-09-26 20:03:40 +01:00
FuelBoard Contributor b8f267814c Update FuelBoard assets and bundled data 2026-09-26 20:03:35 +01:00
FuelBoard Contributor c3a975eb10 Harden watch sync and App Group storage 2026-09-26 20:03:29 +01:00
FuelBoard Contributor 004fe2f787 Configure FuelBoard XCTest target 2026-09-26 20:03:20 +01:00
FuelBoard Contributor 6900345aab Ignore generated build directories 2026-09-26 19:34:50 +01:00
FuelBoard Contributor d0ee8edac3 Document and support StoreKit sandbox testing 2026-09-26 19:34:41 +01:00
FuelBoard Contributor 20f53b9290 Improve StoreKit tip diagnostics and pricing 2026-09-26 14:38:32 +01:00
FuelBoard Contributor 2921271be7 chore: update build settings and screenshots 2026-09-20 14:43:01 +01:00
FuelBoard Contributor 7cb23e09c2 fix: retry StoreKit tip product lookup 2026-09-20 14:33:28 +01:00
FuelBoard Contributor 7dbd48786f docs: add FuelBoard privacy policy 2026-09-19 14:04:23 +01:00
FuelBoard Contributor 3a1ef0bd59 feat: indicate Costco membership requirement 2026-09-19 08:10:15 +01:00
FuelBoard Contributor 551a40ddf0 feat: show station info on long press 2026-09-19 07:41:52 +01:00
FuelBoard Contributor 3c83c9b518 fix: shorten report confirmation action 2026-09-17 22:30:25 +01:00
FuelBoard Contributor 6c8fd8b0de feat: add GOV.UK report issue action 2026-09-17 22:18:27 +01:00
FuelBoard Contributor 44cbd3b1e4 feat: route all Live Activity island taps through Tap station action
Expanded leading/trailing, compact and minimal island views had
no Link, so tapping them just opened the app with no destination.
Every island region now links to the same tap destination as the
Lock Screen card (Maps vs in-app detail).

Tap hint captions follow the setting too: Tap for details vs
Tap for directions.
2026-09-16 16:30:27 +01:00
FuelBoard Contributor 20fe251b8e feat: honour Tap station action in widgets
Small widgetURL and medium list Links route through
StationTapAction via FuelStation.tapDestinationURL: Open map
keeps the Maps URL, More info uses a fuelboard://station
deep link (stationID plus coordinate/name fallback).

Deep link carries fallback coords; ContentView presents the
detail sheet when the station is cached, else the map sheet.
Live Activity detailURL upgraded to the same rich link.
2026-09-16 16:15:07 +01:00
FuelBoard Contributor 465e6cd4d4 feat: honour Tap station action on notification and Live Activity taps
Notification body/action taps route through StationTapAction:
Open map opens Apple Maps (existing); More info presents the
in-app detail sheet (falls back to the map sheet when the
station is not in cache).

Live Activity Lock Screen card and island station row pick
their Link destination the same way: Maps URL for Open map,
fuelboard://station deep link for More info, handled in
FuelBoardApp and observed by ContentView.

Also adds the small non-interactive map preview above the
Directions button in the More info sheet.
2026-09-16 13:11:36 +01:00
FuelBoard Contributor 1299b4a004 feat: split Tap action into its own Settings section
Moves the Tap station picker out of Units into a dedicated Tap
action section, with the tap descriptor text as its footer. Units
footer reverts to distances/prices only.
2026-09-14 22:11:23 +01:00
FuelBoard Contributor cc948dac3a feat: station tap action toggle (open map vs more info)
Settings → Units gains a Tap station picker: Open map (default,
historic behaviour — tap opens Apple Maps directions) or More info
(tap shows a detail sheet with name, address, distance from current
location, and a Directions button). Applies to Stations and
Favourites tabs; persisted keychain-first as
fuelboard.stationTapAction. Stations footer hint follows the setting.
2026-09-14 22:03:20 +01:00
FuelBoard Contributor ac44cb4ce7 Merge feature/watch-app into main 2026-09-14 21:39:18 +01:00
FuelBoard Contributor abc737eb2d test: symlink DataRefreshMode into test target
FuelStore references DataRefreshMode; without the symlink the
SPM test target fails to compile. 107 tests pass.
2026-09-14 21:39:14 +01:00
FuelBoard Contributor 3d5c999dc2 build: align embedded bundle versions for archive 2026-08-31 11:35:22 +01:00
FuelBoard Contributor d899409b78 build: align all target build numbers to 3 2026-08-31 11:18:05 +01:00
FuelBoard Contributor 8bbcb0b4dc watch: polish refresh status and compact live activity 2026-08-31 10:40:16 +01:00
FuelBoard Contributor 767080a983 watch: use MapKit directions handoff 2026-08-31 08:23:07 +01:00
FuelBoard Contributor 7a84364238 watch: fix snapshot sync and polish favourites UX 2026-08-31 07:48:20 +01:00
FuelBoard Contributor 486b8cff2e Fix watch fuel icons and snapshot sync 2026-08-30 23:35:59 +01:00
FuelBoard Contributor d40bcaeca0 Fix watch icon pixel sizes for archive validation 2026-08-30 23:11:27 +01:00
FuelBoard Contributor f2fb075f2b Fix watch app name and unify watch icon assets 2026-08-30 22:58:05 +01:00
FuelBoard Contributor 09740fca18 Fix watch build, status sync, and icon assets 2026-08-30 22:26:30 +01:00
154 changed files with 10369 additions and 1254 deletions
+2
View File
@@ -1,4 +1,6 @@
build/
Builds/
build-*/
build-release/
build-sim/
build-watch/
+130 -28
View File
@@ -1,6 +1,6 @@
# FuelBoard App Store Submission Plan
Updated: 2026-08-16 · Target: safe App Store submission.
Updated: 2026-09-26 · Target: safe App Store submission.
Companion to SECURITY.md (code security) — this file is the *review-gate*
checklist: what must happen, who owns it, and the timeline.
@@ -13,10 +13,108 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
on Apple's network see real data.
- ~~Always-location risk~~ → **Always is requested only when the user enables
Alerts** (ProximityMonitor escalates WhenInUse → Always on alert opt-in).
Onboarding requests WhenInUse only.
Onboarding requests WhenInUse only. FuelBoard does **not** declare the
persistent real-time background location mode; optional alerts use
CoreLocation region monitoring/significant-change behaviour instead.
## StoreKit sandbox: verified working configuration and caveats
### Verified configuration
The FuelBoard consumable tip products are:
- `com.apt.fuelboard.tip099`
- `com.apt.fuelboard.tip299`
- `com.apt.fuelboard.tip499`
The app requests these exact **Product IDs** with StoreKit 2
`Product.products(for:)`; the UI names are display labels and are not used for
lookup. The app bundle ID, App Store Connect app, explicit App ID, development
team, and Sandbox Apple Account must belong to the same developer account.
Two test environments must remain distinct:
- **Local StoreKit testing:** select `/Users/apt/workspace/fuelboard/FuelBoardTips.storekit`
in Xcode, under Product > Scheme > Edit Scheme > Run > Options. This bypasses
App Store Connect and confirms the app's StoreKit loading and purchase code.
- **Apple sandbox testing:** select **None** for StoreKit Configuration, then
use Product > Run on a physical device with the development-signed build and
a Sandbox Apple Account signed in under Settings > Developer. This exercises
the real App Store Connect catalog.
The local configuration returning all three products does not prove that the
Apple sandbox catalog is ready. Conversely, a successful local run showing `$`
does not mean the App Store Connect price is USD: `Product.displayPrice` is
formatted for the active StoreKit/device storefront. Local StoreKit prices are
placeholders and do not upload to App Store Connect.
### Availability versus pricing
App Store Connect has separate controls:
- **Availability** controls the storefronts where the IAP is offered. The
verified FuelBoard configuration uses **United Kingdom only**.
- **Price schedule** may show generated prices for **175 countries and
regions**. This is expected and does not make the IAP available in all 175
storefronts; it is Apple's price conversion schedule.
The Sandbox Apple Account's App Store Country or Region must match an enabled
IAP storefront. After changing the sandbox tester's storefront, sign out and
back in under Settings > Developer so the storefront change activates.
### Important propagation/cache caveat
During sandbox diagnosis, `Product.products(for:)` completed successfully but
returned zero products with the IAP availability set to UK. Temporarily
changing one product's availability to all regions caused it to appear in the
sandbox catalog; changing it back to UK left it working. This indicates an
Apple-side catalog refresh or propagation/cache issue, not a change required in
the StoreKit code. Apple documents that product metadata changes can take up to
approximately one hour to appear in sandbox.
If an otherwise valid product returns zero products:
1. Confirm the Sandbox Apple Account belongs to the same developer team.
2. Confirm its storefront is an enabled availability region.
3. Sign out and back in to the sandbox account on the device.
4. Delete/reinstall the development build and retry with StoreKit Configuration
set to **None**.
5. If necessary, temporarily broaden one product's availability to all
regions, allow propagation, verify the product, then restore the intended
availability and allow propagation again.
Record whether the diagnostic result is **request failed**, **completed with
zero products**, or **a partial product set**. These are different failure
classes. Do not add fallback prices or change product lookup to display names.
Sandbox testing does not require prior App Review approval of the IAP. It does
require active developer membership, an active Paid Applications Agreement,
complete banking and tax information, valid product metadata and pricing, the
correct app/team association, and a matching Sandbox Apple Account.
## Hard blockers (must happen before ANY submission)
0. **[USER] Paid Applications Agreement in effect** — REQUIRED for paid IAP
configuration and sandbox testing. App Store Connect →
Business > Paid Applications Agreement → View and Agree. **This is the most
common cause of a 2.1(b) "unable to purchase item / error message" rejection
— without it every sandbox purchase fails.** (REJECTED once for this:
review date 2026-09-25, version 1.0 (21), iPad Air 11″ iPadOS 27.)
**Status 2026-09-25: agreement clicked Agree but shows "Pending user info" —
this is NOT in effect yet.** Complete the Banking (account + sort code/IBAN)
and Tax (W-8/W-9/UK tax ref, e.g. sole-trader UTR) sections under Business >
Payments & Tax until the agreement shows "Active" and every tab is green.
Only then do sandbox IAPs start working.
0. **[USER] Tip IAPs configured and available to the sandbox** — the three
consumables may exist but must ALSO have complete metadata, a current price,
and an enabled storefront availability. Prior App Review approval or
`Cleared for Sale` status is **not required for sandbox product discovery**.
They must be backed by the **In-App Purchase capability on the App ID**
`com.apt.fuelboard` (developer portal → Identifiers → App ID capability).
3-tier tip code is already correct (retry lookup `7cb23e0`); do NOT churn
SettingsView.swift. Test product discovery and purchase with a Sandbox
Apple Account first: Settings → Support FuelBoard.
1. **[USER] Apple Developer Program membership** — the app has never been
signed (all builds are `CODE_SIGNING_ALLOWED=NO` + ad-hoc re-sign for
SideStore). Requires a paid membership ($99/yr, up to 48 h to approve) and
@@ -25,31 +123,32 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
with the **App Group `group.com.apt.fuelboard`** capability AND the
**In-App Purchase** capability enabled (the widget + app-group cache
depend on the group; the tip jar needs IAP).
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE 2026-08-16** (app + widget
manifests added; UserDefaults `1C8F.1`, Location + Diagnostics declared).
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE** (app, widget, watch app, and
watch wrapper manifests added; UserDefaults `1C8F.1`; app declares Location).
4. **[ME] Real Distribution signing** — Distribution certificate + App Store
provisioning profile (or Xcode automatic signing once the account is in
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
5. **[USER] Privacy policy URL** in App Store Connect — mandatory; the app
collects location. A one-page GitHub Pages privacy policy is fine.
collects location. Policy text is now in `PRIVACY.md`; publish it via the
FuelBoard marketing site or GitHub Pages and paste that public URL into App
Store Connect.
## Required tasks — owner [ME] (technical, do after account exists)
- [x] **PrivacyInfo.xcprivacy** — app + widget manifests added (2026-08-16):
UserDefaults reason `1C8F.1`; app also declares Location (linked, not
tracking) + Diagnostics (not linked, not tracking — the dev beacon)
- [x] **PrivacyInfo.xcprivacy** — app, widget, watch app, and watch wrapper
manifests added: UserDefaults reason `1C8F.1`; app also declares Location
(linked, not tracking). Diagnostic network beacon removed with LAN relay.
- [ ] Distribution archive + TestFlight upload (needs Apple account)
- [x] **Version bump** → `1.0.1` (build 2), app + widget plists
- [x] **Version/build aligned** → `1.0.0` (build 23), app + widget + watch targets
- [x] **OGL attribution** — Settings → Data source footer now reads "Prices
use the UK Fuel Finder dataset (Open Government Licence) and refresh
up to twice a day." (2.3.1 hygiene)
- [x] **Out-of-UK empty state** — "No X stations within N miles" + UK-only
explainer + "Browse all UK stations" button (switches to Closest)
- [ ] ~~Gate dev surface behind #if DEBUG~~ **DECLINED — kept in Release on
purpose**: the user's home-testing installs the Release IPA and toggles
`fuelboard.relayFallback` via Settings → Debug (revealed only by tapping
About → Version ×5 — invisible to reviewers; no credentials involved).
Gating it out of Release would break that workflow for zero review gain.
- [x] **Remove LAN relay fallback entirely** — no private IP, Local Network
permission string, relay toggle, or diagnostic network beacon remains in
the app/widget/watch binary. Hidden Debug still exists for local notification
and widget diagnostics only.
- [ ] Final pass: `swift test` green (95 tests), archive smoke test on device
- [ ] ~~Capture screenshots from simulator~~ **RAW CAPTURES DONE (2026-08-16)** —
native 6.9″ 1320×2868 PNGs of all four tabs (Stations, Favourites,
@@ -75,8 +174,7 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
category (Utilities), pricing (Free), availability (UK first?)
- [ ] Description, keywords, support URL, marketing URL, privacy policy URL
- [ ] Privacy nutrition labels questionnaire (location: yes, linked to user
for alerts/nearby; diagnostics: yes — the widget-diag beacon, not
linked, not used for tracking; no ATT prompt needed)
for alerts/nearby; no tracking; no ATT prompt needed)
- [ ] Age rating questionnaire (4+; no objectionable content)
- [ ] Export compliance: HTTPS-only + keychain → standard encryption
exemption (5A992a), no documentation needed
@@ -90,19 +188,23 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
> Prices are downloaded from the internet up to twice a day; the app filters
> 5/10/15-mile radii on-device and works offline from a bundled snapshot.
> Location is used to (a) rank stations by distance and (b) power the
> optional Alerts feature: when the user enables alerts, the app geofences
> the cheapest station in their radius and sends a notification when they
> approach it. Always-location is requested only when the user enables
> alerts. The app also provides a home-screen widget showing the current
> cheapest station nearby, and a price-history Trends chart for starred
> stations. All data is UK-wide (Fuel Finder covers England, Scotland, Wales
> and Northern Ireland); users outside the UK see an empty state explaining
> this.
> optional Alerts feature: when the user enables alerts, the app registers
> CoreLocation regions around the cheapest station in their radius and sends
> a notification when they approach it. FuelBoard does not use persistent
> real-time background location tracking; it relies on iOS region monitoring
> and significant-change behaviour for optional alerts. Always-location is
> requested only when the user enables alerts. The app also provides a
> home-screen widget showing the current cheapest station nearby, and a
> price-history Trends chart for starred stations. All data is UK-wide (Fuel
> Finder covers England, Scotland, Wales and Northern Ireland); users outside
> the UK see an empty state explaining this.
## Review risks (know before submitting)
- **Always-location (5.1.1)** — the #1 question. The feature is visible
(Alerts tab) and opt-in; the review notes above say so explicitly.
- **Always-location (5.1.1 / 2.5.4)** — the feature is visible (Alerts tab)
and opt-in; the app does not declare persistent background location mode,
and the review notes describe region monitoring/significant-change rather
than continuous tracking.
- **Reviewer outside the UK** — empty radius; mitigated by the empty-state
task above + review notes.
- **"Cheapest" claims (2.3.1)** — mitigated by the OGL/footnote task.
@@ -110,8 +212,8 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
glance. It is declared in the plist already.
- **Brand logos (Shell, BP, Tesco…)** — Simple Icons (CC0) + Wikimedia,
nominative use (cf. GasBuddy); low risk, keep sources documented.
- **GitHub raw as data source** — public data, no credentials; bundled dump
covers any regional GitHub unreachability.
- **GitHub raw as data source** — public HTTPS data, no credentials or Local
Network permission; bundled dump covers any regional GitHub unreachability.
## Timeline (best case)
Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

+7 -10
View File
@@ -4,7 +4,7 @@ FuelBoard — UK-wide petrol-station price widget + app.
GitHub mirror (`aptonline/fuelboard-data`, 2×/day 10:00+16:00) holds the
full-UK dump (~8,022 stations, all prices); app fetches it once per 12 h and
filters/sorts on-device, with a bundled snapshot as the no-network last resort.
LAN relay is dev-only (hidden `fuelboard.relayFallback` flag).
LAN relay fallback has been removed from the app binary; the public GitHub mirror is the only live network data source.
Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea.
Status: TODO / IN PROGRESS / DONE / BLOCKED.
@@ -211,15 +211,12 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
deploy key, 180-day retention) + history read side (Trends, P1) + **live chain
(`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from
raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) →
**GitHub FIRST everywhere** (alert checks + widget too — the relay is
unreachable from any consumer phone and attempting it would fire the Local
Network prompt); LAN relay joins ONLY behind the hidden dev flag
`fuelboard.relayFallback` (Settings → Debug, off by default); bundled REAL
dump (`FuelBoardDump` dataset, refreshed by `scripts/refresh_bundled_dump.sh`)
is the app's no-network last resort. `FuelBeacon` fire-and-forget X-Client app
ping fires only with the dev flag on. **Local Network permission removed from
onboarding entirely** — data page is informational, replay on cellular can no
longer hang. 95 tests.
**GitHub mirror only everywhere** (alert checks + widget too); no LAN relay
fallback, private IP, Local Network permission, or diagnostic network beacon
ships in the app binary. Bundled REAL dump (`FuelBoardDump` dataset, refreshed
by `scripts/refresh_bundled_dump.sh`) is the app's no-network last resort.
Local Network permission removed entirely — data page is informational, replay
on cellular can no longer hang. 107+ tests.
- [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via
relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials
live in relay `.env` only, never in the IPA
+2 -5
View File
@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.1</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -30,11 +30,9 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>2</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>FuelBoard can optionally connect to the FuelBoard Relay on your local network for faster alerts during development.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
<key>NSLocationWhenInUseUsageDescription</key>
@@ -48,7 +46,6 @@
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>location</string>
</array>
<key>UILaunchScreen</key>
<dict/>
+2 -2
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>FuelBoardWatch</string>
<string>FuelBoard</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<string>FuelBoard</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.1</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
+221 -36
View File
@@ -10,6 +10,17 @@
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
FBA000000000000000000001 /* DataRefreshMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000101 /* DataRefreshMode.swift */; };
FBA000000000000000000002 /* FuelHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000102 /* FuelHistory.swift */; };
FBA000000000000000000003 /* FuelPriceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000103 /* FuelPriceProvider.swift */; };
FBA000000000000000000004 /* FuelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000104 /* FuelStore.swift */; };
FBA000000000000000000005 /* MirrorFuelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000105 /* MirrorFuelProvider.swift */; };
FBA000000000000000000006 /* SiriCheapestLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000106 /* SiriCheapestLookup.swift */; };
FBA000000000000000000007 /* WidgetLocationFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000107 /* WidgetLocationFetcher.swift */; };
FBA000000000000000000008 /* FuelBoardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000108 /* FuelBoardTests.swift */; };
FBA000000000000000000009 /* FuelHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000109 /* FuelHistoryTests.swift */; };
FBA00000000000000000000A /* LiveChainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA00000000000000000010A /* LiveChainTests.swift */; };
FBA00000000000000000000B /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FBA00000000000000000010B /* XCTest.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -37,17 +48,6 @@
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -59,6 +59,17 @@
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000006 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -73,10 +84,23 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = FuelBoardTips.storekit; sourceTree = "<group>"; };
AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FuelBoardSharedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FuelBoardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "FuelBoardWatch Watch App Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
FBA000000000000000000101 /* DataRefreshMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataRefreshMode.swift; sourceTree = "<group>"; };
FBA000000000000000000102 /* FuelHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelHistory.swift; sourceTree = "<group>"; };
FBA000000000000000000103 /* FuelPriceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelPriceProvider.swift; sourceTree = "<group>"; };
FBA000000000000000000104 /* FuelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelStore.swift; sourceTree = "<group>"; };
FBA000000000000000000105 /* MirrorFuelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirrorFuelProvider.swift; sourceTree = "<group>"; };
FBA000000000000000000106 /* SiriCheapestLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiriCheapestLookup.swift; sourceTree = "<group>"; };
FBA000000000000000000107 /* WidgetLocationFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationFetcher.swift; sourceTree = "<group>"; };
FBA000000000000000000108 /* FuelBoardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelBoardTests.swift; sourceTree = "<group>"; };
FBA000000000000000000109 /* FuelHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelHistoryTests.swift; sourceTree = "<group>"; };
FBA00000000000000000010A /* LiveChainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveChainTests.swift; sourceTree = "<group>"; };
FBA00000000000000000010B /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -136,18 +160,37 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
FBA000000000000000000302 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
FBA00000000000000000000B /* XCTest.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
AD64FC4B30739A4B008F27A9 /* Recovered References */ = {
isa = PBXGroup;
children = (
FBA00000000000000000010B /* XCTest.framework */,
);
name = "Recovered References";
sourceTree = "<group>";
};
BB000000000000000000000D = {
isa = PBXGroup;
children = (
FBA000000000000000000200 /* FuelBoardTests */,
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */,
BB0000000000000000000008 /* FuelBoard */,
BB0000000000000000000009 /* FuelBoardWidgets */,
BB000000000000000000000A /* Shared */,
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
BB000000000000000000000E /* Products */,
AD64FC4B30739A4B008F27A9 /* Recovered References */,
);
sourceTree = "<group>";
};
@@ -158,16 +201,52 @@
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
FBA000000000000000000200 /* FuelBoardTests */ = {
isa = PBXGroup;
children = (
FBA000000000000000000201 /* Sources */,
FBA000000000000000000202 /* Tests */,
);
path = FuelBoardTests;
sourceTree = "<group>";
};
FBA000000000000000000201 /* Sources */ = {
isa = PBXGroup;
children = (
FBA000000000000000000101 /* DataRefreshMode.swift */,
FBA000000000000000000102 /* FuelHistory.swift */,
FBA000000000000000000103 /* FuelPriceProvider.swift */,
FBA000000000000000000104 /* FuelStore.swift */,
FBA000000000000000000105 /* MirrorFuelProvider.swift */,
FBA000000000000000000106 /* SiriCheapestLookup.swift */,
FBA000000000000000000107 /* WidgetLocationFetcher.swift */,
);
name = Sources;
path = Sources/FuelBoardShared;
sourceTree = "<group>";
};
FBA000000000000000000202 /* Tests */ = {
isa = PBXGroup;
children = (
FBA000000000000000000108 /* FuelBoardTests.swift */,
FBA000000000000000000109 /* FuelHistoryTests.swift */,
FBA00000000000000000010A /* LiveChainTests.swift */,
);
name = Tests;
path = Tests/FuelBoardSharedTests;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */ = {
isa = PBXNativeTarget;
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */;
buildPhases = (
ADCE5B4B3033A329008C131C /* Sources */,
ADCE5B4C3033A329008C131C /* Frameworks */,
@@ -260,6 +339,23 @@
productReference = BB0000000000000000000007 /* FuelBoard.app */;
productType = "com.apple.product-type.application";
};
FBA000000000000000000300 /* FuelBoardSharedTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */;
buildPhases = (
FBA000000000000000000301 /* Sources */,
FBA000000000000000000302 /* Frameworks */,
FBA000000000000000000303 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = FuelBoardSharedTests;
productName = FuelBoardSharedTests;
productReference = AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -278,6 +374,11 @@
};
BB000000000000000000000F = {
CreatedOnToolsVersion = 16.0;
SystemCapabilities = {
com.apple.InAppPurchase = {
enabled = 1;
};
};
};
};
};
@@ -299,6 +400,7 @@
BB0000000000000000000005 /* FuelBoardWidgets */,
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
FBA000000000000000000300 /* FuelBoardSharedTests */,
);
};
/* End PBXProject section */
@@ -332,6 +434,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
FBA000000000000000000303 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -356,6 +465,23 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
FBA000000000000000000301 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
FBA000000000000000000001 /* DataRefreshMode.swift in Sources */,
FBA000000000000000000002 /* FuelHistory.swift in Sources */,
FBA000000000000000000003 /* FuelPriceProvider.swift in Sources */,
FBA000000000000000000004 /* FuelStore.swift in Sources */,
FBA000000000000000000005 /* MirrorFuelProvider.swift in Sources */,
FBA000000000000000000006 /* SiriCheapestLookup.swift in Sources */,
FBA000000000000000000007 /* WidgetLocationFetcher.swift in Sources */,
FBA000000000000000000008 /* FuelBoardTests.swift in Sources */,
FBA000000000000000000009 /* FuelHistoryTests.swift in Sources */,
FBA00000000000000000000A /* LiveChainTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -405,8 +531,9 @@
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -418,14 +545,13 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -471,8 +597,9 @@
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -484,14 +611,13 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -537,7 +663,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -549,13 +675,13 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -602,7 +728,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -614,13 +740,13 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -698,8 +824,9 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist";
@@ -707,9 +834,10 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
@@ -721,8 +849,9 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist";
@@ -730,9 +859,10 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
@@ -745,7 +875,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist";
@@ -754,7 +884,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -770,7 +900,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist";
@@ -779,7 +909,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -789,10 +919,56 @@
};
name = Release;
};
FBA000000000000000000401 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.sharedtests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
FBA000000000000000000402 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.sharedtests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
ADCE5B573033A32B008C131C /* Debug */,
@@ -837,6 +1013,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
FBA000000000000000000401 /* Debug */,
FBA000000000000000000402 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = BB0000000000000000000004 /* Project object */;
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.8">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BB000000000000000000000F"
BuildableName = "FuelBoard.app"
BlueprintName = "FuelBoard"
ReferencedContainer = "container:FuelBoard.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FBA000000000000000000300"
BuildableName = "FuelBoardSharedTests.xctest"
BlueprintName = "FuelBoardSharedTests"
ReferencedContainer = "container:FuelBoard.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<StoreKitConfigurationFileReference
identifier = "../../../FuelBoardTips.storekit">
</StoreKitConfigurationFileReference>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "en"
region = "GB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersion = "1.0"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BB000000000000000000000F"
BuildableName = "FuelBoard.app"
BlueprintName = "FuelBoard"
ReferencedContainer = "container:FuelBoard.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersion = "1.0"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BB000000000000000000000F"
BuildableName = "FuelBoard.app"
BlueprintName = "FuelBoard"
ReferencedContainer = "container:FuelBoard.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+59 -31
View File
@@ -12,6 +12,9 @@ struct AlertsView: View {
@Binding var enabled: Bool
@Binding var radius: Double // stored in km (monitor + storage)
@Binding var fuel: FuelType // the fuel alerts monitor for
@Binding var alertMode: AlertMode
@Binding var bestValueThresholdGBP: Double
let bestValueConfigured: Bool
@Binding var favouriteDropEnabled: Bool
@Binding var favouriteDropFuel: FuelType
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
@@ -38,7 +41,7 @@ struct AlertsView: View {
/// "5 miles" or "8 km" — for the "Follow search" rows.
private var searchInUnitLabel: String {
let shown = distanceUnit.displayMiles(stationLimit) // stationLimit is always miles
return "\(shown) \(distanceUnit.label(for: Double(shown)))"
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
}
/// A miles value (5/10/15) shown truthfully in the user's unit, e.g.
@@ -46,7 +49,7 @@ struct AlertsView: View {
/// what the option really means.
private func distanceLabel(forMiles miles: Int) -> String {
let shown = distanceUnit.displayMiles(miles)
return "\(shown) \(distanceUnit.label(for: Double(shown)))"
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
}
/// Situational guidance for the currently-selected alert radius, shown
@@ -57,23 +60,23 @@ struct AlertsView: View {
private var alertRadiusGuidance: String {
switch alertChoice.wrappedValue {
case .followSearch:
return "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)."
return String(localized: "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).", comment: "Alert radius guidance for following search distance")
case .fixed(let value):
let optionLabel = "\(value) \(distanceUnit.label(for: Double(value)))"
let optionLabel = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
let body: String
switch value {
case 1:
body = "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips."
body = String(localized: "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.", comment: "Alert radius guidance")
case 2:
body = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town."
body = String(localized: "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.", comment: "Alert radius guidance")
case 3:
body = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns."
body = String(localized: "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.", comment: "Alert radius guidance")
case 5:
body = "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."
body = String(localized: "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.", comment: "Alert radius guidance")
default:
body = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires."
body = String(localized: "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.", comment: "Alert radius guidance")
}
return "\(optionLabel) — \(body)"
return String(format: String(localized: "%@ — %@", comment: "Alert radius guidance with option label"), optionLabel, body)
}
}
@@ -134,74 +137,99 @@ struct AlertsView: View {
NavigationStack {
List {
Section {
Toggle("Cheapest favourite", isOn: $favouriteDropEnabled)
Picker("Fuel", selection: $favouriteDropFuel) {
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
Button("Check now", action: onCheckFavouriteDropNow)
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
.disabled(!favouriteDropEnabled)
} header: {
Text("Price-drop alerts")
Text(String(localized: "Price-drop alerts", comment: "Favourite price-drop alerts section title"))
} footer: {
Text("Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.")
Text(String(localized: "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.", comment: "Favourite price-drop alerts section footer"))
}
Section {
Toggle("Cheapest station", isOn: $enabled)
Picker("Fuel", selection: $fuel) {
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in
Text(mode.displayName).tag(mode)
}
}
if !bestValueConfigured {
Text(String(localized: "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.", comment: "Best Value alert unavailable hint"))
.font(.caption)
.foregroundStyle(.secondary)
}
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
Picker("Radius", selection: alertChoice) {
Text("Follow search (\(searchInUnitLabel))").tag(AlertRadiusChoice.followSearch)
Picker(String(localized: "Radius", comment: "Alert radius picker title"), selection: alertChoice) {
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(AlertRadiusChoice.followSearch)
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
Text("\(value) \(distanceUnit.label(for: Double(value)))").tag(AlertRadiusChoice.fixed(value))
Text(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value))
}
}
Text(alertRadiusGuidance)
.font(.caption)
.foregroundStyle(.secondary)
if alertMode == .bestValue {
Picker(String(localized: "Minimum saving", comment: "Best Value alert threshold picker title"), selection: $bestValueThresholdGBP) {
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
Text(FuelSavingsFormat.money(value)).tag(value)
}
}
if !bestValueConfigured {
Text(String(localized: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.", comment: "Best Value alert setup hint"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if enabled && monitoredCount == 0 {
Text("No stations monitored yet — open the Stations tab to load prices first.")
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Proximity alerts")
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
} footer: {
Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling \(fuel.displayName.lowercased()) are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.")
let modeDescription = alertMode == .bestValue
? String(format: String(localized: "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.", comment: "Best Value proximity alerts section footer"), FuelSavingsFormat.money(bestValueThresholdGBP), fuel.displayName.lowercased())
: String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased())
Text(modeDescription)
}
if enabled, let lastAlert {
Section("Last alert") {
Section(String(localized: "Last alert", comment: "Last alert section title")) {
Label(lastAlert, systemImage: "bell.badge.fill")
.font(.footnote)
}
}
Section {
Toggle("Cheapest station", isOn: $liveActivityEnabled)
Picker("Fuel", selection: $liveActivityFuel) {
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
Picker("Distance", selection: laChoice) {
Text("Follow search (\(searchInUnitLabel))").tag(LADistanceChoice.followSearch)
Picker(String(localized: "Distance", comment: "Distance picker title"), selection: laChoice) {
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(LADistanceChoice.followSearch)
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles))
}
}
} header: {
Text("Live Activity")
Text(String(localized: "Live Activity", comment: "Live Activity section title"))
} footer: {
Text("Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.")
Text(String(localized: "Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.", comment: "Live Activity section footer"))
}
}
.navigationTitle("Alerts")
.navigationTitle(String(localized: "Alerts", comment: "Alerts tab title"))
}
}
}
@@ -1,38 +1,170 @@
{
"images" : [
"images": [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
"filename": "AppIcon.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances" : [
"appearances": [
{
"appearance" : "luminosity",
"value" : "dark"
"appearance": "luminosity",
"value": "dark"
}
],
"filename" : "AppIcon-dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
"filename": "AppIcon-dark.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances" : [
"appearances": [
{
"appearance" : "luminosity",
"value" : "tinted"
"appearance": "luminosity",
"value": "tinted"
}
],
"filename" : "AppIcon-tinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
"filename": "AppIcon-tinted.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"filename": "Watch-NotificationCenter-48.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "24x24",
"subtype": "38mm"
},
{
"filename": "Watch-NotificationCenter-55.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "27.5x27.5",
"subtype": "42mm"
},
{
"filename": "Watch-NotificationCenter-66.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "33x33",
"subtype": "45mm"
},
{
"filename": "Watch-CompanionSettings-58.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "2x",
"size": "29x29"
},
{
"filename": "Watch-CompanionSettings-87.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "3x",
"size": "29x29"
},
{
"filename": "Watch-AppLauncher-80.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "40x40",
"subtype": "38mm"
},
{
"filename": "Watch-AppLauncher-88.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "44x44",
"subtype": "40mm"
},
{
"filename": "Watch-AppLauncher-92.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "46x46",
"subtype": "41mm"
},
{
"filename": "Watch-AppLauncher-100.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "50x50",
"subtype": "44mm"
},
{
"filename": "Watch-AppLauncher-102.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "51x51",
"subtype": "45mm"
},
{
"filename": "Watch-AppLauncher-108.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "54x54",
"subtype": "49mm"
},
{
"filename": "Watch-QuickLook-172.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "86x86",
"subtype": "38mm"
},
{
"filename": "Watch-QuickLook-196.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "98x98",
"subtype": "42mm"
},
{
"filename": "Watch-QuickLook-216.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "108x108",
"subtype": "44mm"
},
{
"filename": "Watch-QuickLook-234.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "117x117",
"subtype": "45mm"
},
{
"filename": "Watch-QuickLook-258.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "129x129",
"subtype": "49mm"
},
{
"filename": "Watch-Marketing-1024.png",
"idiom": "watch-marketing",
"scale": "1x",
"size": "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -4,6 +4,14 @@
"filename" : "AppIconArtwork.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
@@ -4,6 +4,14 @@
"filename" : "AppIconPreview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
@@ -4,6 +4,14 @@
"filename" : "AppIconPreviewDark.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
File diff suppressed because one or more lines are too long
@@ -4,6 +4,14 @@
"filename" : "PreviewWallpaper.jpg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_applegreen_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_applegreen_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_applegreen_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_applegreen_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
}
+15 -11
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_asda_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_asda_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_asda_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_asda_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
}
+15 -11
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_bp_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_bp_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_bp_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_bp_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_circle_k_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_circle_k_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_circle_k_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_circle_k_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_essar_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_essar_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_essar_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_essar_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+15 -11
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_esso_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_esso_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_esso_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_esso_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_gleaner_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_gleaner_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_gleaner_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_gleaner_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+15 -11
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_gulf_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_gulf_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_gulf_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_gulf_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_highland_fuels_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_highland_fuels_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_highland_fuels_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_highland_fuels_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+15 -11
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_jet_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_jet_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_jet_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_jet_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_maxol_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_maxol_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_maxol_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_maxol_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_morrisons_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_morrisons_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_morrisons_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_morrisons_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_murco_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_murco_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_murco_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_murco_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_sainsburys_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_sainsburys_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_sainsburys_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_sainsburys_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
}
+15 -11
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_shell_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_shell_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_shell_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_shell_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_spar_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_spar_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_spar_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_spar_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+15 -11
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_tesco_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_tesco_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_tesco_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_tesco_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
}
+15 -11
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_texaco_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_texaco_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_texaco_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_texaco_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_the_co_operative_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_the_co_operative_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_the_co_operative_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_the_co_operative_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_valero_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_valero_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_valero_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_valero_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_welcome_break_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_welcome_break_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_welcome_break_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_welcome_break_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+313
View File
@@ -0,0 +1,313 @@
import SwiftUI
/// The Best Value page body: what the feature does, whether to switch it on, and
/// — only when it is on — the two figures the ranking needs, entered with the
/// same sliders Settings uses (one control, no text entry, so a first-run user
/// can't stall on a keyboard).
///
/// Shared deliberately. First-run onboarding shows it as one of its pages, and
/// existing users who updated into the feature get the same page as a
/// once-per-announcement-version announcement (`BestValueIntroView`). Two copies
/// would drift the first time either was edited, so the copy, the controls and
/// the "declining is a decision" rule all live here.
///
/// Settings are written to the store as the controls move — the same
/// immediate-persist rule as Settings — so a host dismissed mid-page cannot lose
/// a choice the user has already made.
struct BestValueIntroContent: View {
@Binding var settings: FuelSavingsSettings
/// What the host's primary button says. ONE label for both answers: the
/// toggle already shows whether Best Value is being switched on, so a
/// separate "Not now" for the declined case was second-guessing a choice the
/// user could see on screen.
static var primaryTitle: String {
String(localized: "Continue", comment: "Best Value continue button")
}
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 8)
AnimatedOnboardingIcon(tint: .green) {
Image(systemName: "sterlingsign.circle.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.green)
}
.padding(.bottom, 20)
Text(String(localized: "Best Value", comment: "Best Value title"))
.font(.largeTitle.bold())
Text(String(localized: "The cheapest pump isn't always the cheapest fill-up — driving further burns fuel. Best Value sorts stations by what you'd really save once the trip is counted.", comment: "Best Value explanation"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 28)
.padding(.top, 10)
card
.padding(.horizontal, 24)
.padding(.top, 16)
Spacer(minLength: 8)
}
}
private var card: some View {
VStack(spacing: 14) {
Toggle(isOn: enabledBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "Turn on Best Value", comment: "Best Value toggle"))
.font(.headline)
Text(String(localized: "Sort by what a station saves you, travel included.", comment: "Best Value toggle detail"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if settings.enabled {
slider(
title: String(localized: "Average fuel economy", comment: "Best Value MPG slider label"),
readOut: mpgText,
value: mpgBinding,
range: FuelSavingsCalculator.mpgSliderRange,
step: FuelSavingsCalculator.mpgSliderStep
)
Picker("Fill-up unit", selection: unitBinding) {
ForEach(FuelSavingsFillUnit.allCases) { unit in
Text(unit.displayName).tag(unit)
}
}
.pickerStyle(.segmented)
slider(
title: String(localized: "Typical fill-up", comment: "Best Value fill-up slider label"),
readOut: fillUpText,
value: amountBinding,
range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit),
step: FuelSavingsCalculator.fillAmountSliderStep(for: settings.preferredAmount.unit)
)
Text(String(localized: "Best Value uses these, and can be changed any time in Settings.", comment: "Best Value configured note"))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
} else {
Text(String(localized: "That's fine — you can turn Best Value on whenever you like in Settings, under Fuel Savings.", comment: "Best Value declined note"))
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
}
/// The sliders are the only controls for these two figures, exactly as in
/// Settings: a labelled read-out above a slider, no text field.
private func slider(title: String,
readOut: String,
value: Binding<Double>,
range: ClosedRange<Double>,
step: Double) -> some View {
VStack(spacing: 2) {
HStack {
Text(title)
.font(.subheadline)
Spacer()
Text(readOut)
.font(.subheadline)
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: value, in: range, step: step)
.accessibilityLabel(title)
.accessibilityValue(readOut)
}
}
// MARK: Bindings (persist immediately, exactly like Settings)
private var enabledBinding: Binding<Bool> {
Binding(
get: { settings.enabled },
set: { settings.enabled = $0; persist() }
)
}
private var mpgBinding: Binding<Double> {
Binding(
get: { settings.mpg },
set: {
settings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG)
persist()
}
)
}
private var amountBinding: Binding<Double> {
Binding(
get: { settings.preferredAmount.value },
set: {
settings.preferredAmount.value = min(
max($0, 0),
FuelSavingsCalculator.maximumFillAmount(for: settings.preferredAmount.unit)
)
persist()
}
)
}
private var unitBinding: Binding<FuelSavingsFillUnit> {
Binding(
get: { settings.preferredAmount.unit },
set: { newUnit in
settings.preferredAmount.unit = newUnit
// Same guard as Settings: a figure valid in one unit may be
// valid in neither after the switch, so fall back to a sensible
// default for the new unit rather than storing junk.
if !settings.preferredAmount.isValid {
settings.preferredAmount.value = newUnit == .pounds ? 40 : 30
}
persist()
}
)
}
private func persist() {
FuelStore.saveFuelSavingsSettings(settings)
}
/// "45 MPG" / "45.5 MPG" — the slider is the only control, so this read-out
/// is the one place the figure is stated.
private var mpgText: String {
let mpg = settings.mpg
return mpg.rounded() == mpg ? String(format: "%.0f MPG", mpg) : String(format: "%.1f MPG", mpg)
}
/// "£40" / "40 L" — the fill-up slider's read-out.
private var fillUpText: String {
switch settings.preferredAmount.unit {
case .pounds: return String(format: "£%.0f", settings.preferredAmount.value)
case .litres: return String(format: "%.0f L", settings.preferredAmount.value)
}
}
}
/// The announcement existing users see once per announcement version: the Best
/// Value page on its own. Deliberately NOT the onboarding pager — no page dots
/// and no permission pages, because those users answered all of that long ago
/// and the full flow is mandatory with no skip.
struct BestValueIntroView: View {
var onFinish: () -> Void
@State private var settings = FuelStore.loadFuelSavingsSettings()
var body: some View {
VStack(spacing: 0) {
BestValueIntroContent(settings: $settings)
OnboardingPrimaryButton(title: BestValueIntroContent.primaryTitle,
action: finish)
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
.background(
LinearGradient(
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
startPoint: .top, endPoint: .bottom
)
.ignoresSafeArea()
)
}
private func finish() {
// Marked on the way OUT, so a crash or force-quit mid-page re-shows the
// announcement instead of silently swallowing it.
FuelStore.markBestValueIntroSeen()
onFinish()
}
}
/// Presents the Best Value announcement over the app, exactly once per
/// announcement version. It wraps whatever it is given, so the whole feature —
/// state, gate and dismissal — lives in this file and ContentView only has to
/// install it.
///
/// The gate, in order:
/// - suppressed under `-skipOnboarding` (DEBUG), so screenshot runs can open a
/// tab without a cover interrupting the capture;
/// - forced by `-showBestValueIntro` (DEBUG);
/// - never while first-run onboarding is due or on screen — that flow contains
/// the same page, and `OnboardingView.finish` marks this seen;
/// - never when the stored announcement version is current;
/// - never for someone who has already configured Best Value themselves.
struct BestValueAnnouncementHost<Content: View>: View {
/// True while the first-run onboarding cover is up: the announcement must
/// not fight it for the screen.
var isSuppressed: Bool = false
var onDismiss: () -> Void = {}
@ViewBuilder var content: Content
@State private var presenting = false
var body: some View {
content
.fullScreenCover(isPresented: $presenting) {
BestValueIntroView {
presenting = false
}
}
// `task`, not `onAppear`: runs once when the app's content appears,
// after ContentView has made its onboarding decision.
.task {
guard !presenting, shouldAnnounce else { return }
presenting = true
}
.onChange(of: presenting) { _, showing in
guard !showing else { return }
// The page writes straight to the store — it is its own cover
// with its own copy of the settings — so the app's in-memory
// copy has to be pulled back in, or an announcement choice
// wouldn't apply until the next launch.
onDismiss()
}
}
private var shouldAnnounce: Bool {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
if args.contains("-skipOnboarding") { return false }
if args.contains("-showBestValueIntro") { return true }
#endif
// The rule itself lives in the store so it can be unit-tested — see
// `FuelStore.shouldAnnounceBestValue`.
return FuelStore.shouldAnnounceBestValue(
settings: FuelStore.loadFuelSavingsSettings(),
isSuppressed: isSuppressed
)
}
}
/// The prominent button both Best Value hosts use, so they can't drift apart.
struct OnboardingPrimaryButton: View {
var title: String
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
}
}
File diff suppressed because it is too large Load Diff
+72 -10
View File
@@ -14,6 +14,19 @@ struct FavouritesView: View {
let location: Coordinate?
let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let stationTapAction: StationTapAction
/// Fuel Savings config. When it is configured this tab shows the SAME
/// fill-up control and per-row estimate as the Stations list: one session
/// amount is shared, so adjusting it in either list updates both, and it
/// still never touches the Settings default.
let fuelSavingsSettings: FuelSavingsSettings
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
/// Estimated saving for a favourite, against the closest eligible station by
/// journey distance — the same baseline and definition the Stations list uses.
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ 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 }
/// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
@@ -37,6 +50,9 @@ struct FavouritesView: View {
/// Trends sheet (price history chart) presentation state.
@State private var showTrends = false
/// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation?
private var activeFuel: FuelType {
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
}
@@ -65,6 +81,13 @@ struct FavouritesView: View {
location: Coordinate?,
distanceUnit: DistanceUnit,
priceDisplayStyle: PriceDisplayStyle,
stationTapAction: StationTapAction,
fuelSavingsSettings: FuelSavingsSettings = .defaults,
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ 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 },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
onHistoryUnavailable: (() -> Void)? = nil,
@@ -74,6 +97,13 @@ struct FavouritesView: View {
self.location = location
self.distanceUnit = distanceUnit
self.priceDisplayStyle = priceDisplayStyle
self.stationTapAction = stationTapAction
self.fuelSavingsSettings = fuelSavingsSettings
_fuelSavingsAmount = fuelSavingsAmount
self.savingsForStation = savingsForStation
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
self.savingsPriceConfidenceForStation = savingsPriceConfidenceForStation
self.savingsPriceWarningForStation = savingsPriceWarningForStation
self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder
self.onHistoryUnavailable = onHistoryUnavailable
@@ -100,9 +130,9 @@ struct FavouritesView: View {
Image(systemName: "star")
.font(.system(size: 40))
.foregroundStyle(.secondary)
Text("No favourites yet")
Text(String(localized: "No favourites yet", comment: "Favourites empty-state title"))
.font(.headline)
Text("Tap the star on any station in the Stations tab to pin it here. The first station in each list becomes the favourite used in single widgets — drag to put your pick first.")
Text(String(localized: "Tap the star on any station in the Stations tab to pin it here. The first station in each list becomes the favourite used in single widgets — drag to put your pick first.", comment: "Favourites empty-state explanation"))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -111,13 +141,21 @@ struct FavouritesView: View {
.padding(.vertical, 24)
}
} else {
Section("Fuel type") {
Section(String(localized: "Fuel type", comment: "Fuel type section title")) {
FuelTypeSegmentedPicker(
selection: $fuel,
fuels: availableFuels
)
}
if fuelSavingsSettings.isConfigured {
Section {
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
} footer: {
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"))
}
}
Section {
ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
StationRow(
@@ -127,28 +165,35 @@ struct FavouritesView: View {
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: cheapestPrice,
isTopResult: index == 0,
isTopResult: station.id == cheapest?.id,
isFavourite: activeFuelFavouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
topBadgeText: "CHEAPEST",
tapAction: stationTapAction,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
onShowDetails: { detailStation = $0 }
)
}
.onMove(perform: moveFavourite)
} header: {
Text("\(activeFuel.displayName) favourites")
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
} footer: {
Text("The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.")
Text(String(localized: "The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.", comment: "Favourites reorder explanation"))
}
if let cheapest = cheapest {
Section {
Text("\(cheapest.name) is your cheapest \(activeFuel.displayName) favourite. Favourites are monitored automatically — switch to the Alerts tab and enable alerts to get pinged when you approach one that's the cheapest within the radius.")
Text(String(format: String(localized: "%@ is your cheapest %@ favourite. Favourites are monitored automatically — switch to the Alerts tab and enable alerts to get pinged when you approach one that's the cheapest within the radius.", comment: "Cheapest favourite explanation"), cheapest.name, activeFuel.displayName))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Favourites")
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
.toolbar {
if !favourites.isEmpty {
ToolbarItemGroup(placement: .topBarTrailing) {
@@ -156,7 +201,7 @@ struct FavouritesView: View {
showTrends = true
} label: {
Image(systemName: "chart.xyaxis.line")
.accessibilityLabel("Trends")
.accessibilityLabel(String(localized: "Trends", comment: "Trends button accessibility label"))
}
EditButton()
}
@@ -171,6 +216,23 @@ struct FavouritesView: View {
onHistoryRecovered: onHistoryRecovered
)
}
.sheet(item: $detailStation) { station in
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit,
selectedFuel: activeFuel,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
journeyDiagnostics: journeyDiagnosticsForStation(station),
// Every station listed here IS a favourite for the tab's
// fuel, so the star starts filled; tapping it unstars.
isFavourite: true,
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
)
}
.onAppear {
#if DEBUG
// QA hook: launch with `-showTrends` to open the sheet
+56 -4
View File
@@ -6,7 +6,6 @@ import SwiftUI
struct FuelBoardApp: App {
init() {
WatchSyncManager.shared.activate()
SmartDataRefreshScheduler.register()
#if DEBUG
// QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a
// Live Activity with a long station name so the Lock Screen / island
@@ -22,7 +21,7 @@ struct FuelBoardApp: App {
var body: some Scene {
WindowGroup {
ContentView()
rootView
.onOpenURL(perform: handleOpenURL)
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
WatchSyncManager.shared.pushSnapshot()
@@ -33,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
private func startQALiveActivity(fuel: FuelType) {
let state = FuelBoardLiveActivityAttributes.ContentState(
@@ -43,6 +60,10 @@ struct FuelBoardApp: App {
pricePence: 1499,
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
distanceKM: 8.0,
// QA mock: marked approximate so the hook renders the LONGEST
// distance form ("Approx. 8.0 mi") and the island / Lock Screen
// layout can be checked against it.
distanceIsApproximate: true,
lat: 51.5,
lng: -0.12,
updatedAt: Date()
@@ -70,9 +91,40 @@ struct FuelBoardApp: App {
/// `http://maps.apple.com` form is also handled, from very old timelines.
/// In CarPlay the widget uses the same `maps://` URL, which routes to
/// Apple Maps without ever launching this app.
/// A `fuelboard://station?stationID=…&lat=…&lng=…&name=…` link (from a
/// widget or Live Activity tap when Settings → Tap station = More info)
/// posts a notification that ContentView observes to present the detail
/// sheet (falling back to the map sheet when the station has left
/// the cache).
private func handleOpenURL(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let items = components.queryItems
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return }
if components.scheme == "fuelboard",
components.host == "station" {
let items = components.queryItems ?? []
var userInfo: [String: Any] = [:]
if let id = items.first(where: { $0.name == "stationID" })?.value {
userInfo["stationID"] = id
}
if let lat = items.first(where: { $0.name == "lat" })?.value.flatMap(Double.init) {
userInfo["stationLat"] = lat
}
if let lng = items.first(where: { $0.name == "lng" })?.value.flatMap(Double.init) {
userInfo["stationLng"] = lng
}
if let name = items.first(where: { $0.name == "name" })?.value {
userInfo["stationName"] = name
}
NotificationCenter.default.post(
name: .fuelBoardShowStationDetail,
object: nil,
userInfo: userInfo
)
return
}
guard let items = components.queryItems
else { return }
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
+321
View File
@@ -0,0 +1,321 @@
import SwiftUI
struct FuelSavingsAdjustmentView: View {
@Binding var amount: FuelSavingsFillAmount
@State private var unitChangeNotice: String?
private var range: ClosedRange<Double> {
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
}
private var step: Double {
FuelSavingsCalculator.fillAmountSliderStep(for: amount.unit)
}
/// Read-out of the figure the slider is on. Deliberately NOT a text field:
/// a typed figure could sit outside the slider's own bounds, leaving the
/// control and the value it claimed to show disagreeing. Slider-only means
/// what you see is the figure the estimate actually uses.
private var valueText: String {
switch amount.unit {
case .pounds: return String(format: "£%.0f", amount.value)
case .litres: return String(format: "%.0f L", amount.value)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("How much are you filling up?")
.font(.subheadline.weight(.semibold))
Picker("Fill-up unit", selection: unitBinding) {
ForEach(FuelSavingsFillUnit.allCases) { unit in
Text(unit.displayName).tag(unit)
}
}
.pickerStyle(.segmented)
HStack {
Slider(value: sliderBinding, in: range, step: step)
.accessibilityLabel("Fill-up amount")
.accessibilityValue(valueText)
Text(valueText)
.monospacedDigit()
.frame(width: 72, alignment: .trailing)
}
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
.font(.caption2)
.foregroundStyle(.secondary)
Text("£ compares the litres your budget buys at each station; Litres compares one fixed quantity.")
.font(.caption2)
.foregroundStyle(.secondary)
if let unitChangeNotice {
Text(unitChangeNotice)
.font(.caption2)
.foregroundStyle(.orange)
}
}
}
private var unitBinding: Binding<FuelSavingsFillUnit> {
Binding(
get: { amount.unit },
set: { newUnit in
guard newUnit != amount.unit else { return }
// No one litre figure equals a budget at every candidate price.
// A unit change starts a new, explicitly labelled comparison.
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
unitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
}
)
}
private var sliderBinding: Binding<Double> {
Binding(
get: { min(max(amount.value, range.lowerBound), range.upperBound) },
set: { amount.value = min(max($0, range.lowerBound), range.upperBound) }
)
}
}
struct FuelSavingsSummaryView: View {
let availability: FuelSavingsAvailability?
let amount: FuelSavingsFillAmount
/// 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
/// Sizes the "extra return travel" caption in the user's own unit (the
/// stored figure is miles) — used by both the row banner and the detail sheet.
var distanceUnit: DistanceUnit = .miles
var showInfoAction: (() -> Void)? = nil
private func comparisonLabel(for result: FuelSavingsResult) -> String {
FuelSavingsFormat.comparisonLabel(status: result.status)
}
var body: some View {
switch availability {
case .available(let result):
if compact {
// FULL-WIDTH banner under the row. Inline inside the middle
// column the capsule truncated ("Approx. £2.75 better off after
// tra…") because
// the column is squeezed by the price block — the verdict is the
// point of this sort mode, so it gets its own line with room.
// Colour carries the verdict: green save, red worse off, grey
// break even, blue baseline. lineLimit + minimumScaleFactor keep
// it to one line, so rows stay even.
VStack(alignment: .leading, spacing: 2) {
// The capsule gets the WHOLE line and the comparison caption
// sits beneath it. Side by side, the caption's layoutPriority
// squeezed the verdict until it truncated ("Approx. £2.75
// better off after tra…"), and in this sort mode the verdict
// is the point of the row.
capsule(for: result, compact: true)
.accessibilityLabel(accessibilityHeadline(for: result))
Text(comparisonLabel(for: result))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
if let warning = priceWarning ?? priceConfidence.warningText {
Text(warning)
.font(.caption2)
.foregroundStyle(.orange)
.accessibilityLabel(warning)
}
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) {
Text(extraTravel)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
}
}
// Aligns the banner with the row's text column (46 pt logo + 12
// pt spacing) so it reads as part of the row, not the cell.
.padding(.leading, 58)
} else {
// Detail sheet: the SAME verdict capsule and the SAME caption as
// the listing rows (one implementation, so wording and colour
// cannot drift), followed by the breakdown only this view has
// room for. The old shape led with a status word + headline that
// matched neither the listing nor each other.
VStack(alignment: .leading, spacing: 10) {
// Same shape as the listing rows: the capsule takes the line
// and the comparison caption sits directly beneath it, so the
// verdict never competes for width with its own caption.
VStack(alignment: .leading, spacing: 4) {
capsule(for: result, compact: false)
.accessibilityLabel(accessibilityHeadline(for: result))
Text(comparisonLabel(for: result))
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
}
LabeledContent("Your fill-up", value: amountText)
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
.monospacedDigit()
if amount.unit == .pounds {
Text("You pay the selected budget here. The saving compares the same litres at your closest 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(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) {
Text(extraTravel)
.font(.subheadline)
.foregroundStyle(.secondary)
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
}
if result.status == .baseline {
Text("Your closest eligible station by journey distance is the baseline — every other saving is measured against it.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(baselineStationName.map { "Compared with \($0)." } ?? "Compared with your closest eligible station.")
.font(.caption)
.foregroundStyle(.secondary)
LabeledContent(
result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference",
value: result.grossSavingGBP >= 0
? FuelSavingsFormat.money(result.grossSavingGBP)
: "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more"
)
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
Divider()
LabeledContent {
Text(result.headline())
.fontWeight(.semibold)
.foregroundStyle(capsuleColor(for: result.status))
.monospacedDigit()
} label: {
Text("Total")
.fontWeight(.semibold)
}
if result.journeyConfidence.isEstimated {
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
.font(.caption2)
.foregroundStyle(.secondary)
}
Text("Estimate covers fuel cost only. It excludes time, parking, tolls and wear.")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let showInfoAction {
Rectangle()
.fill(Color(.separator))
.frame(height: 0.5)
.padding(.vertical, 4)
Button(action: showInfoAction) {
HStack(spacing: 8) {
Text("How Best Value works")
Spacer(minLength: 8)
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
}
}
}
case .unavailable(let reason):
Text(compact ? "Savings unavailable" : "Best Value unavailable: \(reason)")
.font(compact ? .caption2 : .caption)
.foregroundStyle(.secondary)
case nil:
EmptyView()
}
}
private var amountText: String {
switch amount.unit {
case .pounds: return String(format: "£%.0f", amount.value)
case .litres: return String(format: "%.0f litres", amount.value)
}
}
/// VoiceOver form of the extra-travel caption ("Approximately 6.4 miles extra
/// return travel"): the abbreviation is spelled out and the unit is a word.
/// Falls back to the visible string if no spoken form exists.
private func spokenExtraTravel(for result: FuelSavingsResult, fallback: String) -> String {
FuelSavingsFormat.extraTravelSpokenLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) ?? fallback
}
/// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for
/// and leaves the plain words ("Break even", "Closest eligible
/// station") alone — one source, so visible and spoken copy cannot drift.
private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
result.spokenHeadline()
}
/// The colour-coded verdict capsule — ONE implementation for the listing rows
/// and the detail sheet, so the verdict reads identically in both; the sheet
/// just gets the larger font and padding.
///
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
/// a Label lays its icon and title out across the row and leaves a visible
/// gap between them (22pt in the listing row), which read as the text having
/// drifted away from its symbol. The icon is decorative here — the verdict
/// is in the text — so VoiceOver reads the capsule as one phrase.
private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View {
HStack(spacing: compact ? 3 : 5) {
Image(systemName: symbol(for: result.status))
.accessibilityHidden(true)
Text(result.capsuleHeadline())
.lineLimit(1)
// Belt and braces: now that the capsule owns its line it has no
// rival for width, and shrinking beats truncating at large
// Dynamic Type sizes.
.minimumScaleFactor(0.8)
}
.font(compact ? .caption.weight(.semibold) : .headline)
.foregroundStyle(capsuleColor(for: result.status))
.padding(.horizontal, compact ? 8 : 12)
.padding(.vertical, compact ? 4 : 7)
.background(Capsule().fill(capsuleColor(for: result.status).opacity(0.15)))
}
/// Verdict colour: green save, red worse off, grey break even, blue
/// baseline — the same green/amber/red family the price dot already uses.
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
switch status {
case .worthTheDrive: return .green
case .notWorthTheDrive: return .red
case .breakEven: return .secondary
case .baseline: return .blue
}
}
private func symbol(for status: FuelSavingsStatus) -> String {
switch status {
case .worthTheDrive: return "checkmark.circle.fill"
case .breakEven: return "equal.circle.fill"
case .notWorthTheDrive: return "xmark.circle.fill"
case .baseline: return "location.circle.fill"
}
}
}
+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
+79
View File
@@ -0,0 +1,79 @@
import SwiftUI
import UIKit
import Combine
extension View {
/// Puts a Done button above the keyboard.
///
/// The numeric fields (Settings → Average fuel economy and Typical
/// fill-up, and the Worth the Drive fill-up box in the station list) use
/// `.decimalPad`, which has NO Return key. Without this there is no way to
/// dismiss the keyboard once such a field is tapped, and a
/// `TextField(value:format:)` only commits the typed value when editing
/// ends — so the number never stuck either.
///
/// WHY IT IS DRAWN HERE RATHER THAN AS `.toolbar(placement: .keyboard)`:
/// the keyboard-toolbar form is device-dependent. The same build showed the
/// Done button on the iPhone 15 Pro and NOT on the iPhone SE (both iOS
/// 27.0.1, same install, same binary), leaving the SE with no way to
/// dismiss or commit a numeric field. Drawing the bar in the screen's own
/// bottom safe area renders identically on every device, so the accessibility
/// of the numeric fields no longer depends on the accessory view appearing.
///
/// Apply ONCE per screen, to the list or form containing the fields: any
/// keyboard on that screen shows the bar, so attaching it to each field
/// would stack several Done buttons.
///
/// - Parameter action: clears the screen's focus state. Prefer this where a
/// `@FocusState` exists; the default resigns the current first responder,
/// which works for fields inside subviews that have no focus binding (the
/// fill-up box in the station list is one).
func keyboardDoneButton(action: (() -> Void)? = nil) -> some View {
modifier(KeyboardDoneBar(action: action))
}
}
/// Shows a Done bar while a keyboard is up on this screen.
///
/// Visibility comes from the keyboard notifications rather than a `@FocusState`
/// so the bar also works for fields inside subviews that have no focus binding.
private struct KeyboardDoneBar: ViewModifier {
let action: (() -> Void)?
@State private var isKeyboardVisible = false
func body(content: Content) -> some View {
content
.safeAreaInset(edge: .bottom, spacing: 0) {
if isKeyboardVisible {
HStack {
Spacer()
Button("Done") {
if let action {
action()
} else {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
isKeyboardVisible = false
}
.font(.body.weight(.semibold))
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
.background(.bar)
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
isKeyboardVisible = true
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
isKeyboardVisible = false
}
}
}
+16 -4
View File
@@ -50,6 +50,11 @@ enum LiveActivityManager {
return
}
// ONE cache read for the figure AND its confidence: the activity shows
// "Approx." exactly when the app's own rows would.
let distance = FuelStore.stationDistanceDisplay(
station: best, userLat: location.lat, userLng: location.lng
)
let state = FuelBoardLiveActivityAttributes.ContentState(
fuel: fuel,
stationID: best.id,
@@ -57,7 +62,8 @@ enum LiveActivityManager {
brand: best.brand,
pricePence: price,
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
distanceKM: distance.km,
distanceIsApproximate: distance.isApproximate,
lat: best.lat,
lng: best.lng,
updatedAt: Date()
@@ -73,7 +79,7 @@ enum LiveActivityManager {
currentStartDate = nil
start(attributes: attributes, state: state)
} else {
Task { await current.update(using: state) }
Task { await current.update(ActivityContent(state: state, staleDate: nil)) }
}
} else {
start(attributes: attributes, state: state)
@@ -119,14 +125,20 @@ enum LiveActivityManager {
lat: Double,
lng: Double
) -> FuelStation? {
// Radius and tie-break both use the distance the pill SHOWS, so the
// activity can never name a station the app's own list has filtered out.
stations
.filter { $0.prices[fuel] != nil }
.filter { $0.distanceKM(to: lat, lng2: lng) <= radiusKM }
.filter {
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
userLat: lat, userLng: lng)
}
.min { lhs, rhs in
let lPrice = lhs.prices[fuel]!
let rPrice = rhs.prices[fuel]!
if lPrice != rPrice { return lPrice < rPrice }
return lhs.distanceKM(to: lat, lng2: lng) < rhs.distanceKM(to: lat, lng2: lng)
return FuelStore.displayDistanceKM(station: lhs, userLat: lat, userLng: lng)
< FuelStore.displayDistanceKM(station: rhs, userLat: lat, userLng: lng)
}
}
}
+128 -65
View File
@@ -3,19 +3,31 @@ import CoreLocation
import UserNotifications
import Network
/// First-launch onboarding: introduces FuelBoard, then walks the user through
/// the three system permissions (location, notifications, local network for
/// data loading) with in-context prompts. In production it appears once at
/// initial launch (driven by `FuelStore.loadHasCompletedOnboarding()`); a
/// test button in the Alerts tab re-opens it anytime.
/// First-launch onboarding: introduces FuelBoard, walks the user through the
/// two system permissions (location and notifications) with in-context prompts,
/// then offers Best Value — explained, switched on or declined, and configured
/// with the same sliders Settings uses. Price data downloads over HTTPS with no
/// Local Network permission. In production it appears once at initial launch
/// (driven by `FuelStore.loadHasCompletedOnboarding()`); the Settings tab has a
/// row that re-opens it anytime.
///
/// The Best Value page itself lives in `BestValueIntroContent`, because the same
/// page is also shown to EXISTING users (once per announcement version, by
/// `BestValueIntroView`) — one source for its copy, controls and rules, so the
/// two can never drift apart.
struct OnboardingView: View {
var onFinish: () -> Void
@Environment(\.dismiss) private var dismiss
@StateObject private var prompter = OnboardingPermissionPrompter()
@State private var page = 0
@State private var page = OnboardingView.initialPage()
/// Best Value's own copy of the stored settings. Loaded so a replay starts
/// from what the user already has; the shared page view saves on every
/// change — the same immediate-persist rule as Settings — so a swipe past
/// the page cannot lose a choice the user has already made.
@State private var fuelSavings = OnboardingView.initialFuelSavings()
private let totalPages = 5
private static let totalPages = 6
var body: some View {
VStack(spacing: 0) {
@@ -23,7 +35,7 @@ struct OnboardingView: View {
HStack {
Spacer()
HStack(spacing: 8) {
ForEach(0..<totalPages, id: \.self) { index in
ForEach(0..<Self.totalPages, id: \.self) { index in
Circle()
.fill(index == page ? Color.accentColor : Color.gray.opacity(0.25))
.frame(width: 8, height: 8)
@@ -39,7 +51,8 @@ struct OnboardingView: View {
locationPage.tag(1)
notificationsPage.tag(2)
dataPage.tag(3)
donePage.tag(4)
bestValuePage.tag(4)
donePage.tag(5)
}
.tabViewStyle(.page(indexDisplayMode: .never))
@@ -85,7 +98,7 @@ struct OnboardingView: View {
private var welcomePage: some View {
VStack(spacing: 0) {
Spacer()
AnimatedOnboardingIcon(tint: .accentColor) {
AnimatedOnboardingIcon(tint: .accentColor, showsHalo: false) {
// The app icon itself, with the standard iOS rounded-corner
// treatment (~22% of the size), so onboarding matches the
// Home Screen.
@@ -95,25 +108,26 @@ struct OnboardingView: View {
.frame(width: 96, height: 96)
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
}
.padding(.bottom, 28)
.padding(.bottom, 16)
Text("Welcome to FuelBoard")
Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title"))
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
Text("The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.")
Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.top, 12)
featureRow(icon: "globe.europe.africa.fill", text: "UK-wide prices — 8,000+ stations, updated twice a day")
featureRow(icon: "scope", text: "Cheapest within your chosen radius, or closest station first")
featureRow(icon: "star.fill", text: "Favourites with instant price comparison")
featureRow(icon: "chart.bar.fill", text: "Green/amber/red rating vs the best nearby price")
featureRow(icon: "square.grid.2x2.fill", text: "Home-screen widget showing the cheapest nearby")
featureRow(icon: "bell.fill", text: "Alerts when you approach the cheapest station")
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: "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: "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: "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()
}
@@ -129,10 +143,10 @@ struct OnboardingView: View {
}
.padding(.bottom, 28)
Text("Prices near you")
Text(String(localized: "Prices near you", comment: "Onboarding location permission title"))
.font(.largeTitle.bold())
Text("FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.")
Text(String(localized: "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.", comment: "Onboarding location permission explanation"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -142,7 +156,7 @@ struct OnboardingView: View {
permissionStatusLabel(
granted: prompter.locationGranted,
denied: prompter.locationDenied,
deniedText: "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it."
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)
@@ -160,10 +174,10 @@ struct OnboardingView: View {
}
.padding(.bottom, 28)
Text("Cheapest-station alerts")
Text(String(localized: "Cheapest-station alerts", comment: "Onboarding notifications title"))
.font(.largeTitle.bold())
Text("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.")
Text(String(localized: "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.", comment: "Onboarding notifications explanation"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -173,7 +187,7 @@ struct OnboardingView: View {
permissionStatusLabel(
granted: prompter.notificationsGranted,
denied: prompter.notificationsDenied,
deniedText: "Notifications were denied — you can still use FuelBoard, just without alert banners."
deniedText: String(localized: "Notifications were denied — you can still use FuelBoard, just without alert banners.", comment: "Onboarding notifications denied text")
)
.padding(.top, 24)
@@ -191,10 +205,10 @@ struct OnboardingView: View {
}
.padding(.bottom, 28)
Text("Prices, ready when you are")
Text(String(localized: "Prices, ready when you are", comment: "Onboarding data title"))
.font(.largeTitle.bold())
Text("FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.")
Text(String(localized: "FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.", comment: "Onboarding data explanation"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -205,6 +219,12 @@ struct OnboardingView: View {
}
}
/// The Best Value page — the shared body, so the first-run page and the
/// announcement existing users see can never drift apart.
private var bestValuePage: some View {
BestValueIntroContent(settings: $fuelSavings)
}
private var donePage: some View {
VStack(spacing: 0) {
Spacer()
@@ -218,10 +238,10 @@ struct OnboardingView: View {
}
.padding(.bottom, 28)
Text("You're all set")
Text(String(localized: "You're all set", comment: "Onboarding final title"))
.font(.largeTitle.bold())
Text("Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.")
Text(String(localized: "Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.", comment: "Onboarding final explanation"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -238,11 +258,9 @@ struct OnboardingView: View {
private var bottomAction: some View {
switch page {
case 0:
primaryButton("Continue") { page = 1 }
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) { page = 1 }
case 1:
primaryButton(
prompter.locationDenied ? "Open Settings" : (prompter.locationGranted ? "Continue" : "Allow Location Access")
) {
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) {
if prompter.locationDenied {
openSettings()
} else if prompter.locationGranted {
@@ -252,14 +270,12 @@ struct OnboardingView: View {
// screen (after it's been read) and BEFORE advancing — so
// the prompt never covers the next page's animation. Auto-
// advance on grant moves us on once the user responds;
// denying leaves the page's "Open Settings" path.
// denying leaves the page's Settings path.
prompter.requestLocation()
}
}
case 2:
primaryButton(
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
) {
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) {
if prompter.notificationsDenied || prompter.notificationsGranted {
page = 3 // decided either way — move on
} else {
@@ -269,40 +285,43 @@ struct OnboardingView: View {
}
case 3:
// No permission on this page — prices need none. Straight on.
primaryButton("Continue") { page = 4 }
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) { page = 4 }
case 4:
// Best Value: the settings were saved when the toggle moved, so a
// swipe past this page keeps the choice.
primaryButton(BestValueIntroContent.primaryTitle) { page = 5 }
default:
primaryButton("Start Using FuelBoard") { finish() }
primaryButton(String(localized: "Start Using FuelBoard", comment: "Finish onboarding button")) { finish() }
}
}
private func primaryButton(_ title: LocalizedStringKey, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View {
OnboardingPrimaryButton(title: title, action: action)
}
private func featureRow(icon: String, text: LocalizedStringKey) -> some View {
HStack(spacing: 12) {
private func featureRow(icon: String, title: String, subtitle: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 32)
Text(text)
.font(.subheadline)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.bold())
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 36)
.padding(.vertical, 10)
.padding(.vertical, 8)
}
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: LocalizedStringKey) -> some View {
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
Group {
if granted {
Label("Allowed", systemImage: "checkmark.circle.fill")
Label(String(localized: "Allowed", comment: "Permission granted status"), systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else if denied {
Text(deniedText)
@@ -311,7 +330,7 @@ struct OnboardingView: View {
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
} else {
Text("The system prompt will appear next.")
Text(String(localized: "The system prompt will appear next.", comment: "Permission prompt pending status"))
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -326,12 +345,51 @@ struct OnboardingView: View {
}
private func finish() {
// Anyone who goes through the whole of onboarding has now seen the Best
// Value page — mark the announcement seen so they don't immediately get
// the standalone one aimed at existing users.
FuelStore.markBestValueIntroSeen()
FuelStore.saveHasCompletedOnboarding(true)
onFinish()
}
// MARK: - QA hooks
/// DEBUG-only launch args in the same spirit as ContentView's `-tab` and
/// `-skipOnboarding`, both no-ops in release:
///
/// - `-onboardingPage 4` opens straight on a page, so the screenshot harness
/// can photograph a page it cannot swipe or tap its way to.
/// - `-onboardingBestValue on|off` forces the Best Value page's choice, so
/// both of its states can be captured without driving the toggle.
private static func initialPage() -> Int {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
if let index = args.firstIndex(of: "-onboardingPage"),
index + 1 < args.count,
let value = Int(args[index + 1]),
(0..<totalPages).contains(value) {
return value
}
#endif
return 0
}
private static func initialFuelSavings() -> FuelSavingsSettings {
var settings = FuelStore.loadFuelSavingsSettings()
#if DEBUG
let args = ProcessInfo.processInfo.arguments
if let index = args.firstIndex(of: "-onboardingBestValue"), index + 1 < args.count {
settings.enabled = args[index + 1].lowercased() == "on"
}
#endif
return settings
}
}
private struct AnimatedOnboardingIcon<Content: View>: View {
/// The animated badge behind each page's icon. Internal rather than private
/// because `BestValueIntroContent` lives in its own file and uses it too.
struct AnimatedOnboardingIcon<Content: View>: View {
enum Motion {
case gentle
case locationArrow
@@ -341,6 +399,9 @@ private struct AnimatedOnboardingIcon<Content: View>: View {
let tint: Color
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
@State private var settled = false
@@ -350,16 +411,18 @@ private struct AnimatedOnboardingIcon<Content: View>: View {
var body: some View {
ZStack {
Circle()
.fill(tint.opacity(0.10))
.frame(width: 108, height: 108)
.scaleEffect(settled ? 1.0 : 0.94)
if showsHalo {
Circle()
.fill(tint.opacity(0.10))
.frame(width: 108, height: 108)
.scaleEffect(settled ? 1.0 : 0.94)
Circle()
.stroke(tint.opacity(0.18), lineWidth: 1)
.frame(width: 118, height: 118)
.scaleEffect(settled ? 1.03 : 0.97)
.opacity(settled ? 1.0 : 0.55)
Circle()
.stroke(tint.opacity(0.18), lineWidth: 1)
.frame(width: 118, height: 118)
.scaleEffect(settled ? 1.03 : 0.97)
.opacity(settled ? 1.0 : 0.55)
}
content
.scaleEffect(iconScale)
+261 -35
View File
@@ -84,6 +84,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
@Published var lastAlert: String?
@Published private(set) var lastTestResult: String?
@Published var pendingStationMap: StationMapRequest?
/// Station tapped via notification when Settings → Tap station = More info.
/// ContentView presents this as a StationDetailView sheet.
@Published var pendingDetailStation: FuelStation?
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
/// every location/stations change via `refreshDebugStatus()`.
@Published private(set) var debugStatus: DebugLocationStatus?
@@ -108,6 +111,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
private var favourites: [FuelStation] = []
private var fuel: FuelType = .e10
private var radiusKM: Double = 3.0
private var alertMode: AlertMode = .cheapest
private var bestValueThresholdGBP: Double = 1.0
private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
private var fuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount
private var lastNotified: [String: Date] = [:] // dedup per station
private var enabled = false
/// Dynamically registered categories for tie-choice notifications. Kept so
@@ -132,6 +139,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
radiusKM = FuelStore.effectiveAlertsRadiusKM(
followsSearch: FuelStore.loadAlertsFollowsSearch(),
manualKM: FuelStore.loadAlertsRadius())
alertMode = FuelStore.loadAlertsMode()
bestValueThresholdGBP = FuelStore.loadAlertsBestValueThresholdGBP()
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
fuelSavingsAmount = fuelSavingsSettings.preferredAmount
stations = FuelStore.loadStations()
favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
}
@@ -140,7 +151,16 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// Favourites are fuel-scoped entries; only entries for the monitored fuel
/// get priority slots (a Diesel favourite must not grab a slot while
/// monitoring Unleaded).
func update(stations: [FuelStation], favourites: [FavouriteEntry], fuel: FuelType, radiusKM: Double) {
func update(
stations: [FuelStation],
favourites: [FavouriteEntry],
fuel: FuelType,
radiusKM: Double,
alertMode: AlertMode = .cheapest,
bestValueThresholdGBP: Double = 1.0,
fuelSavingsSettings: FuelSavingsSettings = .defaults,
fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
) {
// Fall back to the shared cache when called before the first fetch
// completes (launch, background region-event wake).
self.stations = stations.isEmpty ? FuelStore.loadStations() : stations
@@ -149,6 +169,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
: favourites.filter { $0.fuel == fuel }.map(\.station)
self.fuel = fuel
self.radiusKM = radiusKM
self.alertMode = alertMode
self.bestValueThresholdGBP = bestValueThresholdGBP
self.fuelSavingsSettings = fuelSavingsSettings
self.fuelSavingsAmount = fuelSavingsAmount
refreshDebugStatus()
@@ -166,18 +190,37 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
// Debug section only shows the CURRENT region problem.
lastRegionError = nil
// Favourites first (guaranteed slots), then closest stations, max 18.
// `self.favourites` is already filtered to the monitored fuel.
// Favourites first (guaranteed slots). For Best Value mode, spend the
// remaining iOS region slots on likely savings winners before falling
// back to closest stations; the actual notification still rechecks the
// winner after the geofence wakes the app.
var candidates: [FuelStation] = self.favourites
let favIDs = Set(self.favourites.map(\.id))
let location = FuelStore.loadLocation()
let others = stations
.filter { !favIDs.contains($0.id) }
.sorted { lhs, rhs in
guard let location else { return false }
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
rhs.distanceKM(to: location.lat, lng2: location.lng)
let eligibleOthers = stations.filter { !favIDs.contains($0.id) && $0.prices[fuel] != nil }
let others: [FuelStation]
if alertMode == .bestValue,
fuelSavingsSettings.isConfigured,
let location {
let inRadius = eligibleOthers.filter {
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
userLat: location.lat, userLng: location.lng)
}
others = FuelSavingsEstimator.ranked(
inRadius,
fuel: fuel,
baseline: savingsBaseline(in: inRadius, location: location),
settings: fuelSavingsSettings,
amount: fuelSavingsAmount,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
)
} else {
others = eligibleOthers.sorted { lhs, rhs in
guard let location else { return false }
return FuelStore.displayDistanceKM(station: lhs, userLat: location.lat, userLng: location.lng) <
FuelStore.displayDistanceKM(station: rhs, userLat: location.lat, userLng: location.lng)
}
}
candidates.append(contentsOf: others)
var registered: [String] = []
@@ -219,6 +262,33 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
var isEnabled: Bool { enabled }
private func savingsBaseline(in stations: [FuelStation], location: Coordinate) -> FuelStation? {
FuelSavingsEstimator.baselineStation(
stations,
fuel: fuel,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
)
}
private func savingsJourneyMiles(for station: FuelStation, location: Coordinate) -> FuelSavingsJourney? {
if let roadMeters = FuelStore.roadJourneyMeters(for: station, userLat: location.lat, userLng: location.lng) {
let confidence: JourneyConfidence = FuelStore.hasMeasuredReturnRoadDistance(
for: station, userLat: location.lat, userLng: location.lng
) ? .confirmed : .estimated
return FuelSavingsJourney(
miles: roadMeters / FuelSavingsCalculator.metresPerMile,
basis: .roadRoute,
confidence: confidence
)
}
return FuelSavingsJourney(
miles: FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) /
FuelSavingsCalculator.kilometresPerMile * 2,
basis: .straightLine,
confidence: .estimated
)
}
/// Compares the latest refreshed favourites against the persisted cheapest-
/// favourite baseline for one fuel and schedules a notification when the
/// winner changes or the current winner drops by a meaningful amount.
@@ -313,12 +383,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
return
}
let sellers = stations.filter { $0.prices[fuel] != nil }
// Display distance, so the "N in range" count here matches the list the
// user is actually looking at.
let inRange = sellers.filter {
$0.distanceKM(to: fix.coordinate.lat, lng2: fix.coordinate.lng) <= radiusKM
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
userLat: fix.coordinate.lat, userLng: fix.coordinate.lng)
}
let cheapest = inRange.min { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
let cheapestDistance = cheapest.map {
$0.distanceKM(to: fix.coordinate.lat, lng2: fix.coordinate.lng)
FuelStore.displayDistanceKM(station: $0, userLat: fix.coordinate.lat, userLng: fix.coordinate.lng)
}
debugStatus = DebugLocationStatus(
coordinate: fix.coordinate,
@@ -432,25 +505,13 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
$0.prices[fuel] != nil &&
$0.distanceKM(to: station.lat, lng2: station.lng) <= radiusKM
}
guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }) else {
logAlert(.gate, "\(station.name) — no \(fuel.displayName.lowercased()) sellers within \(String(format: "%.1f", radiusKM)) km after fresh fetch")
return
}
guard cheapest.id == station.id else {
logAlert(.gate, "\(station.name) — not cheapest: \(cheapest.name) at \(String(format: "%.1fp", cheapest.prices[fuel]!)) is cheaper within the radius")
return
}
// withinRadius guarantees the fuel exists on every candidate.
guard let price = cheapest.prices[fuel] else { return }
// Ties: all stations in radius at the same cheapest price.
let tied = FuelStore.tiedStations(
in: withinRadius, fuel: fuel, price: price,
fromLat: station.lat, lng: station.lng
)
fireAlert(for: cheapest, price: price, ties: tied)
lastNotified[stationID] = Date()
switch alertMode {
case .cheapest:
handleCheapestEntry(station: station, withinRadius: withinRadius)
case .bestValue:
handleBestValueEntry(station: station, withinRadius: withinRadius)
}
} catch {
// Trace the silent catch — a relay failure drops the alert
// with no banner, which previously looked like "no alert".
@@ -459,6 +520,65 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
}
}
private func handleCheapestEntry(station: FuelStation, withinRadius: [FuelStation]) {
guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }) else {
logAlert(.gate, "\(station.name) — no \(fuel.displayName.lowercased()) sellers within \(String(format: "%.1f", radiusKM)) km after fresh fetch")
return
}
guard cheapest.id == station.id else {
logAlert(.gate, "\(station.name) — not cheapest: \(cheapest.name) at \(String(format: "%.1fp", cheapest.prices[fuel]!)) is cheaper within the radius")
return
}
// withinRadius guarantees the fuel exists on every candidate.
guard let price = cheapest.prices[fuel] else { return }
// Ties: all stations in radius at the same cheapest price.
let tied = FuelStore.tiedStations(
in: withinRadius, fuel: fuel, price: price,
fromLat: station.lat, lng: station.lng
)
fireAlert(for: cheapest, price: price, ties: tied)
lastNotified[station.id] = Date()
}
private func handleBestValueEntry(station: FuelStation, withinRadius: [FuelStation]) {
guard fuelSavingsSettings.isConfigured else {
logAlert(.gate, "\(station.name) — Best Value not configured")
return
}
guard let candidate = bestValueCandidate(in: withinRadius, around: station) else {
logAlert(.gate, "\(station.name) — no Best Value candidate clears \(FuelSavingsFormat.money(bestValueThresholdGBP))")
return
}
guard candidate.station.id == station.id else {
logAlert(.gate, "\(station.name) — not Best Value: \(candidate.station.name) is \(candidate.result.headline())")
return
}
fireBestValueAlert(for: candidate.station, result: candidate.result)
lastNotified[station.id] = Date()
}
private func bestValueCandidate(in stations: [FuelStation], around station: FuelStation) -> BestValueAlertCandidate? {
let origin = Coordinate(lat: station.lat, lng: station.lng)
let threshold = bestValueThresholdGBP
let extraMargin = 0.50
let candidate = FuelSavingsCalculator.bestValueAlertCandidate(
stations: stations,
fuel: fuel,
settings: fuelSavingsSettings,
amount: fuelSavingsAmount,
minimumNetSavingGBP: threshold,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: origin) }
)
guard let candidate else { return nil }
if candidate.result.journeyConfidence.isEstimated,
candidate.result.netSavingGBP < threshold + extraMargin {
return nil
}
return candidate
}
private func fireAlert(for station: FuelStation, price: Double, ties: [FuelStation] = []) {
let brand = station.brand.isEmpty ? station.name : station.brand
let unit = FuelStore.loadDistanceUnit()
@@ -493,6 +613,17 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away"
}
private func fireBestValueAlert(for station: FuelStation, result: FuelSavingsResult) {
let brand = station.brand.isEmpty ? station.name : station.brand
let content = UNMutableNotificationContent()
content.sound = .default
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
content.body = "\(station.name): \(result.headline()). Tap for directions."
addAlertRequest(content: content, station: station)
logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())")
lastAlert = "Best Value · \(brand) · \(result.headline())"
}
private func fireFavouriteDropAlert(
current: FavouriteEntry,
previous: FavouriteAlertSnapshot,
@@ -663,6 +794,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// the geofence itself. Uses the monitor's live state (fuel, radius,
/// stations) so the notification reflects the Alerts-tab configuration.
func sendTestNotification() {
switch alertMode {
case .cheapest:
sendCheapestTestNotification()
case .bestValue:
sendBestValueTestNotification()
}
}
private func sendCheapestTestNotification() {
let unit = FuelStore.loadDistanceUnit()
let location = FuelStore.loadLocation()
let sellers = stations.filter { $0.prices[fuel] != nil }
@@ -671,7 +811,8 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
// identical to handleEntry's withinRadius logic.
let inRadius = sellers.filter { station in
guard let location else { return true }
return station.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
return FuelStore.isWithinDisplayRadius(station: station, radiusKM: radiusKM,
userLat: location.lat, userLng: location.lng)
}
let candidate = inRadius.min { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
@@ -738,6 +879,70 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
}
}
private func sendBestValueTestNotification() {
let unit = FuelStore.loadDistanceUnit()
let location = FuelStore.loadLocation()
let sellers = stations.filter { $0.prices[fuel] != nil }
let inRadius = sellers.filter { station in
guard let location else { return true }
return FuelStore.isWithinDisplayRadius(station: station, radiusKM: radiusKM,
userLat: location.lat, userLng: location.lng)
}
let origin = location ?? inRadius.first.map { Coordinate(lat: $0.lat, lng: $0.lng) }
let candidate: BestValueAlertCandidate? = {
guard let origin else { return nil }
return FuelSavingsCalculator.bestValueAlertCandidate(
stations: inRadius,
fuel: fuel,
settings: fuelSavingsSettings,
amount: fuelSavingsAmount,
minimumNetSavingGBP: bestValueThresholdGBP,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: origin) }
)
}()
guard fuelSavingsSettings.isConfigured else {
sendNoCandidateTestNotification(
title: "FuelBoard Best Value test",
body: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.",
result: "Best Value not configured"
)
return
}
guard let candidate else {
let body = sellers.isEmpty
? "No station data loaded yet — open the Stations tab first, then retry."
: "No Best Value candidate above \(FuelSavingsFormat.money(bestValueThresholdGBP)) within \(unit.format(radiusKM))."
sendNoCandidateTestNotification(
title: "FuelBoard Best Value test",
body: body,
result: sellers.isEmpty ? "No stations loaded — open the Stations tab first" : "No Best Value candidate above \(FuelSavingsFormat.money(bestValueThresholdGBP)) within \(unit.format(radiusKM))"
)
return
}
let content = UNMutableNotificationContent()
content.sound = .default
let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
content.body = "\(candidate.station.name): \(candidate.result.headline()). Tap for directions."
let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))"
lastTestResult = baseResult
logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())")
addAlertRequest(content: content, station: candidate.station) { status in
self.lastTestResult = "\(baseResult) · \(status)"
}
}
private func sendNoCandidateTestNotification(title: String, body: String, result: String) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
logAlert(.fired, "test alert scheduled — \(result)")
lastTestResult = result
}
/// Fires a notification with NO criteria checks at all — no geofence, no
/// cheapest-within-radius, no dedup, no permission gate, no fuel/radius
/// coupling. Still carries a REAL station (the nearest one selling the
@@ -791,9 +996,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
}
}
/// Tapping a notification opens Apple Maps directions to the station from
/// the user's current location. If Apple Maps can't be opened (e.g. inside
/// LiveContainer), an in-app map sheet with directions is shown instead.
/// Tapping a notification honours Settings → Tap station: Open map opens
/// Apple Maps directions to the station from the user's current location;
/// More info opens the station's detail sheet in the app instead. If Apple
/// Maps can't be opened (e.g. inside LiveContainer), an in-app map sheet
/// with directions is shown instead.
///
/// When the notification carried tie-choice action buttons, the tapped
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
@@ -818,9 +1025,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
let tieName = ties[index]["name"] as? String,
let tieLat = ties[index]["lat"] as? Double,
let tieLng = ties[index]["lng"] as? Double {
openDirections(to: tieName, latitude: tieLat, longitude: tieLng)
routeNotificationTap(name: tieName, latitude: tieLat, longitude: tieLng)
} else if !isTie, let name, let lat, let lng {
openDirections(to: name, latitude: lat, longitude: lng)
routeNotificationTap(name: name, latitude: lat, longitude: lng)
}
// Tie body tap: intentionally no directions — the user must pick
// an option button to choose a station.
@@ -828,6 +1035,25 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
}
}
/// Routes a resolved notification tap through the user's Tap station
/// preference: Open map → Apple Maps; More info → in-app detail sheet
/// (falling back to the map sheet when the station isn't in cache).
@MainActor
private func routeNotificationTap(name: String, latitude: Double, longitude: Double) {
guard FuelStore.loadStationTapAction() == .showDetails else {
openDirections(to: name, latitude: latitude, longitude: longitude)
return
}
if let station = stations.first(where: { $0.lat == latitude && $0.lng == longitude })
?? stations.first(where: { $0.name == name }) {
pendingDetailStation = station
} else {
// Station not in cache (e.g. data refreshed since the alert fired)
// — fall back to the map sheet so the tap still shows something.
pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude)
}
}
/// Opens Apple Maps with driving directions to the station; falls back to
/// the in-app map sheet when the hand-off fails.
@MainActor
+381 -26
View File
@@ -7,74 +7,415 @@
// here, not in the widget. The widget/Live Activity just read the cache.
//
// Throttling: recompute at most every `throttleMinutes`, or when the user has
// moved `moveThresholdMeters` from where the cache was built. Bounded to the
// `candidatesPerPass` nearest stations so a pass stays a handful of route calls.
// moved `moveThresholdMeters` from where the cache was built. In
// "Accurate distances" mode a pass routes the WHOLE search area (up to
// `fillCapacity` stations) so the browse layer can serve routed distances for
// every row at once; the mode must also be ON for any pass to run at all.
import Foundation
import CoreLocation
import MapKit
import UIKit
import WidgetKit
struct RoadDistanceFillDebugStatus: Codable, Equatable {
var state: State = .idle
var reason: String = "Waiting"
var updatedAt: Date = Date()
var originLat: Double?
var originLng: Double?
var stationPoolCount: Int = 0
var priorityCount: Int = 0
var candidateCount: Int = 0
var routedCount: Int = 0
var cachedCount: Int = 0
var failedCount: Int = 0
var complete: Bool = false
var capped: Bool = false
var elapsedSeconds: Double?
var movedMeters: Double?
var throttleSecondsRemaining: Double?
enum State: String, Codable {
case idle
case gated
case running
case completed
}
var summary: String {
switch state {
case .idle: return "Idle — \(reason)"
case .gated: return "Gated — \(reason)"
case .running: return "Running — \(reason)"
case .completed: return complete ? "Complete — routed set ready" : "Incomplete — still Approx."
}
}
static let idle = RoadDistanceFillDebugStatus()
}
enum RoadDistanceService {
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
/// route calls. Raised from 12 so stations past the old nearest-12 cutoff
/// still get real road distances instead of a straight-line fallback.
static let candidatesPerPass = 40
/// Upper bound on stations routed per pass. Accurate-distances mode routes the
/// whole search area in ONE pass, because a 12-per-pass budget could never
/// make a list fully routed — that is exactly why two devices (and two
/// radii) disagreed about the same station. Capped so a pathological dataset
/// can't turn one pass into thousands of route requests; a capped pass stays
/// incomplete, and the browse layer keeps showing straight-line + "Approx."
/// until a later pass finishes the job. Measured on device: ~3.2 route calls
/// per second, so 600 stations (1,200 calls) is ~6 min at the very worst.
static let fillCapacity = 600
/// How many Worth-the-Drive candidates may claim slots in a pass. Those
/// rows are ranked by NET SAVING and are typically FURTHER out than the
/// nearest stations — routing only the nearest stations (the old behaviour)
/// meant they never got a measured route and stayed on straight-line
/// estimates forever.
static let priorityCandidates = 6
/// Only route stations within this straight-line radius (km). Covers the
/// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so
/// every station a widget/Live Activity/list can actually show gets routed.
static let maxRadiusKM: Double = 25
/// Don't route again more often than this (minutes).
/// Don't route again more often than this (minutes) once the set is whole.
static let throttleMinutes: Double = 10
/// Retry window for a set that is still INCOMPLETE — a pass cut short by
/// backgrounding the app must be continued promptly, not in ten minutes.
static let incompleteRetrySeconds: Double = 30
/// Recompute when the user moves more than this (metres) from the last
/// source location.
static let moveThresholdMeters: Double = 400
static private(set) var debugStatus = RoadDistanceFillDebugStatus.idle
@MainActor
static func currentDebugStatus() -> RoadDistanceFillDebugStatus {
debugStatus
}
@MainActor
private static func publishDebug(_ status: RoadDistanceFillDebugStatus) {
debugStatus = status
NotificationCenter.default.post(name: .fuelBoardRoadDistanceDebugUpdated, object: nil)
}
private static func updateDebug(_ status: RoadDistanceFillDebugStatus) async {
await MainActor.run { publishDebug(status) }
}
private static func baseDebugStatus(state: RoadDistanceFillDebugStatus.State,
reason: String,
stations: [FuelStation],
lat: Double,
lng: Double,
priorityCount: Int) -> RoadDistanceFillDebugStatus {
RoadDistanceFillDebugStatus(
state: state,
reason: reason,
updatedAt: Date(),
originLat: lat,
originLng: lng,
stationPoolCount: stations.count,
priorityCount: priorityCount
)
}
/// Refreshes the cached road distances for the in-radius stations around
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
guard !stations.isEmpty else { return }
///
/// - Parameter priorityStations: stations to route BEFORE the nearest ones
/// (the Worth-the-Drive candidates and the comparison baseline), so the
/// numbers the user actually reads are road-routed rather than estimated.
static func refreshIfNeeded(stations: [FuelStation],
lat: Double,
lng: Double,
prioritising priorityStations: [FuelStation] = []) async {
// "Accurate distances" OFF (the default) means the browse layer never
// serves a routed value, so any routing would be pure cost with nothing
// visible to show for it: no pass, no requests, no widget reloads.
guard FuelStore.accurateDistancesEnabled else {
await updateDebug(baseDebugStatus(state: .gated,
reason: "Accurate distances off",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count))
return
}
// Only fill while the app is on screen. The browse layer is the only
// consumer, so a background fill would spend radio and battery routing a
// position nothing is displaying — worst case, a long drive.
guard await appIsActive() else {
await updateDebug(baseDebugStatus(state: .gated,
reason: "App not active",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count))
return
}
guard await refreshGate.begin() else {
await updateDebug(baseDebugStatus(state: .gated,
reason: "Fill already running",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count))
return
}
defer { Task { await refreshGate.end() } }
guard !stations.isEmpty else {
await updateDebug(baseDebugStatus(state: .gated,
reason: "No stations loaded",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count))
return
}
let existing = FuelStore.loadRoadDistances()
// Entries can only be merged when they were routed from (near enough)
// this same origin — the read path rejects anything else anyway.
let originCompatible = existing.map {
haversineMeters($0.sourceLat, $0.sourceLng, lat, lng) < moveThresholdMeters
} ?? false
// Throttle: keep cached values when fresh and the user hasn't moved far.
if let cache = FuelStore.loadRoadDistances() {
// `FB_ROUTE_REFRESH=1` forces a pass (DEBUG verification hook).
#if DEBUG
let forcePass = ProcessInfo.processInfo.environment["FB_ROUTE_REFRESH"] == "1"
#else
let forcePass = false
#endif
if !forcePass, let cache = existing {
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
// An INCOMPLETE set still has work to do, so it is not throttled for
// the full ten minutes. The browse layer refuses to serve a partial
// set (every row reads straight-line + "Approx." until the set is
// whole), and a pass is routinely cut short by backgrounding the app
// — so a ten-minute throttle on an incomplete set is what made
// "Approx." look permanent: reopen the app inside the window and the
// pass that would finish the job never even started, and refreshing
// the prices could not help. Retry soon instead.
let incomplete = cache.complete != true
let throttleWindow = incomplete ? incompleteRetrySeconds : throttleMinutes * 60
if elapsed < throttleWindow && movedMeters < moveThresholdMeters {
var status = baseDebugStatus(state: .gated,
reason: incomplete ? "Throttled — incomplete set, retrying"
: "Throttled",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count)
status.cachedCount = cache.entries.count
status.complete = !incomplete
status.movedMeters = movedMeters
status.throttleSecondsRemaining = throttleWindow - elapsed
if incomplete,
await repairCompletenessIfNothingLeftToRoute(cache, stations: stations,
lat: lat, lng: lng) {
status.complete = true
status.reason = "Throttled — stale incomplete flag repaired"
}
await updateDebug(status)
if status.complete {
await notifyDistanceSurfaces()
}
return
}
}
// Candidate stations: the nearest-by-straight-line subset that the UI
// could actually display, capped so a pass stays bounded.
let nearest = stations
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
.prefix(candidatesPerPass)
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
// Candidates: Worth-the-Drive priorities first, then the nearest
// stations. Stations that already have a route from this origin are
// skipped so the per-pass budget goes on the ones still missing one.
var candidates: [FuelStation] = []
var seen = Set<String>()
func consider(_ station: FuelStation) {
guard candidates.count < fillCapacity else { return }
guard station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM else { return }
guard seen.insert(station.id).inserted else { return }
guard !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng) else { return }
candidates.append(station)
}
for station in priorityStations.prefix(priorityCandidates) { consider(station) }
let nearest = stations.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
for station in nearest {
if candidates.count >= fillCapacity { break }
consider(station)
}
guard !candidates.isEmpty else {
var status = baseDebugStatus(state: .gated,
reason: "No unrouted candidates",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count)
status.cachedCount = existing?.entries.count ?? 0
var complete = existing?.complete == true && FuelStore.accurateDistancesReady(userLat: lat, userLng: lng)
if !complete, let existing,
await repairCompletenessIfNothingLeftToRoute(existing, stations: stations, lat: lat, lng: lng) {
complete = true
status.reason = "No unrouted candidates — stale incomplete flag repaired"
}
status.complete = complete
await updateDebug(status)
if status.complete {
await notifyDistanceSurfaces()
}
return
}
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
var entries: [String: CachedRoadDistance] = [:]
for station in nearest {
var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:]
var running = baseDebugStatus(state: .running,
reason: "Routing \(candidates.count) stations",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count)
running.candidateCount = candidates.count
running.cachedCount = entries.count
running.capped = candidates.count >= fillCapacity
await updateDebug(running)
let passStart = Date()
var routedCount = 0
for station in candidates {
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
if let meters = await roadMeters(from: origin, to: dest) {
async let outward = roadMeters(from: origin, to: dest)
async let back = roadMeters(from: dest, to: origin)
if let meters = await outward {
routedCount += 1
// Store the exact pin that was routed so the display layer can
// refuse to serve this value if the station later appears with
// a different coordinate (corrected pin / other data source).
let returnMeters = await back
entries[station.id] = CachedRoadDistance(meters: meters,
lat: station.lat,
lng: station.lng)
lng: station.lng,
returnMeters: returnMeters)
}
}
guard !entries.isEmpty else { return }
guard entries.count > (originCompatible ? (existing?.entries.count ?? 0) : 0) || !originCompatible else {
var status = baseDebugStatus(state: .gated,
reason: "Route pass added no distances",
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count)
status.candidateCount = candidates.count
status.routedCount = routedCount
status.failedCount = candidates.count - routedCount
status.cachedCount = entries.count
status.elapsedSeconds = Date().timeIntervalSince(passStart)
await updateDebug(status)
return
}
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
// Wake the widgets so the new road distances surface immediately.
// A pass is complete when nothing in the search area is left UNRESOLVED:
// every station either has a route from this anchor, or was attempted in
// THIS pass and Apple Maps returned none. A few pins genuinely have no
// route (offshore, deleted forecourt, bad snapshot) and one of those must
// never leave the area permanently incomplete — that would pin the whole
// list on straight-line estimates forever. Those stations show their
// straight line, marked Approx., and the radius filter uses that same
// distance, so nothing is ever out of step with what a row displays.
let attempted = Set(candidates.map(\.id))
let capped = candidates.count >= fillCapacity
let unresolved = capped || stations.contains { station in
station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM
&& !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng)
&& !attempted.contains(station.id)
}
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries,
complete: !unresolved)
var completed = baseDebugStatus(state: .completed,
reason: !unresolved ? "Fill complete" : (capped ? "Capacity cap hit" : "Unresolved stations remain"),
stations: stations,
lat: lat,
lng: lng,
priorityCount: priorityStations.count)
completed.candidateCount = candidates.count
completed.routedCount = routedCount
completed.failedCount = candidates.count - routedCount
completed.cachedCount = entries.count
completed.complete = !unresolved
completed.capped = capped
completed.elapsedSeconds = Date().timeIntervalSince(passStart)
await updateDebug(completed)
#if DEBUG
// `FB-ROUTE` prints what a pass actually cost (stations, route calls,
// wall-clock, unroutable pins, whether the set is now complete) — the
// evidence for any "how taxing is routing?" answer, and how a bigger pass
// gets scaled from measurement instead of guesswork.
print("FB-ROUTE pass stations=\(candidates.count) calls=\(candidates.count * 2) "
+ "elapsed=\(String(format: "%.2f", Date().timeIntervalSince(passStart)))s "
+ "cached=\(entries.count) failed=\(candidates.count - routedCount) "
+ "complete=\(!unresolved)")
#endif
await notifyDistanceSurfaces()
}
/// A cache can hold a route for EVERY station in the search area and still be
/// flagged incomplete — a previous pass hit the capacity cap, or was
/// interrupted between routing and writing. Nothing would ever clear that
/// flag on its own (the next pass finds nothing left to route, and the
/// throttle path returns without writing), and every row would stay on
/// straight-line + "Approx." while its routed value sat in the cache. When
/// nothing in the area is left unrouted, the set IS complete: correct the
/// flag so the routed values can be served.
private static func repairCompletenessIfNothingLeftToRoute(_ cache: RoadDistanceCache,
stations: [FuelStation],
lat: Double,
lng: Double) async -> Bool {
guard cache.complete != true, !stations.isEmpty else { return false }
let nothingLeftToRoute = !stations.contains { station in
station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM
&& !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng)
}
guard nothingLeftToRoute else { return false }
FuelStore.saveRoadDistances(sourceLat: cache.sourceLat, sourceLng: cache.sourceLng,
entries: cache.entries, complete: true)
#if DEBUG
print("FB-COMPLETE stale incomplete flag repaired cached=\(cache.entries.count) "
+ "(every in-radius station already routed — no re-routing needed)")
#endif
return true
}
@MainActor
private static func notifyDistanceSurfacesOnMain() {
WidgetCenter.shared.reloadAllTimelines()
NotificationCenter.default.post(name: .fuelBoardRoadDistancesUpdated, object: nil)
}
private static func notifyDistanceSurfaces() async {
await MainActor.run { notifyDistanceSurfacesOnMain() }
}
private static let refreshGate = RoadDistanceRefreshGate()
/// Accurate mode fills only while the app is on screen: the browse layer is
/// the only consumer of routed distances, so a background fill would spend
/// radio and battery routing a position nothing is looking at.
private static func appIsActive() async -> Bool {
await MainActor.run { UIApplication.shared.applicationState == .active }
}
/// Driving distance (metres) between two coordinates via Apple Maps routing.
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
request.source = MKMapItem(
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.requestsAlternateRoutes = false
do {
@@ -96,3 +437,17 @@ enum RoadDistanceService {
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
}
private actor RoadDistanceRefreshGate {
private var isRefreshing = false
func begin() -> Bool {
guard !isRefreshing else { return false }
isRefreshing = true
return true
}
func end() {
isRefreshing = false
}
}
+379 -31
View File
@@ -18,6 +18,8 @@ struct SettingsView: View {
@ObservedObject var tipStore: TipStore
@Binding var distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle
@Binding var stationTapAction: StationTapAction
@Binding var fuelSavingsSettings: FuelSavingsSettings
@Binding var dataRefreshMode: DataRefreshMode
/// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say.
@@ -52,6 +54,7 @@ struct SettingsView: View {
var regionEventCount: Int = 0
/// Debug fence state (monitor.debugFenceIdentifier) — armed vs cleared.
var debugFenceIdentifier: String?
var roadDistanceDebugStatus: RoadDistanceFillDebugStatus = .idle
/// Arms a 100 m fence at the current location (monitor.registerDebugFenceAroundMe).
var onDebugFence: () -> Void = {}
/// Clears the armed fence (monitor.clearDebugFence).
@@ -61,9 +64,9 @@ struct SettingsView: View {
/// Hidden developer flag — the Debug section only appears when on. Toggled
/// by tapping the About → Version row five times (NOT a user-facing switch).
@State private var debugMode: Bool = FuelStore.loadDebugMode()
/// Dev-only: re-inserts the LAN relay into the live chain (off by default —
/// consumers must never attempt local-network access).
@State private var relayFallbackEnabled: Bool = FuelStore.loadRelayFallbackEnabled()
/// "Accurate distances" — read once on appear and written straight through to
/// the shared store, which is what every display surface actually consults.
@State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled
@State private var versionTapCount = 0
@State private var lastVersionTap = Date.distantPast
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
@@ -72,6 +75,18 @@ struct SettingsView: View {
@State private var mediumWidgetDiag: String?
@State private var installedWidgets: String = ""
/// Which numeric field on this screen is being edited. `.decimalPad` has no
/// 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
/// nothing sets it — it stays for any numeric field this screen takes again.
@State private var settingsUnitChangeNotice: String?
@FocusState private var focusedSavingsField: SavingsField?
private enum SavingsField: Hashable {
case averageMPG
case fillAmount
}
private func refreshWidgetDiag() {
smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidget")
mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent")
@@ -94,6 +109,64 @@ struct SettingsView: View {
var body: some View {
NavigationStack {
List {
Section {
Toggle("Best Value", isOn: fuelSavingsEnabledBinding)
// The feature's own controls follow its toggle: switched
// off they grey out but stay on screen (same rule as the
// distance picker in Closest mode) and keep their values, so
// re-enabling restores exactly what was there. The sections
// that USE the feature — the in-list fill-up control and the
// Best Value sort option — are gated on `isConfigured`,
// which already includes `enabled`, so they hide entirely.
Group {
HStack {
Text("Average fuel economy")
Spacer()
Text(mpgText)
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: fuelSavingsMPGBinding,
in: FuelSavingsCalculator.mpgSliderRange,
step: FuelSavingsCalculator.mpgSliderStep)
.accessibilityLabel("Average fuel economy")
.accessibilityValue(mpgText)
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
ForEach(FuelSavingsFillUnit.allCases) { unit in
Text(unit.displayName).tag(unit)
}
}
.pickerStyle(.segmented)
HStack {
Text("Typical fill-up")
Spacer()
Text(fillUpText)
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: fuelSavingsAmountBinding,
in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit),
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit))
.accessibilityLabel("Typical fill-up")
.accessibilityValue(fillUpText)
if let settingsUnitChangeNotice {
Text(settingsUnitChangeNotice)
.font(.caption2)
.foregroundStyle(.orange)
}
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
.font(.caption)
.foregroundStyle(.orange)
}
}
.disabled(!fuelSavingsSettings.enabled)
} header: {
Text("Fuel Savings")
} footer: {
Text("Best Value estimates fuel cost only, using UK imperial MPG, your typical purchase amount, fuel prices and Apple Maps road distances. Real-world average MPG is better than manufacturer figures; the fill-up amount is your usual purchase, not tank capacity.")
}
Section {
Picker("Distance", selection: $distanceUnit) {
ForEach(DistanceUnit.allCases) { unit in
@@ -123,6 +196,36 @@ struct SettingsView: View {
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
}
Section {
Toggle("Accurate distances", isOn: $accurateDistances)
.onChange(of: accurateDistances) { _, newValue in
FuelStore.setAccurateDistancesEnabled(newValue)
WidgetCenter.shared.reloadAllTimelines()
NotificationCenter.default.post(name: .fuelBoardAccurateDistancesChanged,
object: nil)
}
} header: {
Text("Distances")
} footer: {
Text("Off (default): distances are straight-line estimates marked Approx. and the app makes no route requests. On: Apple Maps driving distances for every station in range, filled only while this app is open. Search radii then mean miles of driving.")
}
Section {
Picker("Tap station", selection: $stationTapAction) {
ForEach(StationTapAction.allCases) { action in
Text(action.displayName).tag(action)
}
}
.pickerStyle(.segmented)
.onChange(of: stationTapAction) { _, newValue in
FuelStore.saveStationTapAction(newValue)
}
} header: {
Text("Tap action")
} footer: {
Text("Tapping a station either opens directions straight away or shows its details first.")
}
Section {
Picker("Data checking", selection: $dataRefreshMode) {
ForEach(DataRefreshMode.allCases) { mode in
@@ -159,10 +262,6 @@ struct SettingsView: View {
if debugMode {
Section {
Toggle("LAN relay fallback (dev)", isOn: $relayFallbackEnabled)
.onChange(of: relayFallbackEnabled) { _, enabled in
FuelStore.saveRelayFallbackEnabled(enabled)
}
Button {
sendTestAlert()
} label: {
@@ -192,7 +291,7 @@ struct SettingsView: View {
} header: {
Text("Debug")
} 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() }
@@ -232,6 +331,55 @@ struct SettingsView: View {
}
.onAppear { refreshWidgetDiag() }
Section {
Button {
Task { await tipStore.load() }
} label: {
Label("Probe App Store products", systemImage: "arrow.clockwise")
}
LabeledContent("Probe", value: tipStore.productLoadStatus)
if let loadedAt = tipStore.lastProductLoadDate {
LabeledContent("Last probe", value: loadedAt.formatted(date: .abbreviated, time: .standard))
}
LabeledContent("Requested", value: "\(TipStore.tiers.count) IDs")
LabeledContent("Returned", value: "\(tipStore.debugProducts.count) products")
if !tipStore.debugProducts.isEmpty {
ForEach(tipStore.debugProducts) { product in
VStack(alignment: .leading, spacing: 3) {
Text(product.id)
.font(.caption.monospaced())
.textSelection(.enabled)
Text(product.displayName)
.font(.footnote.weight(.medium))
Text("\(product.type) · \(product.displayPrice) · raw \(product.price)")
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
} else {
Text("No products were returned by StoreKit.")
.font(.caption)
.foregroundStyle(.secondary)
}
if !tipStore.missingProductIDs.isEmpty {
Text("Missing IDs: \(tipStore.missingProductIDs.joined(separator: ", "))")
.font(.caption2)
.foregroundStyle(.orange)
.textSelection(.enabled)
}
if let error = tipStore.lastProductLoadError {
Text("Error: \(error)")
.font(.caption2)
.foregroundStyle(.red)
.textSelection(.enabled)
}
} header: {
Text("IAP Diagnostics")
} footer: {
Text("Shows the exact product identifiers requested and the StoreKit products returned. Product names and prices are read from StoreKit; missing IDs were not returned by the current App Store environment.")
}
Section {
HStack {
Text("Connection")
@@ -259,6 +407,36 @@ struct SettingsView: View {
Text("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.")
}
Section {
LabeledContent("Status", value: roadDistanceDebugStatus.summary)
LabeledContent("Updated", value: roadDistanceDebugStatus.updatedAt.formatted(date: .omitted, time: .standard))
if let lat = roadDistanceDebugStatus.originLat,
let lng = roadDistanceDebugStatus.originLng {
LabeledContent("Origin", value: String(format: "%.5f, %.5f", lat, lng))
}
LabeledContent("Pool", value: "\(roadDistanceDebugStatus.stationPoolCount)")
LabeledContent("Candidates", value: "\(roadDistanceDebugStatus.candidateCount)")
LabeledContent("Cached", value: "\(roadDistanceDebugStatus.cachedCount)")
if roadDistanceDebugStatus.routedCount > 0 || roadDistanceDebugStatus.failedCount > 0 {
LabeledContent("Routed", value: "\(roadDistanceDebugStatus.routedCount)")
LabeledContent("Failed", value: "\(roadDistanceDebugStatus.failedCount)")
}
if let elapsed = roadDistanceDebugStatus.elapsedSeconds {
LabeledContent("Elapsed", value: String(format: "%.1fs", elapsed))
}
if let moved = roadDistanceDebugStatus.movedMeters {
LabeledContent("Moved", value: String(format: "%.0f m", moved))
}
if let remaining = roadDistanceDebugStatus.throttleSecondsRemaining {
LabeledContent("Throttle", value: String(format: "%.0f s remaining", max(0, remaining)))
}
LabeledContent("Ready", value: roadDistanceDebugStatus.complete ? "yes" : "no")
} header: {
Text("Road distance fill")
} footer: {
Text("Shows whether Accurate distances is running or gated. Distances stay Approx. until Ready is yes for the current origin; gated reasons explain why no fill is happening.")
}
Section {
if monitoredCount == 0 {
Text("No stations monitored yet — open the Stations tab to load prices first.")
@@ -387,6 +565,9 @@ struct SettingsView: View {
}
}
// Numeric entry must be escapable: `.decimalPad` has no Return key.
.keyboardDoneButton { focusedSavingsField = nil }
.scrollDismissesKeyboard(.interactively)
.navigationTitle("Settings")
.onAppear {
Task { await tipStore.load() }
@@ -506,6 +687,62 @@ struct SettingsView: View {
return "\(version) (\(build))"
}
private var fuelSavingsEnabledBinding: Binding<Bool> {
Binding(
get: { fuelSavingsSettings.enabled },
set: { fuelSavingsSettings.enabled = $0; persistFuelSavingsSettings() }
)
}
private var fuelSavingsMPGBinding: Binding<Double> {
Binding(
get: { fuelSavingsSettings.mpg },
set: { fuelSavingsSettings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG); persistFuelSavingsSettings() }
)
}
private var fuelSavingsAmountBinding: Binding<Double> {
Binding(
get: { fuelSavingsSettings.preferredAmount.value },
set: {
fuelSavingsSettings.preferredAmount.value = min(max($0, 0), FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
persistFuelSavingsSettings()
}
)
}
private var fuelSavingsUnitBinding: Binding<FuelSavingsFillUnit> {
Binding(
get: { fuelSavingsSettings.preferredAmount.unit },
set: { newUnit in
guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return }
fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
settingsUnitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
persistFuelSavingsSettings()
}
)
}
/// "45 MPG" / "45.5 MPG". The slider is the only control, so this read-out
/// is the one place the figure is stated.
private var mpgText: String {
let mpg = fuelSavingsSettings.mpg
return mpg.rounded() == mpg ? String(format: "%.0f MPG", mpg) : String(format: "%.1f MPG", mpg)
}
/// "£40" / "40 L" — the fill-up slider's read-out.
private var fillUpText: String {
switch fuelSavingsSettings.preferredAmount.unit {
case .pounds: return String(format: "£%.0f", fuelSavingsSettings.preferredAmount.value)
case .litres: return String(format: "%.0f L", fuelSavingsSettings.preferredAmount.value)
}
}
private func persistFuelSavingsSettings() {
FuelStore.saveFuelSavingsSettings(fuelSavingsSettings)
}
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
/// when it fell back to the public mirror, "—" when unknown/never fetched.
private var connectionText: String {
@@ -614,19 +851,18 @@ final class TipStore: ObservableObject {
let id: String // product ID
let name: String
let blurb: String
let fallbackPrice: String
let accent: Color // card/price accent (matches the fuel palette)
}
static let tiers: [TipTier] = [
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99",
blurb: "Just enough to keep things moving.",
accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
blurb: "A generous top-up for development.", fallbackPrice: "£2.99",
blurb: "A generous top-up for development.",
accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99",
blurb: "Keeping the app on the road.",
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
]
@@ -640,6 +876,22 @@ final class TipStore: ObservableObject {
@Published private(set) var products: [String: Product] = [:]
@Published private(set) var purchaseInProgress = false
@Published private(set) var outcome: TipOutcome?
@Published private(set) var productLoadStatus = "Not probed"
@Published private(set) var lastProductLoadDate: Date?
@Published private(set) var lastProductLoadError: String?
@Published private(set) var debugProducts: [DebugProduct] = []
struct DebugProduct: Identifiable {
let id: String
let displayName: String
let type: String
let displayPrice: String
let price: String
}
var missingProductIDs: [String] {
Self.tiers.map(\.id).filter { products[$0] == nil }
}
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
private var dismissTask: Task<Void, Never>?
@@ -650,9 +902,31 @@ final class TipStore: ObservableObject {
/// the queue but are never finished or acknowledged.
private var updatesTask: Task<Void, Never>?
/// Drains verified FuelBoard transactions that were already unfinished
/// before this store was created (for example after termination during a
/// sandbox purchase). `Transaction.updates` only covers updates delivered
/// while the listener is active, so both sequences are required.
private var unfinishedTask: Task<Void, Never>?
/// Uses the existing hidden developer switch in Settings. StoreKit
/// diagnostics must stay silent unless the developer explicitly enables
/// Debug mode with the five-tap Version gesture.
private static func debugLog(_ message: @autoclosure () -> String) {
guard FuelStore.loadDebugMode() else { return }
print(message())
}
init() {
Self.debugLog("[FuelBoard StoreKit] TipStore initialized; expected IDs=\(Self.tiers.map(\.id).joined(separator: ","))")
updatesTask = Task { [weak self] in
for await update in Transaction.updates {
Self.debugLog("[FuelBoard StoreKit] Transaction.updates received")
await self?.handle(update)
}
}
unfinishedTask = Task { [weak self] in
for await update in Transaction.unfinished {
Self.debugLog("[FuelBoard StoreKit] Transaction.unfinished received")
await self?.handle(update)
}
}
@@ -660,14 +934,27 @@ final class TipStore: ObservableObject {
deinit {
updatesTask?.cancel()
unfinishedTask?.cancel()
dismissTask?.cancel()
}
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
Self.debugLog("[FuelBoard StoreKit] Handling transaction update")
// Never deliver or finish an untrusted purchase.
guard case .verified(let transaction) = update else { return }
guard case .verified(let transaction) = update else {
if case .unverified(_, let error) = update {
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction error=\(error.localizedDescription)")
} else {
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction update")
}
return
}
// Only acknowledge our own consumables (future products get their own).
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else {
Self.debugLog("[FuelBoard StoreKit] Ignoring unrelated product ID=\(transaction.productID)")
return
}
Self.debugLog("[FuelBoard StoreKit] Finishing verified transaction productID=\(transaction.productID)")
await transaction.finish()
showOutcome(
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
@@ -696,45 +983,101 @@ final class TipStore: ObservableObject {
}
func displayPrice(for tier: TipTier) -> String {
products[tier.id]?.displayPrice ?? tier.fallbackPrice
products[tier.id]?.displayPrice
?? NSLocalizedString("Unavailable", comment: "Tip product has not been returned by StoreKit")
}
func load() async {
// Refreshes product state on every visit so newly-approved products
// (or restored transactions) are picked up.
let ids = Self.tiers.map(\.id)
productLoadStatus = "Loading…"
lastProductLoadError = nil
Self.debugLog("[FuelBoard StoreKit] Loading products IDs=\(ids.joined(separator: ","))")
do {
let fetched = try await Product.products(for: Self.tiers.map(\.id))
let fetched = try await Product.products(for: ids)
lastProductLoadDate = Date()
Self.debugLog("[FuelBoard StoreKit] Product.products returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))")
for product in fetched {
Self.debugLog("[FuelBoard StoreKit] Product id=\(product.id) type=\(String(describing: product.type)) displayPrice=\(product.displayPrice) price=\(product.price)")
}
products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
debugProducts = fetched.map {
DebugProduct(
id: $0.id,
displayName: $0.displayName,
type: String(describing: $0.type),
displayPrice: $0.displayPrice,
price: String(describing: $0.price)
)
}
productLoadStatus = fetched.isEmpty ? "Completed — zero returned" : "Completed"
let matched = Self.tiers.filter { products[$0.id] != nil }.map(\.id)
Self.debugLog("[FuelBoard StoreKit] Matched products after load=\(matched.joined(separator: ","))")
} catch {
// No products yet (sideloaded build) — the buttons still show the
// intended prices and report purchase attempts gracefully.
lastProductLoadDate = Date()
productLoadStatus = "Failed"
lastProductLoadError = "\(String(describing: type(of: error))): \(error.localizedDescription)"
debugProducts = []
Self.debugLog("[FuelBoard StoreKit] Product load failed type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
// Keep the cards visible so the user can retry while App Store
// Connect metadata propagates; displayPrice() shows "Unavailable"
// until StoreKit returns a real product.
products = [:]
}
}
private func merge(_ fetched: [Product]) {
products.merge(Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })) { _, new in new }
}
private func product(for tier: TipTier) async throws -> Product? {
if let product = products[tier.id] {
Self.debugLog("[FuelBoard StoreKit] Using cached product ID=\(tier.id)")
return product
}
// TestFlight/App Store Connect product availability can lag the Settings
// onAppear load. Re-query the tapped tier before falling back so a newly
// approved IAP opens the system purchase sheet instead of showing stale
// "not available" copy.
Self.debugLog("[FuelBoard StoreKit] Re-querying tapped product ID=\(tier.id)")
let fetched = try await Product.products(for: [tier.id])
Self.debugLog("[FuelBoard StoreKit] Re-query returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))")
merge(fetched)
let match = fetched.first { $0.id == tier.id }
Self.debugLog("[FuelBoard StoreKit] Tapped product match=\(match != nil) ID=\(tier.id)")
return match
}
func purchase(_ tier: TipTier) async {
guard !purchaseInProgress else { return }
Self.debugLog("[FuelBoard StoreKit] Purchase requested ID=\(tier.id) name=\(tier.name)")
guard !purchaseInProgress else {
Self.debugLog("[FuelBoard StoreKit] Purchase ignored because another purchase is in progress")
return
}
purchaseInProgress = true
defer { purchaseInProgress = false }
// If the product hasn't loaded (e.g. not configured in App Store
// Connect yet), still allow the attempt so the user sees a clear
// outcome rather than a dead button.
guard let product = products[tier.id] else {
showOutcome(
NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: ""),
icon: "info.circle.fill",
tint: Color(.secondaryLabel)
)
return
}
do {
guard let product = try await product(for: tier) else {
Self.debugLog("[FuelBoard StoreKit] Purchase stopped: no StoreKit product for ID=\(tier.id)")
showOutcome(
NSLocalizedString("This tip isn't available from the App Store yet. Please try again shortly.", comment: ""),
icon: "info.circle.fill",
tint: Color(.secondaryLabel)
)
return
}
Self.debugLog("[FuelBoard StoreKit] Calling product.purchase() ID=\(product.id) displayPrice=\(product.displayPrice)")
let result = try await product.purchase()
switch result {
case .success(let verification):
Self.debugLog("[FuelBoard StoreKit] Purchase result=success")
switch verification {
case .verified(let transaction):
Self.debugLog("[FuelBoard StoreKit] Purchase verified productID=\(transaction.productID); finishing")
// Consume the consumable — otherwise StoreKit re-delivers
// it through Transaction.updates on every launch.
await transaction.finish()
@@ -744,6 +1087,7 @@ final class TipStore: ObservableObject {
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
)
case .unverified:
Self.debugLog("[FuelBoard StoreKit] Purchase result=unverified")
showOutcome(
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
icon: "exclamationmark.triangle.fill",
@@ -751,17 +1095,21 @@ final class TipStore: ObservableObject {
)
}
case .userCancelled:
Self.debugLog("[FuelBoard StoreKit] Purchase result=userCancelled")
dismissOutcome() // silent — the user just closed the sheet
case .pending:
Self.debugLog("[FuelBoard StoreKit] Purchase result=pending")
showOutcome(
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
icon: "hourglass",
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
)
@unknown default:
Self.debugLog("[FuelBoard StoreKit] Purchase result=unknown")
dismissOutcome()
}
} catch {
Self.debugLog("[FuelBoard StoreKit] Purchase threw type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
showOutcome(
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
icon: "xmark.circle.fill",
+329
View File
@@ -0,0 +1,329 @@
import SwiftUI
import MapKit
import UIKit
/// DEBUG-only explanation of the Best Value numbers for one station. It carries
/// no precise user coordinates and is never persisted.
struct FuelSavingsJourneyDiagnostics {
let rows: [(label: String, value: String)]
}
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
/// Shows a small map preview, the address, distance from the current location,
/// and a Directions button that opens Apple Maps — the same destination a direct
/// tap would have opened. The station name lives in the sheet header, not in a
/// row.
struct StationDetailView: View {
let station: FuelStation
let location: Coordinate?
let distanceUnit: DistanceUnit
/// Fuel whose price is being shown by the surrounding station list.
var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
var fuelSavings: FuelSavingsAvailability? = nil
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var savingsPriceWarning: String? = nil
var savingsBaselineStationName: String? = nil
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
/// Whether this station is starred for the fuel currently being viewed, and
/// the toggle that flips it. Passed from the list that opened the sheet, so
/// the star here and the star on the row are the same fuel-scoped
/// favourite and cannot drift apart.
var isFavourite: Bool = false
var onToggleFavourite: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@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 var stationCoordinate: CLLocationCoordinate2D {
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 {
NavigationStack {
List {
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(
center: stationCoordinate,
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
)))) {
Marker(station.name, coordinate: stationCoordinate)
.tint(.red)
}
.frame(height: 180)
.clipShape(RoundedRectangle(cornerRadius: 12))
.allowsHitTesting(false)
.overlay {
Button {
openDirections()
} label: {
Color.clear
.contentShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "Directions to \(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 {
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
.monospacedDigit()
}
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
.monospacedDigit()
LabeledContent("Price updated", value: priceFreshness.updateAgeLabel)
.monospacedDigit()
ForEach(StationNotice.notices(for: station)) { notice in
Label(notice.title, systemImage: notice.symbolName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.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
// when the feature is off — `savingsLookup` returns nil when it
// is not configured — so the heading cannot be left dangling
// over an empty section.
if let fuelSavings {
Section {
FuelSavingsSummaryView(
availability: fuelSavings,
amount: fuelSavingsAmount,
priceConfidence: savingsPriceConfidence,
priceWarning: savingsPriceWarning,
baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit,
showInfoAction: { showBestValueInfo = true }
)
} header: {
Text("Best Value")
}
}
#if DEBUG
if let journeyDiagnostics {
Section("Best Value Diagnostics") {
ForEach(journeyDiagnostics.rows, id: \.label) { row in
LabeledContent(row.label, value: row.value)
.font(.caption)
.monospacedDigit()
}
}
}
#endif
}
.navigationTitle(station.name)
.navigationBarTitleDisplayMode(.inline)
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Report") {
UIApplication.shared.open(reportIssueURL)
}
} message: {
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 {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button {
onToggleFavourite?()
} label: {
Image(systemName: isFavourite ? "star.fill" : "star")
}
.tint(isFavourite ? Color.yellow : nil)
.disabled(onToggleFavourite == nil)
.accessibilityLabel(
isFavourite
? String(localized: "Remove from favourites", comment: "Unstar a station from the More info sheet")
: String(localized: "Add to favourites", comment: "Star a station from the More info sheet")
)
}
}
// 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
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import Foundation
/// Presentation-only notices derived from existing station metadata.
///
/// These do not alter `FuelStation`, persistence, filtering, sorting, or
/// navigation. They are intentionally small and reusable so additional
/// informational station notices can be added later from the UI layer.
enum StationNotice: Identifiable, Equatable {
case membershipRequired
var id: String {
switch self {
case .membershipRequired: return "membershipRequired"
}
}
var title: String {
switch self {
case .membershipRequired: return "Membership required"
}
}
var symbolName: String {
switch self {
case .membershipRequired: return "creditcard.fill"
}
}
static func notices(for station: FuelStation) -> [StationNotice] {
isCostco(station) ? [.membershipRequired] : []
}
static func isCostco(_ station: FuelStation) -> Bool {
containsCostcoToken(station.brand) || containsCostcoToken(station.name)
}
private static func containsCostcoToken(_ value: String) -> Bool {
let tokens = value.uppercased().split { !$0.isLetter && !$0.isNumber }
return tokens.contains("COSTCO")
}
}
+148 -26
View File
@@ -15,6 +15,13 @@ struct StationsView: View {
let topStationID: String?
let location: Coordinate?
let favouriteIDs: Set<String>
let stationTapAction: StationTapAction
let fuelSavingsSettings: FuelSavingsSettings
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var savingsPriceConfidenceForStation: (FuelStation) -> FuelPriceFreshness.ComparisonConfidence
var savingsPriceWarningForStation: (FuelStation) -> String?
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onRefresh: () async -> Void = {}
@@ -29,6 +36,14 @@ struct StationsView: View {
/// info button in the header — keeps the list focused on stations.
@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).
@State private var detailStation: FuelStation?
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
/// directions context + "N/TOTAL stations updated". The numerator is the
/// current list pool; the denominator is the full UK station total from
@@ -37,7 +52,15 @@ struct StationsView: View {
let miles = distanceUnit.displayMiles(stationLimit)
let unit = distanceUnit.label(for: Double(miles))
let fuel = selectedFuel.displayName
let mode = sortMode == .closest ? "Closest" : "Cheapest"
let mode: String
switch sortMode {
case .closest: mode = "Closest"
case .cheapest: mode = "Cheapest"
case .worthTheDrive: mode = "Best Value"
}
let tapHint = stationTapAction == .openMap
? "tap a station for directions."
: "tap a station for details."
let ratio: String
if let total = FuelStore.loadStationCount() {
ratio = "\(totalCount)/\(total)"
@@ -46,11 +69,11 @@ struct StationsView: View {
}
if location != nil {
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 · tap a station for directions."
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
}
return "\(mode) \(fuel) — \(ratio) stations updated · tap a station for directions."
return "\(mode) \(fuel) — \(ratio) stations updated · \(tapHint)"
}
var body: some View {
@@ -72,7 +95,7 @@ struct StationsView: View {
}
.pickerStyle(.segmented)
// 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
// kept for when the user switches back to Cheapest.
.disabled(sortMode == .closest)
@@ -85,7 +108,7 @@ struct StationsView: View {
Section("Stations") {
Picker("Sort by", selection: $sortMode) {
ForEach(SortMode.allCases) { mode in
ForEach(availableSortModes) { mode in
Text(mode.displayName).tag(mode)
}
}
@@ -95,7 +118,19 @@ struct StationsView: View {
}
.padding(.vertical, 2)
if isLoading {
// Only offered while the feature is actually configured — a
// stale stored sort mode must not keep the control on screen
// for a switched-off feature.
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
if !stations.isEmpty && stations.allSatisfy({ savingsPriceConfidenceForStation($0) == .ineligible }) {
Text("Best Value unavailable: no eligible price comparison. Stations and prices remain visible below.")
.font(.caption)
.foregroundStyle(.orange)
}
}
if isLoading && stations.isEmpty {
HStack(spacing: 10) {
ProgressView()
Text("Fetching prices…")
@@ -117,17 +152,7 @@ struct StationsView: View {
.padding(.vertical, 4)
} else {
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
StationRow(
station: station,
fuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice,
isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
)
stationRow(station)
}
if visibleCount < stations.count {
@@ -171,6 +196,12 @@ struct StationsView: View {
// large title at rest, scrolls it away and fades the compact
// inline title in. No custom toolbar needed — the native mechanism
// only renders text, which is exactly what we want here.
// No numeric field is left on this screen — the Worth the Drive
// fill-up control is a slider — so no keyboard should appear here.
// The Done bar and interactive dismissal stay as the escape hatch
// for any field that returns.
.keyboardDoneButton()
.scrollDismissesKeyboard(.interactively)
.navigationTitle("FuelBoard")
.navigationBarTitleDisplayMode(.large)
.onAppear {
@@ -180,6 +211,30 @@ struct StationsView: View {
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
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
}
.task(id: stations.count) {
#if DEBUG
// QA hook: `-showDetail` opens the More info sheet for the first
// listed station, so the sheet can be screenshotted without a
// tap. Waits for the list to arrive first.
guard ProcessInfo.processInfo.arguments.contains("-showDetail"),
detailStation == nil,
let first = stations.first else { return }
try? await Task.sleep(nanoseconds: 2_000_000_000)
detailStation = first
#endif
}
.toolbar {
@@ -193,21 +248,46 @@ struct StationsView: View {
}
}
.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 {
List {
Section {
VStack(spacing: 10) {
keyCard(
icon: "checkmark.circle.fill", color: .green,
title: "Best value", blurb: "Within 1.5p of the cheapest",
threshold: "≤1.5p")
title: "Good price", blurb: "Within 1.5p of the cheapest",
threshold: "0–1.5p")
keyCard(
icon: "equal.circle.fill", color: .orange,
title: "Okay", blurb: "Within 4p of the cheapest",
threshold: "≤4p")
title: "Okay", blurb: "More than 1.5p, up to 4p above the cheapest",
threshold: ">1.5–4p")
keyCard(
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")
Rectangle()
.fill(Color(.separator))
@@ -239,15 +319,27 @@ struct StationsView: View {
.padding(.horizontal, 14)
.padding(.vertical, 12)
.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)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, alignment: .center)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
// The explanation is its own sheet now, opened from this
// row. Gated exactly like the More info row and the Best
// Value card itself: `isConfigured` is the same condition
// `savingsForStation` uses, so the row appears and
// disappears with the feature.
if fuelSavingsSettings.isConfigured {
Section {
HowBestValueRow { showBestValueInfo = true }
}
}
}
.navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline)
@@ -256,11 +348,40 @@ struct StationsView: View {
Button("Done") { showKey = false }
}
}
// Nested sheet: the Key sheet stays behind the explanation.
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
}
.presentationDetents([.fraction(0.6)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
}
}
private func stationRow(_ station: FuelStation) -> some View {
StationRow(
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,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
onShowDetails: { detailStation = $0 }
)
}
private var availableSortModes: [SortMode] {
var modes: [SortMode] = [.closest, .cheapest]
if fuelSavingsSettings.isConfigured {
modes.append(.worthTheDrive)
}
return modes
}
/// One price-rating card for the Key sheet: accent icon, title + blurb,
@@ -276,6 +397,7 @@ struct StationsView: View {
Text(LocalizedStringKey(blurb))
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
HStack(spacing: 6) {
+41 -5
View File
@@ -3,6 +3,17 @@ import WatchConnectivity
extension Notification.Name {
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
/// Posted when a road-distance fill finishes, so the app's rows re-read the
/// distance they are showing — those reads happen inside the row views, so
/// nothing else would invalidate them.
static let fuelBoardRoadDistancesUpdated = Notification.Name("FuelBoardRoadDistancesUpdated")
/// Posted when "Accurate distances" is switched, so the browse layer can
/// re-render and start a fill from where the user actually is.
static let fuelBoardAccurateDistancesChanged = Notification.Name("FuelBoardAccurateDistancesChanged")
/// Posted whenever the road-distance fill starts, finishes, or is gated.
/// Settings → Debug subscribes so the tester can see why Approx. remains.
static let fuelBoardRoadDistanceDebugUpdated = Notification.Name("FuelBoardRoadDistanceDebugUpdated")
}
@MainActor
@@ -10,6 +21,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
static let shared = WatchSyncManager()
private var pendingSnapshotPush = false
private var activationRequested = false
private override init() {
super.init()
@@ -17,19 +29,28 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
func activate() {
guard WCSession.isSupported() else { return }
pendingSnapshotPush = true
let session = WCSession.default
guard session.isWatchAppInstalled else { return }
pendingSnapshotPush = true
if session.delegate !== self {
session.delegate = self
}
guard session.activationState != .activated else { return }
guard !activationRequested else { return }
activationRequested = true
session.activate()
}
func pushSnapshot() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
guard session.isWatchAppInstalled else {
pendingSnapshotPush = false
return
}
guard session.activationState == .activated else {
pendingSnapshotPush = true
activate()
return
}
do {
@@ -46,6 +67,11 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) {
context[FuelStore.favouritesKey] = favourites
}
// Do NOT send the full station dump to watchOS via WatchConnectivity.
// The UK dataset is megabytes large and exceeds application-context
// payload limits, which prevents any snapshot from arriving. The
// favourites payload already carries station snapshots + prices, so the
// watch can render favourites without the full list.
context[FuelStore.fuelKey] = FuelStore.loadSelectedFuel().rawValue
context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue
@@ -67,6 +93,9 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
}
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
Task { @MainActor in
self.activationRequested = false
}
if let error {
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
return
@@ -82,19 +111,26 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
nonisolated func sessionDidDeactivate(_ session: WCSession) {
Task { @MainActor in
WCSession.default.activate()
self.activationRequested = false
self.activate()
}
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
handleIncomingRefreshRequest(userInfo)
handleIncomingMessage(userInfo)
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
handleIncomingRefreshRequest(message)
handleIncomingMessage(message)
}
private nonisolated func handleIncomingRefreshRequest(_ payload: [String: Any]) {
private nonisolated func handleIncomingMessage(_ payload: [String: Any]) {
if payload["fuelboard.snapshotRequest"] as? String != nil {
Task { @MainActor in
self.pushSnapshot()
}
}
guard let raw = payload[FuelStore.watchRefreshRequestKey] as? String,
let timestamp = TimeInterval(raw) else { return }
let date = Date(timeIntervalSince1970: timestamp)
+18 -6
View File
@@ -66,14 +66,25 @@ struct WidgetMockScreen: View {
}
}
/// Real stations (loaded dump), cheapest-first for the chosen fuel — the
/// same selection the widget's own provider makes.
/// Real stations (loaded dump) for the widget's DEFAULT configuration —
/// Unleaded / Cheapest / 5 miles — with the SAME radius test the widget's
/// provider applies. Without it this preview happily listed the UK-wide
/// cheapest (Banbridge, 184 mi away) under "Cheapest fuel near you", which
/// is not what the widget ever shows.
private func mockEntry(family: WidgetFamily) -> FuelPriceWidgetEntryData {
let fuel = FuelType.e10
let all = FuelStore.loadStations()
let selling = all.filter { $0.prices[fuel] != nil }
.sorted { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
// 5 miles — the widget config's default Distance. WidgetDistance itself
// lives in the widget target, so the preview mirrors its default here.
let radiusKM = 5 * FuelSavingsCalculator.kilometresPerMile
let all = FuelStore.loadStations()
let selling = all.filter { station in
guard station.prices[fuel] != nil else { return false }
guard let location else { return true }
return FuelStore.isWithinDisplayRadius(station: station, radiusKM: radiusKM,
userLat: location.lat, userLng: location.lng)
}
.sorted { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
let count = family == .systemSmall ? 1 : 3
return FuelPriceWidgetEntryData(
date: Date(),
@@ -83,7 +94,8 @@ struct WidgetMockScreen: View {
isFavourites: false,
location: location,
locationSource: location != nil ? "live" : "none",
unit: FuelStore.loadDistanceUnit()
unit: FuelStore.loadDistanceUnit(),
bestValueResult: nil
)
}
}
+73 -7
View File
@@ -12,7 +12,7 @@
"TOP" = "TOP";
/* 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.";
"%@ %@ — tap a station for directions." = "%@ %@ — tap a station for directions.";
"Fuel type" = "Fuel type";
@@ -23,15 +23,25 @@
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
"All %lld stations shown" = "All %lld stations shown";
"Key" = "Key";
"Best value" = "Best value";
"Good price" = "Good price";
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
"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";
"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";
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
"Colours match each station's rating on the list" = "Colours match each station's rating on the list";
/* Star button in the More info sheet heading (accessibility labels) */
"Add to favourites" = "Add to favourites";
"Remove from favourites" = "Remove from favourites";
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
"How Best Value works" = "How Best Value works";
"Best Value compares each station with the closest 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.";
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
/* Favourites tab */
"No favourites yet" = "No favourites yet";
@@ -56,12 +66,24 @@
"Units" = "Units";
"Price display" = "Price display";
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
"Tap station" = "Tap station";
"Tap action" = "Tap action";
"Tapping a station either opens directions straight away or shows its details first." = "Tapping a station either opens directions straight away or shows its details first.";
"Open map" = "Open map";
"More info" = "More info";
"Name" = "Name";
"Address" = "Address";
"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";
"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)";
"Send plain test notification" = "Send plain test notification";
"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";
"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: %@";
@@ -119,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.";
"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.";
"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";
"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.";
@@ -139,6 +161,12 @@
"You" = "You";
"Done" = "Done";
"Directions" = "Directions";
"Report issue" = "Report issue";
"Membership required" = "Membership required";
"Report an error?" = "Report an error?";
"Cancel" = "Cancel";
"Report" = "Report";
"Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser." = "Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.";
/* Station row */
"best" = "best";
@@ -168,3 +196,41 @@
"Check your internet connection" = "Check your internet connection";
"Tap to try again" = "Tap to try again";
"Check your internet connection. Tap to try again" = "Check your internet connection. Tap to try again";
/* Alerts and favourites localisation pass */
"Cheapest favourite" = "Cheapest favourite";
"Check now" = "Check now";
"Price-drop alerts" = "Price-drop alerts";
"Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise." = "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.";
"Cheapest station" = "Cheapest station";
"Alert type" = "Alert type";
"Best Value" = "Best Value";
"Total" = "Total";
"Minimum saving" = "Minimum saving";
"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.";
"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.";
"When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour." = "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.";
"Proximity alerts" = "Proximity alerts";
"When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar." = "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.";
"Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)." = "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).";
"Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips." = "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.";
"City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town." = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.";
"Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns." = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.";
"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.";
"%@ — %@" = "%@ — %@";
/* 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.";
"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 updated" = "Price updated";
"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";
"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/DataRefreshMode.swift
@@ -0,0 +1 @@
../../../Shared/FuelPriceFreshness.swift
@@ -0,0 +1 @@
../../../Shared/FuelSavings.swift
@@ -0,0 +1 @@
../../../Shared/StationAddress.swift
@@ -0,0 +1,86 @@
import XCTest
@testable import FuelBoardShared
/// The Best Value announcement gate — shown to users who updated into the
/// feature after finishing onboarding, once per announcement version.
///
/// Every fact the rule depends on is injectable, so the whole truth table is
/// checked here instead of driving a simulator through four combinations (which
/// is how a rule like this quietly rots).
final class BestValueAnnouncementTests: XCTestCase {
/// Nothing set up: the case that should be announced to.
private var unconfigured: FuelSavingsSettings { FuelSavingsSettings.defaults }
/// Enabled with the default 45 MPG / £40, which is a valid configuration.
private var configured: FuelSavingsSettings {
var settings = FuelSavingsSettings.defaults
settings.enabled = true
return settings
}
func testAnnouncesToAnExistingUserWhoHasNotSeenIt() {
XCTAssertTrue(
FuelStore.shouldAnnounceBestValue(settings: unconfigured,
hasSeen: false,
onboardingDue: false,
isSuppressed: false),
"an existing user, nothing configured, announcement unseen: show it"
)
}
func testNeverAnnouncesTwice() {
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
hasSeen: true,
onboardingDue: false))
}
func testNeverWhileOnboardingIsDue() {
// A new install runs the full onboarding, which already contains this
// page — showing both would be the obvious first bug.
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
hasSeen: false,
onboardingDue: true))
}
func testNeverWhileOnboardingIsOnScreen() {
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
hasSeen: false,
onboardingDue: false,
isSuppressed: true))
}
func testNeverForSomebodyWhoAlreadyConfiguredIt() {
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: configured,
hasSeen: false,
onboardingDue: false),
"never announce a feature the user set up themselves")
}
func testStillAnnouncesWhenEnabledButNotActuallyConfigured() {
// Enabled with an out-of-range MPG is NOT configured, so the
// announcement still has something useful to offer.
var settings = FuelSavingsSettings.defaults
settings.enabled = true
settings.mpg = 2 // below the validity floor
XCTAssertTrue(FuelStore.shouldAnnounceBestValue(settings: settings,
hasSeen: false,
onboardingDue: false))
}
func testMarkingSeenIsWhatStopsTheNextLaunch() {
// The stored contract the gate reads.
FuelStore.markBestValueIntroSeen()
XCTAssertTrue(FuelStore.hasSeenBestValueIntro())
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
onboardingDue: false))
}
func testTheStoredValueIsTheAnnouncementVersionNotTheAppVersion() {
// Keyed on the announcement version so a bugfix release cannot
// re-announce anything. Bump `currentBestValueIntroVersion` only when
// Best Value changes in a way worth re-explaining — this assertion is
// the reminder that doing so re-shows the page to everyone.
XCTAssertEqual(FuelStore.currentBestValueIntroVersion, 1)
}
}
@@ -1,5 +1,7 @@
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
// FuelBoard shared-logic tests. Run from FuelBoardTests/ with `swift test`.
// Covers the pure logic that drives the app + widget: title sanitizer, price
@@ -209,10 +211,10 @@ final class DistanceTests: XCTestCase {
}
func testMilesConversion() {
// 5 miles ≈ 8.05 km (app uses × 1.60934)
XCTAssertEqual(5 * 1.60934, 8.0467, accuracy: 0.001)
// 262 km ≈ 162.8 miles (display uses × 0.621371)
XCTAssertEqual(262 * 0.621371, 162.8, accuracy: 0.1)
// 5 miles ≈ 8.05 km using the shared exact conversion
XCTAssertEqual(5 * FuelSavingsCalculator.kilometresPerMile, 8.04672, accuracy: 0.001)
// 262 km ≈ 162.8 miles using the shared exact conversion
XCTAssertEqual(262 / FuelSavingsCalculator.kilometresPerMile, 162.8, accuracy: 0.1)
}
}
@@ -265,14 +267,14 @@ final class FuelTypeLabelTests: XCTestCase {
}
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.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)
}
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")
}
}
@@ -398,7 +400,7 @@ final class AlertRadiusFollowTests: XCTestCase {
FuelStore.saveDistanceUnit(.miles)
FuelStore.saveStationLimit(5)
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: true, manualKM: 3)
XCTAssertEqual(eff, 5 * 1.60934, accuracy: 0.001)
XCTAssertEqual(eff, 5 * FuelSavingsCalculator.kilometresPerMile, accuracy: 0.001)
}
}
@@ -630,6 +632,9 @@ final class RoadDistanceCacheTests: XCTestCase {
// test or run would pollute these. Overwrite with an empty, far-away
// cache (source at (0,0)) so every test starts from a clean slate.
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
// "Accurate distances" is a persisted setting, so a previous run (or a
// test that turned it on) would leak into these. Off is the default.
FuelStore.setAccurateDistancesEnabled(false)
}
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
@@ -645,6 +650,7 @@ final class RoadDistanceCacheTests: XCTestCase {
}
func testRoadDistanceUsedWhenCachedNear() {
FuelStore.setAccurateDistancesEnabled(true)
let s = station("a", 51.5074, -0.1278)
// Cache a road distance of 3.2 km for this station from the user's fix.
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
@@ -654,6 +660,7 @@ final class RoadDistanceCacheTests: XCTestCase {
}
func testRoadDistanceNilWhenOriginFar() {
FuelStore.setAccurateDistancesEnabled(true)
let s = station("a", 51.5074, -0.1278)
// Cache built in London, but the user is now ~200 km away -> stale.
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278,
@@ -666,6 +673,7 @@ final class RoadDistanceCacheTests: XCTestCase {
}
func testRoadDistanceUsedForOtherStationNotFound() {
FuelStore.setAccurateDistancesEnabled(true)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
// A station that isn't in the cache falls back to straight-line.
@@ -675,6 +683,7 @@ final class RoadDistanceCacheTests: XCTestCase {
}
func testRoadDistanceNotServedWhenStationPinDiffers() {
FuelStore.setAccurateDistancesEnabled(true)
// Route a road distance to station "a" at pin P1.
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
@@ -686,6 +695,376 @@ final class RoadDistanceCacheTests: XCTestCase {
XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
"road value routed to the old pin leaked onto a different coordinate")
}
/// A partial cache legitimately mixes road and straight-line distances for
/// the SAVINGS maths (Best Value marks the estimated ones itself), but the
/// browse layer only ever serves a COMPLETE set — see the accurate-distances
/// tests below.
func testPartialRoadCacheCanMixRoadAndStraightLineDistances() {
FuelStore.setAccurateDistancesEnabled(true)
let routed = station("routed", 51.5074, -0.1278)
let estimated = station("estimated", 51.5100, -0.1300)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 3200, lat: 51.5074, lng: -0.1278, returnMeters: 3600)])
XCTAssertEqual(FuelStore.roadDistanceMeters(for: routed, userLat: 51.6, userLng: -0.1), 3200)
XCTAssertNil(FuelStore.roadDistanceMeters(for: estimated, userLat: 51.6, userLng: -0.1))
XCTAssertEqual(FuelStore.displayDistanceKM(station: routed, userLat: 51.6, userLng: -0.1), 3.2, accuracy: 0.0001)
XCTAssertEqual(FuelStore.displayDistanceKM(station: estimated, userLat: 51.6, userLng: -0.1),
estimated.distanceKM(to: 51.6, lng2: -0.1),
accuracy: 0.0001)
XCTAssertEqual(FuelStore.roadJourneyMeters(for: routed, userLat: 51.6, userLng: -0.1), 6800,
"outward and return road routes are combined once, not doubled")
XCTAssertTrue(FuelStore.hasMeasuredReturnRoadDistance(for: routed, userLat: 51.6, userLng: -0.1))
XCTAssertFalse(FuelStore.hasMeasuredReturnRoadDistance(for: estimated, userLat: 51.6, userLng: -0.1))
}
/// The reported bug: "10 miles cheapest" listed Jet Chidswell at 14.6 mi.
/// Its straight line was inside 10 miles, its routed distance was not — the
/// filter tested the former while the row displayed the latter.
func testRadiusFilterUsesTheDisplayedDistanceNotTheStraightLine() {
FuelStore.setAccurateDistancesEnabled(true)
let sourceLat = 51.6, sourceLng = -0.1
let far = station("far", 51.5074, -0.1278) // in by straight line…
let near = station("near", 51.5400, -0.1000)
FuelStore.saveRoadDistances(
sourceLat: sourceLat, sourceLng: sourceLng,
entries: ["far": .init(meters: 23_500, lat: 51.5074, lng: -0.1278, returnMeters: 23_500)])
let tenMilesKM = 10 * FuelSavingsCalculator.kilometresPerMile
XCTAssertLessThan(far.distanceKM(to: sourceLat, lng2: sourceLng), tenMilesKM,
"fixture: the straight line must sit INSIDE the 10-mile radius")
XCTAssertGreaterThan(FuelStore.displayDistanceKM(station: far, userLat: sourceLat, userLng: sourceLng),
tenMilesKM, "fixture: the routed distance must sit OUTSIDE it")
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: near, radiusKM: tenMilesKM,
userLat: sourceLat, userLng: sourceLng))
XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: far, radiusKM: tenMilesKM,
userLat: sourceLat, userLng: sourceLng),
"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.
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: far, radiusKM: 15 * FuelSavingsCalculator.kilometresPerMile,
userLat: sourceLat, userLng: sourceLng))
// …and an unknown station (no cached route) still tests its displayed
// straight line, so nothing changes for the bulk of the list.
let unrouted = station("unrouted", 51.5400, -0.1000)
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: unrouted, radiusKM: tenMilesKM,
userLat: sourceLat, userLng: sourceLng))
}
func testLegacyOneWayRoadCacheIsUsableButNotConfirmed() {
let legacy = station("legacy", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["legacy": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
XCTAssertEqual(FuelStore.roadJourneyMeters(for: legacy, userLat: 51.6, userLng: -0.1), 6400)
XCTAssertFalse(FuelStore.hasMeasuredReturnRoadDistance(for: legacy, userLat: 51.6, userLng: -0.1),
"legacy one-way cache entries infer the return leg and must remain estimated")
let diagnostics = FuelStore.roadDistanceDiagnostics(for: legacy, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(diagnostics.displaySource, .roadRoute)
XCTAssertEqual(diagnostics.outwardMeters, 3200)
XCTAssertNil(diagnostics.returnMeters)
XCTAssertEqual(diagnostics.totalJourneyMeters, 6400)
XCTAssertFalse(diagnostics.hasMeasuredReturn)
XCTAssertTrue(diagnostics.hasValidCacheEntry)
}
/// A station distance the user can see is marked "Approx." until a routed road
/// distance exists for it. Figure and marker come out of ONE cache read, so a
/// surface can never show a routed value labelled approximate (or the reverse).
func testStationDistanceIsMarkedApproximateUntilRoutingLands() {
FuelStore.setAccurateDistancesEnabled(true)
let routed = station("routed", 51.5074, -0.1278)
let unrouted = station("unrouted", 51.5200, -0.1400)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 3200, lat: 51.5074, lng: -0.1278, returnMeters: 3600)])
let confirmed = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
XCTAssertFalse(confirmed.isApproximate, "a cached road distance is not an approximation")
XCTAssertEqual(confirmed.km, 3.2, accuracy: 0.0001)
XCTAssertEqual(confirmed.label(unit: .miles), "2.0 mi")
XCTAssertEqual(confirmed.labelWithReturn(unit: .miles), "2.0 mi (4.2 mi return)")
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.8 km return)")
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles")
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.2 miles return")
let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1)
XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one")
XCTAssertEqual(approximate.km, unrouted.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
XCTAssertEqual(approximate.label(unit: .miles),
"Approx. " + DistanceUnit.miles.format(approximate.km))
XCTAssertEqual(approximate.labelWithReturn(unit: .miles),
"Approx. " + DistanceUnit.miles.format(approximate.km) + " (Approx. " + DistanceUnit.miles.format(approximate.km * 2) + " return)")
XCTAssertEqual(approximate.spokenLabel(unit: .kilometers),
"Approximately " + DistanceUnit.kilometers.spoken(approximate.km))
XCTAssertEqual(approximate.spokenLabelWithReturn(unit: .kilometers),
"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
/// route lands, "Approx." again once the origin moves out of tolerance.
func testStationDistanceLabelFollowsTheRoadCache() {
FuelStore.setAccurateDistancesEnabled(true)
let moved = station("moved", 51.5074, -0.1278)
XCTAssertTrue(FuelStore.stationDistanceDisplay(station: moved, userLat: 51.6, userLng: -0.1).isApproximate,
"nothing has been routed yet")
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["moved": .init(meters: 3200, lat: 51.5074, lng: -0.1278, returnMeters: 3600)])
XCTAssertFalse(FuelStore.stationDistanceDisplay(station: moved, userLat: 51.6, userLng: -0.1).isApproximate,
"route data landed, so the marker must drop without any other change")
XCTAssertTrue(FuelStore.stationDistanceDisplay(station: moved, userLat: 59.0, userLng: -0.1).isApproximate,
"a cache built from another origin must not confirm this one")
}
// MARK: - Display origin across processes (widget extension fix vs app fix)
/// The reported bug: the iPhone SE widget kept reading "Approx. 1.5 mi" for
/// the nearest stations while the app showed the SAME figure as a routed road
/// distance. The widget extension gets its own fix from its own
/// CLLocationManager and it is usually coarse — hundreds of metres from the
/// app's GPS fix that the routes were anchored to. That must not be read as
/// "the user moved away from the cache" and collapse every row.
func testCoarseWidgetFixDoesNotDowngradeRoutedDistances() {
FuelStore.setAccurateDistancesEnabled(true)
let appLat = 51.6, appLng = -0.1
FuelStore.saveLocation(lat: appLat, lng: appLng)
FuelStore.saveRoadDistances(sourceLat: appLat, sourceLng: appLng,
entries: ["a": .init(meters: 2344, lat: 51.5074, lng: -0.1278,
returnMeters: 2400)],
complete: true)
// The extension's own fix: ~700 m from the app's anchor, reported coarse.
let widgetLat = 51.6063
let offsetKM = station("anchor", appLat, appLng).distanceKM(to: widgetLat, lng2: appLng)
XCTAssertGreaterThan(offsetKM, FuelStore.roadDistanceOriginToleranceMeters / 1000,
"fixture: the widget fix must sit outside the origin ring")
XCTAssertLessThan(offsetKM, FuelStore.roadDistanceCoarseFixRadiusMeters / 1000,
"fixture: and inside the coarse-fix radius")
let origin = FuelStore.distanceDisplayOrigin(candidateLat: widgetLat, candidateLng: appLng,
candidateAccuracyMeters: 500)
XCTAssertTrue(origin.routed, "a coarse widget fix must not invalidate the routed set")
XCTAssertEqual(origin.source, "appFix")
XCTAssertEqual(origin.lat, appLat, accuracy: 0.000001)
XCTAssertEqual(origin.lng, appLng, accuracy: 0.000001)
let shown = FuelStore.stationDistanceDisplay(station: station("a", 51.5074, -0.1278),
userLat: origin.lat, userLng: origin.lng)
XCTAssertFalse(shown.isApproximate, "the nearest station must not read Approx. on the widget")
XCTAssertEqual(shown.km, 2.344, accuracy: 0.0001)
XCTAssertEqual(shown.label(unit: .miles), "1.5 mi")
}
/// Sub-kilometre disagreement is fix noise whatever the reported accuracy:
/// two honest fixes of the same place can differ that much, and it is far
/// too small to justify re-labelling a whole list.
func testSubKilometreDisagreementKeepsRoutedDistances() {
FuelStore.setAccurateDistancesEnabled(true)
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
complete: true)
let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.6063, candidateLng: -0.1,
candidateAccuracyMeters: 10)
XCTAssertTrue(origin.routed)
XCTAssertEqual(origin.source, "appFix")
}
/// A PRECISE fix far from the anchor IS the user having moved. The honest
/// answer stays the straight line, marked Approx., until the app re-routes
/// from the new position.
func testPreciseFixAwayFromTheAnchorStillFallsBackToApproximate() {
FuelStore.setAccurateDistancesEnabled(true)
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
complete: true)
let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.78, candidateLng: -0.1,
candidateAccuracyMeters: 10)
XCTAssertFalse(origin.routed)
XCTAssertEqual(origin.source, "straightLine")
XCTAssertEqual(origin.lat, 51.78, accuracy: 0.000001)
let shown = FuelStore.stationDistanceDisplay(station: station("a", 51.5074, -0.1278),
userLat: origin.lat, userLng: origin.lng)
XCTAssertTrue(shown.isApproximate)
}
/// The rescue only applies where the stored app fix actually backs the cache:
/// an app fix somewhere else cannot confirm routes for this neighbourhood.
func testCoarseFixIsNotRescuedWhenTheStoredAppFixIsElsewhere() {
FuelStore.setAccurateDistancesEnabled(true)
FuelStore.saveLocation(lat: 53.4808, lng: -2.2426) // app fix: Manchester
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
complete: true)
let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.6063, candidateLng: -0.1,
candidateAccuracyMeters: 500)
XCTAssertFalse(origin.routed)
XCTAssertEqual(origin.source, "straightLine")
}
/// Neither an incomplete fill nor the mode being off may resolve a routed
/// origin from any fix — same all-or-nothing rule as the rows.
func testRoutedOriginRequiresACompleteCacheAndTheModeOn() {
FuelStore.setAccurateDistancesEnabled(true)
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
complete: false)
XCTAssertFalse(FuelStore.distanceDisplayOrigin(candidateLat: 51.6, candidateLng: -0.1,
candidateAccuracyMeters: 10).routed,
"an incomplete fill must never serve routed distances")
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
complete: true)
FuelStore.setAccurateDistancesEnabled(false)
XCTAssertFalse(FuelStore.distanceDisplayOrigin(candidateLat: 51.6, candidateLng: -0.1,
candidateAccuracyMeters: 10).routed,
"with the mode off the browse layer never serves routes")
}
/// The widget must record its own fix under its own key: overwriting the
/// app's GPS fix with a Wi-Fi-grade one left the app seeded with the wrong
/// position and broke the anchor comparison above.
func testWidgetFixIsStoredSeparatelyFromTheAppFix() {
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
FuelStore.saveWidgetLocation(lat: 51.6063, lng: -0.1)
XCTAssertEqual(FuelStore.loadLocation()?.lat ?? 0, 51.6, accuracy: 0.000001,
"the app's GPS fix must survive a widget timeline")
XCTAssertEqual(FuelStore.loadWidgetLocation()?.coordinate.lat ?? 0, 51.6063, accuracy: 0.000001)
}
// MARK: - Accurate distances toggle (browse layer only)
/// The toggle must be opt-in: unset means off, so a fresh install makes no
/// route requests and every device shows the same straight-line figures.
func testAccurateDistancesDefaultsToOffWhenUnset() {
UserDefaults(suiteName: FuelStore.appGroupSuite)?.removeObject(forKey: FuelStore.accurateDistancesKey)
XCTAssertFalse(FuelStore.accurateDistancesEnabled)
}
/// Mode OFF: a COMPLETE routed cache exists and the browse layer must still
/// show the straight line, marked Approx., for every row — while the savings
/// maths keeps using the measured routes. That boundary is the feature.
func testAccurateDistancesOffIgnoresCachedRoutes() {
let routed = station("routed", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
lng: -0.1278, returnMeters: 23_500)],
complete: true)
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")
XCTAssertEqual(display.km, routed.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * FuelSavingsCalculator.kilometresPerMile,
userLat: 51.6, userLng: -0.1),
"with the mode off the radius is a straight-line radius, agreeing with the rows")
XCTAssertEqual(FuelStore.roadJourneyMeters(for: routed, userLat: 51.6, userLng: -0.1), 47_000,
"Best Value keeps its measured routes whichever way the toggle is set")
XCTAssertTrue(FuelStore.hasMeasuredReturnRoadDistance(for: routed, userLat: 51.6, userLng: -0.1))
}
/// An incomplete fill must never be half-served: the row that HAS a route
/// falls back too, so a list can't read "14.6 mi" beside rows that its radius
/// filter measured as straight lines.
func testAccurateDistancesOnWithIncompleteSetServesStraightLineForEveryRow() {
FuelStore.setAccurateDistancesEnabled(true)
let routed = station("routed", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
lng: -0.1278, returnMeters: 23_500)],
complete: false)
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
XCTAssertTrue(display.isApproximate)
XCTAssertEqual(display.km, routed.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
"a partial set must not be served, even for a station that has a route")
}
/// Mode ON with a complete set from a location we are standing at: routed
/// values, unmarked, and the radius means miles of driving.
func testAccurateDistancesOnServesRoutedValuesWhenCompleteAndNear() {
FuelStore.setAccurateDistancesEnabled(true)
let routed = station("routed", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
lng: -0.1278, returnMeters: 23_500)],
complete: true)
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
XCTAssertFalse(display.isApproximate)
XCTAssertEqual(display.km, 23.5, accuracy: 0.0001)
XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * FuelSavingsCalculator.kilometresPerMile,
userLat: 51.6, userLng: -0.1),
"in this mode a 10-mile radius is 10 miles of driving, matching the routed rows")
}
/// Same complete cache, but the user has since driven away: nothing is served
/// (every row falls back together) rather than showing routes measured from a
/// place the user has left. Walking back inside tolerance serves it again
/// with no refill.
func testAccurateDistancesOnFallsBackTogetherWhenTheAnchorMoves() {
FuelStore.setAccurateDistancesEnabled(true)
let routed = station("routed", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
lng: -0.1278, returnMeters: 23_500)],
complete: true)
let movedLat = 51.6 + 0.018 // ~2 km north, outside the 600 m tolerance
XCTAssertTrue(FuelStore.stationDistanceDisplay(station: routed, userLat: movedLat, userLng: -0.1).isApproximate)
XCTAssertFalse(FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1).isApproximate)
}
/// The toggle is a browse-layer switch, not a data switch: Best Value's
/// journey metres and its measured-return confidence must be identical in
/// both positions.
func testAccurateDistancesSavingsMathsIsUnaffectedByTheToggle() {
let routed = station("routed", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
lng: -0.1278, returnMeters: 23_600)],
complete: true)
for mode in [false, true] {
FuelStore.setAccurateDistancesEnabled(mode)
XCTAssertEqual(FuelStore.roadJourneyMeters(for: routed, userLat: 51.6, userLng: -0.1), 47_100)
XCTAssertTrue(FuelStore.hasMeasuredReturnRoadDistance(for: routed, userLat: 51.6, userLng: -0.1))
}
}
}
// MARK: - Install identity
@@ -1,5 +1,7 @@
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
// FuelHistory — price-history store for the Favourites Trends graph.
// Covers day math, slim day parsing, series building, delta rebasing and
@@ -0,0 +1,251 @@
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 testMoreInfoReportsOneStationPriceAge() {
let updated = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .e10, now: now)
XCTAssertEqual(assessment.updateAgeLabel, "28h ago")
XCTAssertEqual(assessment.source, .stationWide)
XCTAssertEqual(assessment.status, .fresh)
}
func testOlderFuelSpecificTimestampStillReportsItsOwnAgeOnce() {
let updated = now.addingTimeInterval(-10 * 86_400).timeIntervalSince1970
let priced = station(priceUpdatedByFuel: [.diesel: updated])
let assessment = FuelPriceFreshness.assessment(for: priced, fuel: .diesel, now: now)
XCTAssertEqual(assessment.updateAgeLabel, "10d ago")
XCTAssertEqual(assessment.source, .gradeSpecific)
}
func testMissingUpdateTimeReadsNotProvided() {
let assessment = FuelPriceFreshness.assessment(for: station(), fuel: .e10, now: now)
XCTAssertEqual(assessment.updateAgeLabel, "Not provided")
}
func testProvisionalSavingAddsNoSecondWarningLine() {
let recent = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
let stationWide = station(priceUpdated: recent)
let gradeSpecific = station(priceUpdatedByFuel: [.e10: recent])
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(
station: gradeSpecific, fuel: .e10, baseline: stationWide, now: now), .provisional)
XCTAssertNil(FuelPriceFreshness.comparisonWarning(
station: stationWide, fuel: .e10, baseline: stationWide, now: now))
XCTAssertNil(FuelPriceFreshness.comparisonWarning(
station: gradeSpecific, fuel: .e10, baseline: stationWide, now: now))
}
func testIneligibleComparisonStillExplainsItself() {
let old = now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
let stale = station(priceUpdatedByFuel: [.diesel: old])
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
station: stale, fuel: .diesel, baseline: stale, now: now),
"Price age unavailable or too old — not a Best Value recommendation.")
}
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)
}()
}
}
@@ -0,0 +1,538 @@
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
/// Worth the Drive calculation audit.
///
/// Written against the audit brief (30 MPG imperial, £60 fill-up, baseline
/// Rontec Shelf at 171.9p, 0.4 miles). The brief's three observed results are
/// reproduced here exactly, so any future change to the formula breaks a test
/// instead of silently shifting the numbers on screen.
///
/// Deterministic journey distances only — never live MKDirections responses.
final class FuelSavingsCalculationAuditTests: XCTestCase {
private func station(_ id: String, price: Double?) -> FuelStation {
FuelStation(
id: id,
name: id,
brand: "X",
address: "",
postcode: "",
lat: 51.5,
lng: -0.1,
prices: price.map { [FuelType.e10: $0] } ?? [:],
priceUpdated: nil
)
}
/// Journeys are OUT AND BACK (the app's convention): the brief's baseline is
/// 0.4 miles away, so its journey is 0.8 miles, and "extra driving" of N
/// miles means a candidate journey of 0.8 + N.
private func journey(_ miles: Double, basis: FuelSavingsDistanceBasis = .roadRoute) -> FuelSavingsJourney {
FuelSavingsJourney(miles: miles, basis: basis)
}
private let baselineJourneyMiles = 0.8
private func briefResult(candidatePrice: Double, extraMiles: Double) throws -> FuelSavingsResult {
try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: candidatePrice,
baselineJourneyMiles: baselineJourneyMiles,
candidateJourneyMiles: baselineJourneyMiles + extraMiles
)
}
// 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() {
XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609)
XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784,
"a US gallon must never be used: it would understate every travel cost")
// 30 miles at 30 MPG burns exactly one imperial gallon.
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 30, mpg: 30), 4.54609, accuracy: 1e-12)
// 100 miles at 50 MPG.
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 100, mpg: 50), 9.09218, accuracy: 1e-12)
// Zero distance burns nothing, and the relationship is linear in distance.
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 0, mpg: 30), 0, accuracy: 1e-12)
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 60, mpg: 30),
2 * FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 30, mpg: 30),
accuracy: 1e-12)
}
func testMileAndKilometreFactorsAreTheExactOnes() {
XCTAssertEqual(FuelSavingsCalculator.kilometresPerMile, 1.609344)
XCTAssertEqual(FuelSavingsCalculator.metresPerMile, 1609.344)
XCTAssertEqual(FuelSavingsCalculator.metresPerMile,
FuelSavingsCalculator.kilometresPerMile * 1000,
accuracy: 1e-9,
"the metre and kilometre factors must describe the same mile")
}
func testDistanceLabelUsesTheSameConversionAsTheModel() {
// One mile, shown in kilometres, goes through the single factor.
XCTAssertEqual(
FuelSavingsFormat.distance(1, unit: .kilometers),
DistanceUnit.kilometers.format(FuelSavingsCalculator.kilometresPerMile)
)
XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344))
}
// MARK: - 2. Candidate-priced fill-up
func testPoundsFillUsesCandidatePriceForLitresAndGrossSaving() throws {
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0.879,
candidateJourneyMiles: 8.306
)
XCTAssertEqual(result.fillLitres, 60 / 1.699, accuracy: 1e-9)
XCTAssertEqual(result.grossSavingGBP, (60 / 1.699) * 0.02, accuracy: 1e-9)
XCTAssertEqual(FuelSavingsFormat.money(result.grossSavingGBP), "£0.71")
XCTAssertEqual(result.extraFuelLitres, 1.1254603476666667, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 1.9121571306856667, accuracy: 1e-9)
XCTAssertEqual(result.netSavingGBP, -1.2058593084372853, accuracy: 1e-9)
XCTAssertEqual(result.headline(), "Costs £1.21 more")
}
func testPoundsExamplesScaleFromThirtyToOneHundredPounds() throws {
for (budget, expectedLitres, expectedGross) in [
(30.0, 17.657445556209535, 0.3531489111241907),
(60.0, 35.31489111241907, 0.7062978222483814),
(100.0, 58.85815185403179, 1.1771630370806357)
] {
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: budget, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0,
candidateJourneyMiles: 0
)
XCTAssertEqual(result.fillLitres, expectedLitres, accuracy: 1e-8)
XCTAssertEqual(result.grossSavingGBP, expectedGross, accuracy: 1e-8)
}
}
func testCandidatePriceControlsLitresWhileBaselinePriceOnlyChangesSaving() throws {
let amount = FuelSavingsFillAmount(value: 60, unit: .pounds)
let first = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
let differentBaseline = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 179.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
let differentCandidate = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 179.9,
candidatePricePencePerLitre: 159.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
XCTAssertEqual(first.fillLitres, differentBaseline.fillLitres, accuracy: 1e-12)
XCTAssertNotEqual(first.grossSavingGBP, differentBaseline.grossSavingGBP)
XCTAssertNotEqual(differentBaseline.fillLitres, differentCandidate.fillLitres)
}
// MARK: - 3/5. The three stations from the brief
func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws {
let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0)
XCTAssertEqual(result.fillLitres, 35.108250, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 0.351083, accuracy: 1e-6, "£60 buys 35.11 L at the candidate price")
XCTAssertEqual(result.extraDistanceMiles, 2.0, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -0.166869, accuracy: 1e-6)
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "Costs £0.17 more")
}
/// The brief recorded "£0.18 worse off" for this row against a displayed
/// "2.0 miles" of extra driving; the brief also warns that the displayed
/// mileage is rounded. This pins that explanation instead of treating it as
/// a calculation bug: at an extra distance the caption still shows as
/// "2.0 mi" (2.00-2.05), the headline reads £0.17-£0.18, and the difference
/// is entirely the rounding of the distance, not of the money.
func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws {
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(),
"Costs £0.17 more")
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(),
"Costs £0.18 more")
// 2.05 mi shows as "2.0 mi" at one decimal place — the same caption.
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles),
"2.0 mi extra return travel")
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.00, unit: .miles),
"2.0 mi extra return travel")
}
func testBriefCaseMFGHareAndHoundsIsWorseOff() throws {
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
XCTAssertEqual(result.fillLitres, 34.702140, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, -0.347021, accuracy: 1e-6, "£60 buys 34.70 L at the candidate price")
XCTAssertEqual(result.extraDistanceMiles, 2.8, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "Costs £1.08 more")
}
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
let result = try briefResult(candidatePrice: 171.9, extraMiles: 2.8)
XCTAssertEqual(result.grossSavingGBP, 0, accuracy: 1e-9, "same pump price saves nothing at the pump")
XCTAssertEqual(result.extraDrivingCostGBP, 0.729375, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6,
"with no price difference the whole result is the extra driving")
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "Costs £0.73 more")
}
// MARK: - 5/6. Classification, tolerance and rounding
func testSamePriceAndSameDistanceIsBreakEven() throws {
let result = try briefResult(candidatePrice: 171.9, extraMiles: 0)
XCTAssertEqual(result.grossSavingGBP, 0, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0, accuracy: 1e-9)
XCTAssertEqual(result.netSavingGBP, 0, accuracy: 1e-9)
XCTAssertEqual(result.status, .breakEven)
XCTAssertEqual(result.headline(), "Break even")
}
func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws {
// 171.9p vs 170.9p over a 1.23 mi delta: the small saving remains within tolerance.
let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23)
XCTAssertEqual(result.netSavingGBP, 0.03254252417853143, accuracy: 1e-9)
XCTAssertEqual(result.status, .breakEven, "±5p reads as break even rather than a meaningless £0.03")
XCTAssertEqual(result.headline(), "Break even")
}
func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws {
let net = -0.166869
// A tolerance wider than the loss → break even; narrower → worse off.
let lenient = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 170.9,
baselineJourneyMiles: baselineJourneyMiles,
candidateJourneyMiles: baselineJourneyMiles + 2.0,
breakEvenToleranceGBP: 0.17
)
XCTAssertEqual(lenient.netSavingGBP, net, accuracy: 1e-6)
XCTAssertEqual(lenient.status, .breakEven)
let strict = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 170.9,
baselineJourneyMiles: baselineJourneyMiles,
candidateJourneyMiles: baselineJourneyMiles + 2.0,
breakEvenToleranceGBP: 0.16
)
XCTAssertEqual(strict.status, .notWorthTheDrive)
}
func testMoneyIsRoundedOnlyForDisplay() throws {
// Full precision retained internally…
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded")
// …and rounded to two places only when shown.
XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08")
XCTAssertEqual(FuelSavingsFormat.money(0.729375), "£0.73")
XCTAssertEqual(FuelSavingsFormat.money(0.1749), "£0.17")
XCTAssertEqual(FuelSavingsFormat.money(0.176), "£0.18")
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() {
// 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.
func ranked(_ id: String, net: Double) -> FuelSavingsRankedStation {
FuelSavingsRankedStation(
stationID: id,
result: .available(FuelSavingsResult(
fillLitres: 34.9,
grossSavingGBP: 0,
extraDrivingCostGBP: abs(net),
extraDistanceMiles: 2.0,
netSavingGBP: net,
status: .notWorthTheDrive
)),
distanceMiles: 2.8,
pricePencePerLitre: 171.9
)
}
let worse = ranked("worse", net: -0.1751)
let lessWorse = ranked("less-worse", net: -0.1749)
XCTAssertEqual(FuelSavingsFormat.money(abs(worse.result.result!.netSavingGBP)), "£0.18")
XCTAssertEqual(FuelSavingsFormat.money(abs(lessWorse.result.result!.netSavingGBP)), "£0.17")
let sorted = FuelSavingsCalculator.sortedForWorthTheDrive([worse, lessWorse])
XCTAssertEqual(sorted.first?.stationID, "less-worse",
"the smaller loss must rank first even when the money rounds to the same figure")
}
// MARK: - 3. Distances and travel cost
func testCheaperFuelFurtherAwayCanStillBeWorthTheDrive() throws {
// 145.9p nearby vs 138.9p five miles further out, £40 fill, 45 MPG.
let result = try FuelSavingsCalculator.calculate(
mpg: 45,
amount: FuelSavingsFillAmount(value: 40, unit: .pounds),
baselinePricePencePerLitre: 145.9,
candidatePricePencePerLitre: 138.9,
baselineJourneyMiles: 2.0,
candidateJourneyMiles: 7.0
)
XCTAssertEqual(result.fillLitres, 28.797696, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 2.015839, accuracy: 1e-6)
XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, 1.314226, accuracy: 1e-6)
XCTAssertEqual(result.status, .worthTheDrive)
XCTAssertEqual(result.headline(), "Save £1.31")
}
func testNearerCandidateIsNeverChargedExtraTravel() throws {
// The baseline is the nearest priced station, so this should not happen
// in the app — but the clamp must hold if it ever does.
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 2.0,
candidateJourneyMiles: 1.0
)
XCTAssertEqual(result.extraDistanceMiles, 0, "a shorter journey is never a negative extra distance")
XCTAssertEqual(result.extraDrivingCostGBP, 0, accuracy: 1e-12)
XCTAssertEqual(result.netSavingGBP, result.grossSavingGBP, accuracy: 1e-12)
}
func testDifferentMPGValuesScaleOnlyTheTravelCost() throws {
let sixty = try FuelSavingsCalculator.calculate(
mpg: 60,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 172.9,
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(sixty.grossSavingGBP, -0.347021, accuracy: 1e-6, "MPG must not touch the pump saving")
XCTAssertEqual(sixty.extraDrivingCostGBP, 0.366809, accuracy: 1e-6)
XCTAssertEqual(sixty.netSavingGBP, -0.713830, accuracy: 1e-6)
let fortyFive = try FuelSavingsCalculator.calculate(
mpg: 45,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 172.9,
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6)
XCTAssertEqual(fortyFive.netSavingGBP, -0.836100, accuracy: 1e-6)
}
func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws {
let forty = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 40, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 172.9,
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(forty.fillLitres, 23.134760, accuracy: 1e-6, "£40 buys 23.13 L at the candidate price")
XCTAssertEqual(forty.grossSavingGBP, -0.231348, accuracy: 1e-6)
XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent")
XCTAssertEqual(forty.netSavingGBP, -0.964965, accuracy: 1e-6)
let ninety = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 90, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 172.9,
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(ninety.fillLitres, 52.053210, accuracy: 1e-6)
XCTAssertEqual(ninety.grossSavingGBP, -0.520532, accuracy: 1e-6)
XCTAssertEqual(ninety.netSavingGBP, -1.254150, accuracy: 1e-6)
}
func testFillUpInLitresIsFixedRegardlessOfPrice() throws {
// 40 L is 40 L: no baseline price is involved in the quantity.
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 40, unit: .litres),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 165.9,
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(result.fillLitres, 40, accuracy: 1e-9)
XCTAssertEqual(result.grossSavingGBP, 40 * 0.06, accuracy: 1e-9)
}
// MARK: - 7. Failures never invent a saving
func testUnavailableRouteProducesNoSavingAtAll() {
let baseline = station("baseline", price: 171.9)
let candidate = station("candidate", price: 170.9)
let settings = FuelSavingsSettings(
enabled: true,
mpg: 30,
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
)
let noRoute = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
journey: nil,
baselineJourney: journey(0.8)
)
XCTAssertEqual(noRoute, .unavailable("Distance unavailable"))
let noBaselineRoute = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
journey: journey(2.8),
baselineJourney: nil
)
XCTAssertEqual(noBaselineRoute, .unavailable("No comparison station"))
// With both legs present it produces the saving — no silent zero.
let both = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
journey: journey(2.8),
baselineJourney: journey(0.8)
)
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.166869, accuracy: 1e-6)
}
func testMixedRouteBasisIsReportedAsAnEstimate() {
let baseline = station("baseline", price: 171.9)
let candidate = station("candidate", price: 170.9)
let settings = FuelSavingsSettings(
enabled: true,
mpg: 30,
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
)
let mixed = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
journey: journey(2.8, basis: .straightLine),
baselineJourney: journey(0.8, basis: .roadRoute)
)
XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine,
"one unmeasured leg makes the whole estimate an estimate")
XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated)
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. Costs £0.17 more")
}
func testBaselineStationIsLabelledClosestStationNotBreakEven() {
let baseline = station("baseline", price: 171.9)
let settings = FuelSavingsSettings(
enabled: true,
mpg: 30,
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
)
let result = FuelSavingsEstimator.availability(
station: baseline,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
journey: journey(0.8),
baselineJourney: journey(0.8)
)?.result
XCTAssertEqual(result?.status, .baseline)
XCTAssertEqual(result?.netSavingGBP, 0)
XCTAssertEqual(result?.headline(), "Closest eligible station")
}
}
@@ -0,0 +1,833 @@
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
/// Ranking rules for the Worth the Drive sort.
///
/// Regression: the sort used to rank only a shortlist of the nearest stations
/// whose ROAD ROUTES had been measured, and everything else was listed as
/// unavailable. Cheaper stations a few miles further out — the exact stations
/// the sort exists to surface — were dropped from the top of the list, and
/// stations on later pages showed no savings at all.
final class FuelSavingsEstimateTests: XCTestCase {
private let settings = FuelSavingsSettings(
enabled: true,
mpg: 45,
preferredAmount: FuelSavingsFillAmount(value: 40, unit: .pounds)
)
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
private func station(_ id: String,
price: Double?,
lat: Double = 51.5,
lng: Double = -0.1) -> FuelStation {
FuelStation(
id: id,
name: id,
brand: "X",
address: "",
postcode: "",
lat: lat,
lng: lng,
prices: price.map { [FuelType.e10: $0] } ?? [:],
priceUpdated: nil
)
}
/// Straight-line out-and-back estimate, as the app builds it when no road
/// route has been measured yet.
private func straightLine(_ miles: Double) -> FuelSavingsJourney {
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() {
// 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.
let baseline = station("baseline", price: 145.9)
let candidate = station("cheaper-3.5", price: 138.9, lat: 51.55)
let filler = (0..<14).map { station("filler-\($0)", price: 150 + Double($0)) }
let pool = [baseline, candidate] + filler
let ranked = FuelSavingsEstimator.ranked(
pool,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: amount,
journeyMiles: { s in
switch s.id {
case "baseline": return self.straightLine(2.0)
case "cheaper-3.5": return self.straightLine(7.0)
default: return self.straightLine(20.0)
}
}
)
XCTAssertEqual(ranked.count, pool.count)
XCTAssertEqual(ranked.first?.id, "cheaper-3.5",
"a cheaper station 3.5 miles away must rank above the nearest station")
let result = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: amount,
journey: straightLine(7.0),
baselineJourney: straightLine(2.0)
)?.result
XCTAssertNotNil(result, "the station must show a saving, not an unavailable state")
XCTAssertGreaterThan(result?.netSavingGBP ?? -1, 0)
XCTAssertEqual(result?.status, .worthTheDrive)
XCTAssertEqual(result?.distanceBasis, .straightLine,
"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() {
// 40 stations, but only the first one has a measured road route — the
// old implementation capped the whole ranking to 12 shortlisted
// stations and returned "Outside route shortlist" for the rest.
let baseline = station("baseline", price: 145.9)
let pool = [baseline] + (0..<39).map { station("s\($0)", price: 135 + Double($0)) }
let ranked = FuelSavingsEstimator.ranked(
pool,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: amount,
journeyMiles: { s in
s.id == "baseline"
? FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
: self.straightLine(2.0 + (Double(s.id.dropFirst()) ?? 6))
}
)
XCTAssertEqual(ranked.count, pool.count)
for station in pool where station.id != "baseline" {
let availability = FuelSavingsEstimator.availability(
station: station,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: amount,
journey: straightLine(10.0),
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
)
XCTAssertNotNil(availability?.result,
"\(station.id) should carry an estimated saving, not an unavailable state")
}
}
func testMeasuredRoadRoutesAreReportedAsRoadRouteBasis() {
let baseline = station("baseline", price: 145.9)
let candidate = station("candidate", price: 138.9)
let result = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: amount,
journey: FuelSavingsJourney(miles: 7.0, basis: .roadRoute),
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
)?.result
XCTAssertEqual(result?.distanceBasis, .roadRoute)
XCTAssertFalse(FuelSavingsDistanceBasis.roadRoute.isEstimate)
}
func testMixedBasisIsReportedAsAnEstimate() {
let baseline = station("baseline", price: 145.9)
let candidate = station("candidate", price: 138.9)
// Baseline measured, candidate not: the comparison is only as good as
// its weakest leg, so it must be labelled an estimate.
let result = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: amount,
journey: straightLine(7.0),
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
)?.result
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() {
let geographicallyNearest = station("geographically-nearest", price: 145.9)
let roadNearest = station("road-nearest", price: 146.9)
let noPrice = station("no-price", price: nil)
let baseline = FuelSavingsEstimator.baselineStation(
[geographicallyNearest, roadNearest, noPrice],
fuel: .e10,
journeyMiles: { station in
switch station.id {
case "geographically-nearest": return FuelSavingsJourney(miles: 8.0, basis: .roadRoute)
case "road-nearest": return FuelSavingsJourney(miles: 4.0, basis: .roadRoute)
case "no-price": return FuelSavingsJourney(miles: 1.0, basis: .roadRoute)
default: return nil
}
}
)
XCTAssertEqual(baseline?.id, "road-nearest",
"the Best Value baseline must be the shortest usable journey, not a separate straight-line/display-distance choice")
}
func testBaselineStationFallsBackThenChangesWhenRoadRoutesArrive() {
let stationA = station("a", price: 145.9)
let stationB = station("b", price: 146.9)
let noRoadRoutes = FuelSavingsEstimator.baselineStation(
[stationA, stationB],
fuel: .e10,
journeyMiles: { station in
switch station.id {
case "a": return FuelSavingsJourney(miles: 2.0, basis: .straightLine)
case "b": return FuelSavingsJourney(miles: 3.0, basis: .straightLine)
default: return nil
}
}
)
XCTAssertEqual(noRoadRoutes?.id, "a")
let partialRoadRoutes = FuelSavingsEstimator.baselineStation(
[stationA, stationB],
fuel: .e10,
journeyMiles: { station in
switch station.id {
case "a": return FuelSavingsJourney(miles: 5.0, basis: .roadRoute)
case "b": return FuelSavingsJourney(miles: 3.0, basis: .straightLine)
default: return nil
}
}
)
XCTAssertEqual(partialRoadRoutes?.id, "b",
"baseline must be recalculated from the latest journey values when routes arrive")
}
func testRankingToleratesDuplicateStationIDs() {
let baseline = station("baseline", price: 145.9)
let duplicateA = station("duplicate", price: 138.9, lat: 51.55)
let duplicateB = station("duplicate", price: 139.9, lat: 51.56)
let pool = [baseline, duplicateA, duplicateB]
let ranked = FuelSavingsEstimator.ranked(
pool,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: amount,
journeyMiles: { s in
s.id == "baseline" ? self.straightLine(2.0) : self.straightLine(7.0)
}
)
XCTAssertEqual(ranked.count, pool.count)
XCTAssertEqual(ranked.filter { $0.id == "duplicate" }.count, 2)
}
func testStationWithoutAPriceIsUnavailable() {
let baseline = station("baseline", price: 145.9)
let noPrice = station("no-price", price: nil)
let availability = FuelSavingsEstimator.availability(
station: noPrice,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: amount,
journey: straightLine(4.0),
baselineJourney: straightLine(2.0)
)
XCTAssertEqual(availability, .unavailable("No price"))
}
func testFeatureOffProducesNoSavingsRow() {
let baseline = station("baseline", price: 145.9)
let candidate = station("candidate", price: 138.9)
let availability = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: .defaults,
amount: amount,
journey: straightLine(4.0),
baselineJourney: straightLine(2.0)
)
XCTAssertNil(availability)
}
// MARK: - Required wording and baseline labelling
private var briefSettings: FuelSavingsSettings {
FuelSavingsSettings(enabled: true,
mpg: 30,
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds))
}
private var briefAmount: FuelSavingsFillAmount {
FuelSavingsFillAmount(value: 60, unit: .pounds)
}
/// The scenario from the brief: 30 MPG, £60 fill, baseline Rontec Shelf
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi).
///
/// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is
/// correct, not a calculation bug: £60 buys 35.74 L at 167.9p, so the
/// cheaper pump saves 35.74 × 4p = £1.43, while the extra 6.8 return miles
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.30.
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
let baseline = station("rontec-shelf", price: 171.9)
let candidate = station("shell-whetley-hill", price: 167.9)
let availability = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: briefSettings,
amount: briefAmount,
journey: straightLine(7.6), // 3.8 mi out and back
baselineJourney: straightLine(0.8) // 0.4 mi out and back
)
guard let result = availability?.result else {
return XCTFail("expected an estimate, got \(String(describing: availability))")
}
XCTAssertEqual(result.fillLitres, 35.736, accuracy: 0.005,
"the £60 budget buys litres at the candidate pump price")
XCTAssertEqual(result.grossSavingGBP, 1.429, accuracy: 0.005)
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
"only the return-trip DELTA counts — the shared journey is never counted twice")
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
XCTAssertEqual(result.netSavingGBP, -0.301, accuracy: 0.005)
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.journeyConfidence, .estimated)
XCTAssertEqual(result.headline(compact: true), "Approx. Costs £0.30 more")
}
func testMeasuredRoadRoutesDropTheEstimateMarker() {
let baseline = station("baseline", price: 171.9)
let candidate = station("candidate", price: 167.9)
let availability = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: briefSettings,
amount: briefAmount,
journey: FuelSavingsJourney(miles: 7.6, basis: .roadRoute),
baselineJourney: FuelSavingsJourney(miles: 0.8, basis: .roadRoute)
)
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed)
XCTAssertEqual(availability?.result?.headline(compact: true), "Costs £0.30 more")
XCTAssertEqual(availability?.result?.headline(), "Costs £0.30 more")
}
func testBaselineRowIsLabelledClosestStation() {
let baseline = station("nearest", price: 171.9)
let availability = FuelSavingsEstimator.availability(
station: baseline,
fuel: .e10,
baseline: baseline,
settings: briefSettings,
amount: briefAmount,
journey: straightLine(0.8),
baselineJourney: straightLine(0.8)
)
XCTAssertEqual(availability?.result?.status, .baseline)
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest eligible station")
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest eligible station")
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
}
func testPositiveResultHeadlineSaysSaveAfterTravel() {
// 4p cheaper AND almost the same distance, so the saving survives travel.
let baseline = station("baseline", price: 160.0)
let candidate = station("candidate", price: 150.0)
let availability = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: briefSettings,
amount: briefAmount,
journey: straightLine(6.4),
baselineJourney: straightLine(6.0)
)
guard let result = availability?.result else {
return XCTFail("expected an estimate, got \(String(describing: availability))")
}
XCTAssertEqual(result.status, .worthTheDrive)
// £60 buys 40 L at 150p; 10p/litre cheaper saves £4.00; 0.4 extra
// return miles cost ~£0.09. Net ≈ £3.91.
XCTAssertEqual(result.netSavingGBP, 3.909, accuracy: 0.005)
XCTAssertEqual(result.headline(compact: true), "Approx. Save £3.91")
}
func testNeutralResultHeadlineIsBreakEven() {
// 0.1p cheaper over the same distance: inside the break-even tolerance.
let baseline = station("baseline", price: 150.0)
let candidate = station("candidate", price: 149.9)
let availability = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: briefSettings,
amount: briefAmount,
journey: straightLine(6.0),
baselineJourney: straightLine(6.0)
)
XCTAssertEqual(availability?.result?.status, .breakEven)
XCTAssertEqual(availability?.result?.headline(compact: true), "Break even")
XCTAssertEqual(availability?.result?.headline(), "Break even")
}
/// The exact strings the brief asks for.
func testHeadlineWordingMatchesTheBrief() {
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest eligible station")
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
let saving = FuelSavingsResult(fillLitres: 34.9,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 0.88,
extraDistanceMiles: 3.5,
netSavingGBP: 0.52,
status: .worthTheDrive,
distanceBasis: .roadRoute)
XCTAssertEqual(saving.headline(), "Save £0.52")
let loss = FuelSavingsResult(fillLitres: 34.9,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 1.71,
extraDistanceMiles: 6.8,
netSavingGBP: -0.31,
status: .notWorthTheDrive,
distanceBasis: .roadRoute)
XCTAssertEqual(loss.headline(), "Costs £0.31 more")
}
/// The sort must order by NET saving, not pump price: a pricier station can
/// 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() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
func pricedStation(_ id: String, price: Double) -> FuelStation {
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(
[baseline, cheapButFar, pricierButNear],
fuel: .e10,
baseline: baseline,
settings: briefSettings,
amount: briefAmount,
journeyMiles: { s in
switch s.id {
case "baseline": return self.straightLine(0.8)
case "cheap-but-far": return self.straightLine(7.6)
default: return self.straightLine(1.2)
}
}, now: now
)
XCTAssertEqual(ranked.first?.id, "pricier-but-near",
"net saving decides the order, not the pump price")
XCTAssertEqual(ranked.last?.id, "cheap-but-far")
}
// 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
/// figure is always miles, and converting by the current unit instead would
/// leave a km user's figure unlabelled and 1.6× too large.
func testExtraTravelLabelFollowsTheDisplayUnit() {
XCTAssertEqual(
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 19.9, unit: .miles),
"19.9 mi extra return travel"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 19.9, unit: .kilometers),
"32.0 km extra return travel"
)
XCTAssertNil(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 0, unit: .miles),
"the baseline row has no extra travel to report")
// The same conversion backs the detail sheet's extra-distance figure,
// which used to be hardcoded miles ("%.1f miles") for km users too.
XCTAssertEqual(FuelSavingsFormat.distance(19.9, unit: .miles), "19.9 mi")
XCTAssertEqual(FuelSavingsFormat.distance(19.9, unit: .kilometers), "32.0 km")
}
/// The baseline capsule has to say what it IS: it previously repeated the
/// status word above it ("Closest eligible station" twice) and never explained that
/// the savings are measured against it.
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
let baseline = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 0,
extraDrivingCostGBP: 0,
extraDistanceMiles: 0,
netSavingGBP: 0,
status: .baseline
)
XCTAssertEqual(baseline.capsuleHeadline(), "Closest eligible station")
let saving = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 2.40,
extraDrivingCostGBP: 1.40,
extraDistanceMiles: 5.2,
netSavingGBP: 1.00,
status: .worthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(saving.capsuleHeadline(), "Approx. Save £1.00")
let worse = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 1.70,
extraDistanceMiles: 6.7,
netSavingGBP: -0.31,
status: .notWorthTheDrive
)
XCTAssertEqual(worse.capsuleHeadline(), "Costs £0.31 more")
}
// MARK: - Estimate wording (Approx. labels)
/// The estimate marker is the WORD "Approx.", never a "~", and it is applied to
/// both directions of the money result.
func testEstimatedHeadlinesUseTheWordApproxForBothDirections() {
let saving = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 4.00,
extraDrivingCostGBP: 2.75,
extraDistanceMiles: 6.4,
netSavingGBP: 1.25,
status: .worthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(saving.headline(), "Approx. Save £1.25")
let loss = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 2.17,
extraDistanceMiles: 6.4,
netSavingGBP: -0.77,
status: .notWorthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(loss.headline(), "Approx. Costs £0.77 more")
}
/// A confirmed road-route comparison must never carry the marker — in either
/// direction, and not as a stray tilde either.
func testConfirmedHeadlinesCarryNoApproxMarker() {
let saving = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 4.00,
extraDrivingCostGBP: 2.75,
extraDistanceMiles: 6.4,
netSavingGBP: 1.25,
status: .worthTheDrive,
distanceBasis: .roadRoute,
journeyConfidence: .confirmed
)
XCTAssertEqual(saving.headline(), "Save £1.25")
let loss = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 2.17,
extraDistanceMiles: 6.4,
netSavingGBP: -0.77,
status: .notWorthTheDrive,
distanceBasis: .roadRoute,
journeyConfidence: .confirmed
)
XCTAssertEqual(loss.headline(), "Costs £0.77 more")
for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] {
XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate")
XCTAssertFalse(headline.contains("~"), "\"\(headline)\" must use a word, not a symbol")
}
}
/// The extra-travel caption names the RETURN journey in both states. The
/// estimated caption repeats "Approx." deliberately: it describes a different
/// value from the verdict above it.
func testExtraTravelNamesTheReturnJourney() {
XCTAssertEqual(
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 6.4, unit: .miles),
"6.4 mi extra return travel"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
"Approx. 6.4 mi extra return travel"
)
}
/// VoiceOver gets words, not abbreviations: "Approx." → "Approximately" and
/// "mi"/"km" → "miles"/"kilometres".
func testSpokenLabelsSpellOutTheAbbreviations() {
let estimated = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 2.17,
extraDistanceMiles: 6.4,
netSavingGBP: -0.77,
status: .notWorthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(estimated.spokenHeadline(), "Approximately Costs £0.77 more")
XCTAssertEqual(
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
"Approximately 6.4 miles extra return travel"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 19.9, unit: .kilometers),
"32.0 kilometres extra return travel"
)
let confirmed = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 2.17,
extraDistanceMiles: 6.4,
netSavingGBP: -0.77,
status: .notWorthTheDrive,
distanceBasis: .roadRoute,
journeyConfidence: .confirmed
)
XCTAssertEqual(confirmed.spokenHeadline(), confirmed.headline(),
"a confirmed comparison has no abbreviation to spell out")
}
// MARK: - Slider bounds (Settings and the in-list control share these)
/// Both fill-up controls are slider-only now, so every position the slider
/// can reach must be a usable amount — otherwise the control could sit on a
/// figure the calculator rejects.
func testFillAmountSliderBoundsAreAlwaysValidAmounts() {
for unit in FuelSavingsFillUnit.allCases {
let range = FuelSavingsCalculator.fillAmountSliderRange(for: unit)
let step = FuelSavingsCalculator.fillAmountSliderStep(for: unit)
XCTAssertLessThan(range.lowerBound, range.upperBound, "\(unit) range must be usable")
XCTAssertGreaterThan(step, 0, "\(unit) step must advance")
XCTAssertGreaterThan(range.lowerBound, 0, "\(unit) must never offer a zero fill")
XCTAssertLessThanOrEqual(range.upperBound,
FuelSavingsCalculator.maximumFillAmount(for: unit),
"\(unit) slider must stay inside the valid ceiling")
for position in [range.lowerBound, range.upperBound, (range.lowerBound + range.upperBound) / 2] {
XCTAssertTrue(FuelSavingsFillAmount(value: position, unit: unit).isValid,
"\(position) \(unit) must be a valid amount")
}
}
}
func testMPGSliderBoundsAreInsideTheValidMPGRange() {
let range = FuelSavingsCalculator.mpgSliderRange
XCTAssertLessThan(range.lowerBound, range.upperBound)
XCTAssertGreaterThan(FuelSavingsCalculator.mpgSliderStep, 0)
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(range.lowerBound))
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(range.upperBound))
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(45), "a typical UK figure must be reachable")
}
}
@@ -0,0 +1,37 @@
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
final class FuelSavingsRoutingCacheTests: XCTestCase {
override func setUp() {
super.setUp()
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
}
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: 140], priceUpdated: nil)
}
func testJourneyDistanceUsesSeparateOutAndBackRoadRoutes() {
let s = station("a", 51.5074, -0.1278)
FuelStore.saveRoadDistances(
sourceLat: 51.6,
sourceLng: -0.1,
entries: ["a": CachedRoadDistance(meters: 3_200, lat: 51.5074, lng: -0.1278, returnMeters: 3_700)]
)
XCTAssertEqual(FuelStore.roadJourneyMeters(for: s, userLat: 51.6, userLng: -0.1), 6_900)
}
func testLegacyOneWayCacheDoublesForJourneyDistance() {
let s = station("a", 51.5074, -0.1278)
FuelStore.saveRoadDistances(
sourceLat: 51.6,
sourceLng: -0.1,
entries: ["a": CachedRoadDistance(meters: 3_200, lat: 51.5074, lng: -0.1278)]
)
XCTAssertEqual(FuelStore.roadJourneyMeters(for: s, userLat: 51.6, userLng: -0.1), 6_400)
}
}
@@ -0,0 +1,165 @@
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
final class FuelSavingsCalculatorTests: XCTestCase {
func testUKMPGConversionUsesImperialGallons() {
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001)
}
func testPoundsBudgetConvertsToLitresUsingGivenPrice() throws {
let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160)
XCTAssertEqual(litres, 25.0, accuracy: 0.0001)
}
func testLitresAmountPassesThrough() throws {
let amount = FuelSavingsFillAmount(value: 35, unit: .litres)
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160)
XCTAssertEqual(litres, 35, accuracy: 0.0001)
}
func testNetSavingSubtractsAdditionalDrivingFuelCost() throws {
let result = try FuelSavingsCalculator.calculate(
mpg: 45,
amount: FuelSavingsFillAmount(value: 40, unit: .pounds),
baselinePricePencePerLitre: 160,
candidatePricePencePerLitre: 150,
baselineJourneyMiles: 2,
candidateJourneyMiles: 8
)
XCTAssertEqual(result.fillLitres, 26.6667, accuracy: 0.0001)
XCTAssertEqual(result.grossSavingGBP, 2.6667, accuracy: 0.0001)
XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001)
XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001)
XCTAssertEqual(result.netSavingGBP, 1.7574, accuracy: 0.0001)
XCTAssertEqual(result.status, .worthTheDrive)
}
func testNegativeAndBreakEvenOutcomes() throws {
let negative = try FuelSavingsCalculator.calculate(
mpg: 45,
amount: FuelSavingsFillAmount(value: 10, unit: .litres),
baselinePricePencePerLitre: 150,
candidatePricePencePerLitre: 149,
baselineJourneyMiles: 1,
candidateJourneyMiles: 20
)
XCTAssertEqual(negative.status, .notWorthTheDrive)
XCTAssertLessThan(negative.netSavingGBP, 0)
let even = try FuelSavingsCalculator.calculate(
mpg: 45,
amount: FuelSavingsFillAmount(value: 10, unit: .litres),
baselinePricePencePerLitre: 150,
candidatePricePencePerLitre: 150,
baselineJourneyMiles: 2,
candidateJourneyMiles: 2
)
XCTAssertEqual(even.status, .breakEven)
}
func testInvalidInputsRejected() {
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), pricePencePerLitre: 0))
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
}
func testSwitchingPoundsToLitresPreservesPhysicalQuantity() throws {
let pounds = FuelSavingsFillAmount(value: 40, unit: .pounds)
let litres = try FuelSavingsCalculator.convert(amount: pounds, to: .litres, referencePricePencePerLitre: 160)
XCTAssertEqual(litres.unit, .litres)
XCTAssertEqual(litres.value, 25, accuracy: 0.0001)
let back = try FuelSavingsCalculator.convert(amount: litres, to: .pounds, referencePricePencePerLitre: 160)
XCTAssertEqual(back.value, 40, accuracy: 0.0001)
}
func testSortingPlacesUnavailableAfterSavingsAndBreaksTies() {
let a = FuelSavingsRankedStation(stationID: "a", result: .available(.init(fillLitres: 25, grossSavingGBP: 2, extraDrivingCostGBP: 1, extraDistanceMiles: 3, netSavingGBP: 1, status: .worthTheDrive)), distanceMiles: 5, pricePencePerLitre: 150)
let b = FuelSavingsRankedStation(stationID: "b", result: .available(.init(fillLitres: 25, grossSavingGBP: 3, extraDrivingCostGBP: 1, extraDistanceMiles: 2, netSavingGBP: 2, status: .worthTheDrive)), distanceMiles: 8, pricePencePerLitre: 149)
let c = FuelSavingsRankedStation(stationID: "c", result: .unavailable("No route"), distanceMiles: 1, pricePencePerLitre: 140)
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([a, c, b]).map(\.stationID), ["b", "a", "c"])
}
func testBestValueAlertCandidateRequiresConfiguredSavingsAndThreshold() {
let stations = [
station("near", lat: 0, lng: 0, price: 160),
station("cheap", lat: 0, lng: 0, price: 150),
]
let journeys = ["near": 2.0, "cheap": 4.0]
let off = FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
XCTAssertNil(FuelSavingsCalculator.bestValueAlertCandidate(
stations: stations,
fuel: .e10,
settings: off,
amount: off.preferredAmount,
minimumNetSavingGBP: 1.0,
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
))
let on = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
XCTAssertNil(FuelSavingsCalculator.bestValueAlertCandidate(
stations: stations,
fuel: .e10,
settings: on,
amount: on.preferredAmount,
minimumNetSavingGBP: 5.0,
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
))
}
func testBestValueAlertCandidatePicksHighestNetSavingAboveThreshold() throws {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let stations = [
station("baseline", lat: 0, lng: 0, price: 160, priceUpdated: now.timeIntervalSince1970),
station("small", lat: 0, lng: 0, price: 154, priceUpdated: now.timeIntervalSince1970),
station("best", lat: 0, lng: 0, price: 148, priceUpdated: now.timeIntervalSince1970),
]
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 candidate = FuelSavingsCalculator.bestValueAlertCandidate(
stations: stations,
fuel: .e10,
settings: settings,
amount: settings.preferredAmount,
minimumNetSavingGBP: 1.0,
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } },
now: now
)
XCTAssertEqual(candidate?.station.id, "best")
XCTAssertEqual(candidate?.result.status, .worthTheDrive)
XCTAssertGreaterThan(candidate?.result.netSavingGBP ?? 0, 1.0)
}
func testBestValueAlertCandidateBreaksTiesByDistanceThenPrice() throws {
let first = FuelSavingsResult(fillLitres: 25, grossSavingGBP: 3, extraDrivingCostGBP: 1, extraDistanceMiles: 2, netSavingGBP: 2, status: .worthTheDrive)
let nearer = FuelSavingsRankedStation(stationID: "nearer", result: .available(first), distanceMiles: 4, pricePencePerLitre: 149)
let farther = FuelSavingsRankedStation(stationID: "farther", result: .available(first), distanceMiles: 8, pricePencePerLitre: 145)
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer")
}
private func station(_ id: String, lat: Double, lng: Double, price: Double,
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 {
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() {
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: 45, preferredAmount: .init(value: 0, unit: .pounds)).isConfigured)
XCTAssertTrue(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
}
}
@@ -0,0 +1,69 @@
import XCTest
@testable import FuelBoardShared
/// Shared payload storage must survive an install whose provisioning profile
/// has NO `com.apple.security.application-groups` entitlement (free/personal
/// team and SideStore installs) — the exact situation this test runner is in.
///
/// Regression guard: `appGroupFileURL` used to return nil without a group
/// container, so every save silently failed and every load returned empty. That
/// left `loadStations()` permanently empty (letting the freshness gate skip the
/// fetch for ever, i.e. a blank station list) and re-downloaded the ~2.8 MB
/// dump on every refresh. Storage now falls back to the app's own container.
final class FuelStoreFileCacheTests: XCTestCase {
func testSharedFileURLExistsWithoutAppGroupContainer() {
XCTAssertNotNil(
FuelStore.appGroupFileURL("fuelboard.test.probe.json"),
"storage must resolve to a writable location even when the App Group container is unprovisioned"
)
}
func testSharedFileRoundTripsWithoutAppGroupContainer() throws {
let name = "fuelboard.test.roundtrip.json"
let payload = Data("hello-cache".utf8)
XCTAssertTrue(
FuelStore.saveAppGroupFile(payload, name: name),
"save must succeed without an App Group container"
)
XCTAssertEqual(FuelStore.loadAppGroupFile(name), payload)
if let url = FuelStore.appGroupFileURL(name) {
try? FileManager.default.removeItem(at: url)
}
}
/// The dump cache is what stops a ~2.8 MB re-download on every refresh, so
/// it must round-trip too — not just the station list.
func testDumpCacheRoundTripsWithoutAppGroupContainer() {
let day = "2026-01-01"
let data = Data(repeating: 7, count: 4096)
MirrorFuelProvider.saveDumpCache(day: day, data: data)
let cache = MirrorFuelProvider.loadDumpCache()
XCTAssertEqual(cache?.day, day, "cached dump day must survive a round-trip")
XCTAssertEqual(cache?.data, data, "cached dump payload must survive a round-trip")
if let url = FuelStore.appGroupFileURL(MirrorFuelProvider.liveDumpCacheFileName) {
try? FileManager.default.removeItem(at: url)
}
}
/// The road-distance cache is memoized in memory because re-reading it per
/// station (it lives in the keychain) stalled the main thread past the iOS
/// launch watchdog. A save must therefore invalidate the memo — a stale
/// in-memory cache would show wrong distances after a route refresh.
func testRoadDistanceMemoRefreshesAfterSave() {
FuelStore.saveRoadDistances(sourceLat: 51.0, sourceLng: -1.0, entries: [:])
XCTAssertEqual(FuelStore.loadRoadDistances()?.sourceLat, 51.0)
FuelStore.saveRoadDistances(sourceLat: 52.0, sourceLng: -2.0, entries: [:])
XCTAssertEqual(
FuelStore.loadRoadDistances()?.sourceLat, 52.0,
"load must reflect the newest save, not a stale memoized cache"
)
}
}
@@ -1,23 +1,13 @@
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
/// P0 live chain: GitHub mirror FIRST everywhere → [dev-only LAN relay] →
/// P0 live chain: public GitHub mirror first everywhere →
/// (app: bundled dump in ContentView's catch, widget: cache → placeholder).
/// The relay is NOT part of the consumer chain — it only joins behind the
/// hidden dev flag `fuelboard.relayFallback`.
/// No LAN relay fallback is compiled into the app binary.
final class LiveChainTests: XCTestCase {
override func setUp() {
super.setUp()
FuelBeacon.isEnabled = false // chain tests must not fire real pings
FuelStore.saveRelayFallbackEnabled(false) // dev flag off by default
}
override func tearDown() {
FuelBeacon.isEnabled = true
super.tearDown()
}
// MARK: Fixtures
private final class StubProvider: FuelPriceProviding {
@@ -43,86 +33,56 @@ final class LiveChainTests: XCTestCase {
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil)
}
// MARK: Full path (app refresh): GitHub first
// MARK: Full path (app refresh): GitHub mirror only
func testFullPathPrefersMirror() async throws {
func testFullPathUsesMirror() async throws {
let mirror = StubProvider(result: [fixtureStation()])
let relay = StubProvider(error: FuelProviderError.relayUnavailable)
let chain = LiveChainProvider(mirror: mirror, relay: relay)
let chain = LiveChainProvider(mirror: mirror)
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
XCTAssertEqual(stations.count, 1)
XCTAssertEqual(mirror.callCount, 1, "full path must try the GitHub mirror first")
XCTAssertEqual(relay.callCount, 0, "relay must not run when the mirror serves")
XCTAssertEqual(mirror.callCount, 1, "full path must use the public GitHub mirror")
XCTAssertEqual(LiveChainProvider.lastSource, "github")
}
func testFullPathRelaySkippedWhenDisabled() async {
func testFullPathPropagatesMirrorFailure() async {
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
let relay = StubProvider(result: [fixtureStation("relay")])
let chain = LiveChainProvider(mirror: mirror, relay: relay)
let chain = LiveChainProvider(mirror: mirror)
do {
_ = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
XCTFail("chain must throw when the mirror fails and the relay flag is off")
XCTFail("chain must throw when the public mirror fails; callers decide cache/bundled fallback")
} catch {
XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay")
XCTAssertEqual(mirror.callCount, 1)
}
}
func testFullPathRelayFallbackWhenDevFlagOn() async throws {
FuelStore.saveRelayFallbackEnabled(true)
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
let relay = StubProvider(result: [fixtureStation("relay")])
let chain = LiveChainProvider(mirror: mirror, relay: relay)
// MARK: Focused path (alert checks + widget): GitHub mirror only
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
XCTAssertEqual(stations.first?.id, "relay")
XCTAssertEqual(relay.callCount, 1, "dev flag on → relay fallback serves")
XCTAssertEqual(LiveChainProvider.lastSource, "relay")
}
// MARK: Focused path (alert checks + widget): GitHub first too
func testFocusedPathPrefersMirror() async throws {
func testFocusedPathUsesMirror() async throws {
let mirror = StubProvider(result: [fixtureStation("mirror")])
let relay = StubProvider(result: [fixtureStation("relay")])
let chain = LiveChainProvider(mirror: mirror, relay: relay)
let chain = LiveChainProvider(mirror: mirror)
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
XCTAssertEqual(stations.first?.id, "mirror", "focused path must prefer the mirror — the relay is unreachable off-LAN")
XCTAssertEqual(relay.callCount, 0)
XCTAssertEqual(stations.first?.id, "mirror", "focused path must use the public GitHub mirror")
XCTAssertEqual(mirror.callCount, 1)
XCTAssertEqual(LiveChainProvider.lastSource, "github")
}
func testFocusedPathRelaySkippedWhenDisabled() async {
func testFocusedPathPropagatesMirrorFailure() async {
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
let relay = StubProvider(result: [fixtureStation("relay")])
let chain = LiveChainProvider(mirror: mirror, relay: relay)
let chain = LiveChainProvider(mirror: mirror)
do {
_ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
XCTFail("focused path must throw when the mirror fails and the relay flag is off")
XCTFail("focused path must throw when the public mirror fails")
} catch {
XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay")
XCTAssertEqual(mirror.callCount, 1)
}
}
func testFocusedPathRelayFallbackWhenDevFlagOn() async throws {
FuelStore.saveRelayFallbackEnabled(true)
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
let relay = StubProvider(result: [fixtureStation("relay")])
let chain = LiveChainProvider(mirror: mirror, relay: relay)
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
XCTAssertEqual(stations.first?.id, "relay")
XCTAssertEqual(relay.callCount, 1)
}
// MARK: Mirror focused projection (replaces the relay's server-side radius)
func testMirrorFocusedFiltersByFuelRadiusDistanceAndLimit() {
@@ -163,24 +123,4 @@ final class LiveChainTests: XCTestCase {
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil))
}
// MARK: Beacon
func testBeaconURLCarriesAppAttribution() {
guard let url = FuelBeacon.beaconURL(source: "github", n: 8022) else {
return XCTFail("beacon URL must build")
}
XCTAssertTrue(url.absoluteString.contains("api/v1/widget-diag"))
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertTrue(items.contains(URLQueryItem(name: "intent", value: "app-live")))
XCTAssertTrue(items.contains(URLQueryItem(name: "source", value: "github")))
XCTAssertTrue(items.contains(URLQueryItem(name: "n", value: "8022")))
}
func testBeaconGatedByDevFlag() {
FuelBeacon.isEnabled = true
FuelStore.saveRelayFallbackEnabled(false)
XCTAssertFalse(FuelBeacon.shouldFire, "consumers: beacon must never fire (no local-network attempt)")
FuelStore.saveRelayFallbackEnabled(true)
XCTAssertTrue(FuelBeacon.shouldFire, "dev flag on: beacon fires for attribution")
}
}
}
@@ -0,0 +1,32 @@
import XCTest
@testable import FuelBoardShared
/// User-facing copy: the Fuel Savings SORT is called "Best Value" (renamed on the
/// user's instruction from "Worth the Drive"), while the feature that backs it
/// keeps the name "Worth the Drive" in Settings and on the sheet. Pin both so
/// neither drifts by accident.
final class SortModeNamesTests: XCTestCase {
func testSortModeNames() {
XCTAssertEqual(SortMode.cheapest.displayName, "Cheapest")
XCTAssertEqual(SortMode.closest.displayName, "Closest")
XCTAssertEqual(SortMode.worthTheDrive.displayName, "Best Value")
}
func testRawValueIsUnchangedSoStoredPreferencesKeepDecoding() {
// The label is copy; the raw value is persisted state. Renaming the one
// must never move the other.
XCTAssertEqual(SortMode.worthTheDrive.rawValue, "worthTheDrive")
XCTAssertEqual(SortMode(rawValue: "worthTheDrive"), .worthTheDrive)
XCTAssertEqual(SortMode.allCases.map(\.rawValue), ["cheapest", "closest", "worthTheDrive"])
}
func testTheVerdictWordingIsUnchanged() {
// The feature and its sort are both called "Best Value" in user-facing
// copy now (Settings toggle + narrative, the sheet's section header).
// FuelSavingsStatus's own wording describes a VERDICT rather than the
// feature, and is not shown in the UI, so it keeps the drive phrasing.
XCTAssertEqual(FuelSavingsStatus.worthTheDrive.displayName, "Worth the drive")
XCTAssertEqual(FuelSavingsStatus.notWorthTheDrive.displayName, "Not worth the drive")
}
}
@@ -0,0 +1,160 @@
import XCTest
@testable import FuelBoardShared
/// The row and the detail sheet show the station name themselves, so the address
/// line must not repeat it — and must not eat a genuine street address either.
/// Every address below is a real string from the Fuel Finder dump.
final class StationAddressLineTests: XCTestCase {
// MARK: - The name is dropped when the address repeats it
func testAddressStartingWithTheExactName() {
XCTAssertEqual(
StationAddressLine.display(
address: "RING ROAD SERVICE STATION OSMASTON PARK ROAD, DERBY, DE23 8WL",
postcode: "DE23 8WL",
name: "Ring Road Service Station"),
"OSMASTON PARK ROAD, DERBY, DE23 8WL")
}
func testNameHeldInTheFirstSegment() {
// The real "Jet Chidswell" row, which read as its own name twice.
XCTAssertEqual(
StationAddressLine.display(
address: "CHIDSWELL SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW",
postcode: "WF12 7QW",
name: "Jet Chidswell"),
"LEEDS ROAD, DEWSBURY, WF12 7QW")
}
func testNameMatchIgnoresSpacingAndPunctuation() {
XCTAssertEqual(
StationAddressLine.display(
address: "TESCO EXTRA, LEEDS ROAD, LS1 1AA",
postcode: "LS1 1AA",
name: "Tesco Extra"),
"LEEDS ROAD, LS1 1AA")
}
// MARK: - A real street address is left alone
func testStreetAddressWithoutTheNameIsUntouched() {
XCTAssertEqual(
StationAddressLine.display(
address: "66B Drumconvis Rd Coagh",
postcode: "",
name: "Corner filling station"),
"66B Drumconvis Rd Coagh")
}
func testHouseNumberInTheFirstSegmentBlocksTheStrip() {
// "41 ANNACLOY RD ANNACLOY" is a street address, not a restatement of
// the name — stripping it would delete where the station actually is.
XCTAssertEqual(
StationAddressLine.display(
address: "41 ANNACLOY RD ANNACLOY",
postcode: "",
name: "ANNACLOY FILLING STATION"),
"41 ANNACLOY RD ANNACLOY")
}
func testUnrelatedNameIsKept() {
XCTAssertEqual(
StationAddressLine.display(
address: "BALDWINS GATE FILLING STATION BALDWINS GATE, NEWCASTLE, ST5 5DA",
postcode: "ST5 5DA",
name: "Galaxy Overseas Limited"),
"BALDWINS GATE FILLING STATION BALDWINS GATE, NEWCASTLE, ST5 5DA")
}
func testDigitFreeSegmentThatIsNotTheNameIsKept() {
XCTAssertEqual(
StationAddressLine.display(
address: "HIGH STREET, LEEDS, LS1 1AA",
postcode: "LS1 1AA",
name: "Texaco Garage"),
"HIGH STREET, LEEDS, LS1 1AA")
}
func testPartialWordPrefixNeverCutsIntoAWord() {
// "Jet" matches the first letters of "JETSON"; the address is not the name.
XCTAssertEqual(
StationAddressLine.display(
address: "JETSON FILLING STATION, LEEDS ROAD",
postcode: "",
name: "Jet"),
"JETSON FILLING STATION, LEEDS ROAD")
}
// MARK: - The postcode is written exactly once
func testPostcodeAtTheEndOfTheAddressIsNotRepeated() {
let line = StationAddressLine.display(
address: "5-15 High Street, LEEDS, LS1 1AA",
postcode: "LS1 1AA",
name: "Boundary Service station Gulf")
XCTAssertEqual(line, "5-15 High Street, LEEDS, LS1 1AA")
XCTAssertEqual(line.components(separatedBy: "LS1 1AA").count - 1, 1)
}
func testPostcodeIsAppendedWhenTheAddressLacksIt() {
XCTAssertEqual(
StationAddressLine.display(
address: "7 STEAM MILLS, CINDERFORD",
postcode: "GL14 3JD",
name: "Steam Mills Garage"),
"7 STEAM MILLS, CINDERFORD, GL14 3JD")
}
func testEmptyAddressFallsBackToThePostcode() {
XCTAssertEqual(
StationAddressLine.display(address: " ", postcode: "LS1 1AA", name: "Anything"),
"LS1 1AA")
}
func testBlankPostcodeLeavesJustTheAddress() {
XCTAssertEqual(
StationAddressLine.display(address: "76-78 BURN ROAD", postcode: "", name: "SOLO 24 HR"),
"76-78 BURN ROAD")
}
func testWhitespaceIsCollapsed() {
XCTAssertEqual(
StationAddressLine.display(
address: "41 ANNACLOY RD ANNACLOY",
postcode: "",
name: "Somewhere Else"),
"41 ANNACLOY RD ANNACLOY")
}
// MARK: - Every station in the real dump produces a sane line
func testRealDumpNeverProducesAnEmptyOrNameLedLine() throws {
// Guards the rule against the shapes in the live data rather than the
// handful of examples above.
let stations: [(name: String, address: String, postcode: String)] = [
("Ravenspoint Trading Ltd", "RAVENSPOINT CENTRE KERSHADER, ISLE OF LEWIS, HS2 9QA", "HS2 9QA"),
("Galaxy Overseas Limited", "BALDWINS GATE FILLING STATION BALDWINS GATE, NEWCASTLE, ST5 5DA", "ST5 5DA"),
("Jet Chidswell", "CHIDSWELL SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW", "WF12 7QW"),
("Corner filling station", "66B Drumconvis Rd Coagh", ""),
("Belcoo Service Station", "3, LATTONE ROAD, BELCOO EAST, BELCOO, ENNISKILLEN, BT93 5AQ", "BT93 5AQ"),
("ANNACLOY FILLING STATION", "41 ANNACLOY RD ANNACLOY", ""),
("Boundary Service station Gulf", "5-15 High Street", ""),
("SOLO 24 HR", "76-78 BURN ROAD", ""),
("Steam Mills Garage", "7 STEAM MILLS, CINDERFORD, GL14 3JD", "GL14 3JD"),
("Carnagh House Valero", "191 A Castleblayney Road Keady", ""),
]
for station in stations {
let line = StationAddressLine.display(
address: station.address, postcode: station.postcode, name: station.name)
XCTAssertFalse(line.isEmpty, "empty line for \(station.name)")
XCTAssertFalse(line.hasPrefix(" "), "leading space for \(station.name)")
XCTAssertFalse(line.hasSuffix(","), "trailing comma for \(station.name)")
XCTAssertFalse(line.contains(" "), "double space for \(station.name)")
// Never the name alone as the first words — the header has it.
let key = station.name.lowercased().filter { $0.isLetter || $0.isNumber }
let lineKey = line.lowercased().filter { $0.isLetter || $0.isNumber }
XCTAssertFalse(lineKey.hasPrefix(key), "\(station.name) still leads its own address: \(line)")
}
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"identifier" : "fuelboard-local-tips",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "fuelboard-tip099",
"localizations" : [
{
"description" : "Just enough to keep things moving.",
"displayName" : "Splash & Dash",
"locale" : "en_GB"
}
],
"productID" : "com.apt.fuelboard.tip099",
"referenceName" : "FuelBoard Tip 099 - Splash & Dash",
"type" : "Consumable"
},
{
"displayPrice" : "2.99",
"familyShareable" : false,
"internalID" : "fuelboard-tip299",
"localizations" : [
{
"description" : "A generous top-up for development.",
"displayName" : "Half a Tank",
"locale" : "en_GB"
}
],
"productID" : "com.apt.fuelboard.tip299",
"referenceName" : "FuelBoard Tip 299 - Half a Tank",
"type" : "Consumable"
},
{
"displayPrice" : "4.99",
"familyShareable" : false,
"internalID" : "fuelboard-tip499",
"localizations" : [
{
"description" : "Keeping the app on the road.",
"displayName" : "Fill 'Er Up",
"locale" : "en_GB"
}
],
"productID" : "com.apt.fuelboard.tip499",
"referenceName" : "FuelBoard Tip 499 - Fill 'Er Up",
"type" : "Consumable"
}
],
"settings" : {
},
"subscriptionGroups" : [
],
"version" : {
"major" : 2,
"minor" : 0
}
}
@@ -1,13 +1,156 @@
{
"images" : [
"images": [
{
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
"filename": "Watch-NotificationCenter-48.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "24x24",
"subtype": "38mm"
},
{
"filename": "Watch-NotificationCenter-55.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "27.5x27.5",
"subtype": "42mm"
},
{
"filename": "Watch-NotificationCenter-66.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "33x33",
"subtype": "45mm"
},
{
"filename": "Watch-CompanionSettings-58.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "2x",
"size": "29x29"
},
{
"filename": "Watch-CompanionSettings-87.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "3x",
"size": "29x29"
},
{
"filename": "Watch-AppLauncher-80.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "40x40",
"subtype": "38mm"
},
{
"filename": "Watch-AppLauncher-88.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "44x44",
"subtype": "40mm"
},
{
"filename": "Watch-AppLauncher-92.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "46x46",
"subtype": "41mm"
},
{
"filename": "Watch-AppLauncher-100.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "50x50",
"subtype": "44mm"
},
{
"filename": "Watch-AppLauncher-102.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "51x51",
"subtype": "45mm"
},
{
"filename": "Watch-AppLauncher-108.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "54x54",
"subtype": "49mm"
},
{
"filename": "Watch-QuickLook-172.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "86x86",
"subtype": "38mm"
},
{
"filename": "Watch-QuickLook-196.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "98x98",
"subtype": "42mm"
},
{
"filename": "Watch-QuickLook-216.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "108x108",
"subtype": "44mm"
},
{
"filename": "Watch-QuickLook-234.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "117x117",
"subtype": "45mm"
},
{
"filename": "Watch-QuickLook-258.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "129x129",
"subtype": "49mm"
},
{
"filename": "Watch-Marketing-1024.png",
"idiom": "watch-marketing",
"scale": "1x",
"size": "1024x1024"
},
{
"filename": "Watch-LongLook-88.png",
"idiom": "watch",
"role": "longLook",
"scale": "2x",
"size": "44x44",
"subtype": "42mm"
},
{
"filename": "Watch-LongLook-100.png",
"idiom": "watch",
"role": "longLook",
"scale": "2x",
"size": "50x50",
"subtype": "44mm"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Some files were not shown because too many files have changed in this diff Show More