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.
- 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.
- 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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
`-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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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".
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).
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.