The welcome page's green-gradient circle + SF symbol is replaced by the
app icon itself (AppIconArtwork imageset mirroring AppIcon.png), clipped
to the standard iOS rounded-rect corner radius (21pt at 96pt — ~22%),
so onboarding matches the Home Screen icon.
New Icon Composer project artwork (gas-station vector, layered + background)
rendered via ictool at 1024pt: Default / Dark / TintedLight renditions
replacing all three asset-catalog slots.
The export zip contained both a pre-rendered appiconset AND the
Fuelboard.icon composer project; the pre-baked PNGs were a different
(flatter) render. Re-rendered the .icon bundle with ictool (Xcode 26
Icon Composer executable) — Default/Dark/TintedLight renditions at
1024pt — and replaced the three asset-catalog slots.
Replaces the single 1024px icon with the three-variant set (universal,
dark-luminosity, tinted) generated for FuelBoard; Contents.json updated to
the iOS-only slots.
ROOT CAUSE (proven by A/B test on-device): the small widget's custom
intent (FuelBoardSmallWidgetConfigurationIntent with favouriteFuel +
favourite AppEntity params) fails fresh-widget default-config resolution
at the system level — the whole default config fails, no timeline is
ever requested, makeEntry never runs, widget stays on the skeleton.
A minimal small widget sharing the MEDIUM intent (fuel/sort/distance
only) populated immediately in the same extension on the same device.
Neither removing defaultResult() (4d35b08) nor the
@IntentParameterDependency (8a9e775) helped — the extra entity params
themselves break resolution.
FIX: FuelPriceSmallWidget now uses FuelBoardWidgetConfigurationIntent
exactly like the working test widget. FuelBoardSmallWidgetConfiguration
Intent, FavouriteFuel(+Query), WidgetFavourite(+Query) deleted; the
pinned-favourite picker is gone (approved tradeoff). Favourites sort
still works on both faces via the list branch — the small face renders
the cheapest favourite of the chosen fuel. Beacon tag distinguishes the
small widget in diagnostics; test widget removed, gallery order
restored (small, medium, live activity).
The single-slot beacon couldn't distinguish small vs medium (last writer
wins). Beacon keychain service now embeds the intent type name so small
and medium never overwrite each other; the relay GET now carries the
intent type + entry state as query params so the access log shows exactly
which widget kind ran makeEntry, when, and with what. Diagnostics screen
shows SMALL and MEDIUM beacons side by side — a missing SMALL beacon
after adding the small widget proves its timeline never reaches the
provider (system-level config resolution failure).
The small-widget skeleton survived four resolution fixes; instrument the
widget instead of guessing. Every makeEntry now writes its entry state
(intent type, location source, station count, fuel, sort, favourites flag,
first station) to a keychain beacon (app-readable) and fire-and-forgets a
GET to the relay's /api/v1/widget-diag so its access log records whether a
widget timeline actually runs in the extension and what it produced.
Settings → Debug gains a Widget Diagnostics section: the keychain beacon +
the installed-widget inventory from WidgetCenter. Relay gains the
/api/v1/widget-diag route (204, log-only).
Granted label now reads just 'Connected' with the green tick and uses
the same .subheadline confirmation styling as the Location and
Notifications pages.
Replaying onboarding showed a visible probe on the 4th page because
iOS exposes no status API for the Local Network permission — the
connection attempt IS the only detector. Location and Notifications
query their status at init, so their ticks are already known when the
page appears; Local Network could only be known by probing.
Now, when onboarding is re-opened (already completed), a pre-flight
probe runs silently at open: granted connects in ms, denied reports
.waiting/.localNetworkDenied immediately, and a plain .waiting
(permission undetermined after a reinstall) cancels so page 4's
Continue still fires the prompt in context. By the time the data page
appears its tick (or denied text) is already on screen, matching the
other permission pages. First run is unchanged.
The probe result landed while the Local Network prompt was still
dismissing, so the granted tick was skipped and the page jumped
straight to the final slide. Hold 'Connected — prices will load'
on screen for a beat before auto-advancing, matching the visible
tick flow of the Location and Notifications pages.
- FuelTypeSegmentedPicker promoted to shared (was private in StationsView)
and parameterised by fuel list so Favourites passes only fuels with favourites
- Favourites tab swaps native .segmented picker for the capsule control with
green/purple/grey fuelpump icons, matching Stations
The gold-crown header row is redundant with the list itself (rows are already
cheapest-first with a TOP badge on the first). The bottom caption about
alerts monitoring stays.
- Alerts section restyled to mirror the Live Activity section: one section
with toggle + Fuel picker + Radius picker (was segmented fuel + slider)
- Alert radius options expanded to 1/2/3/5/8/10/15/20 (display unit, snapped
to nearest option; stored km unchanged; validation widened to 33 km)
- Live Activity section moved from Settings into the Alerts tab so both
'notify while driving' surfaces sit together
- SettingsView loses the Live Activity bindings/section
- Settings → Live Activity now has Fuel and Distance pickers (5/10/15,
unit-aware) stored under fuelboard.liveActivityFuel / liveActivityRadiusMiles
- LiveActivityManager uses these instead of the Stations-tab selectedFuel /
stationLimit; Stations changes no longer re-target the pill
- Fuel moved from Activity attributes into ContentState so changing fuel in
Settings updates the RUNNING activity in place (no end+restart needed)
- Stations tab stays a pure data-interrogation + favourites surface
Widget distance menu: the Distance picker only makes sense for
Cheapest ordering. It was hidden for Favourites but Closest still
showed it (regressed when the per-widget Distance picker was added).
parameterSummary now nests When clauses: Distance visible for
Cheapest only; Closest and Favourites hide it.
Notifications: three compounding defects kept real geofence alerts
from ever firing while the app was suspended:
1. Always permission was never properly requested. requestPermissions
fired WhenInUse and Always back-to-back; iOS ignores the second
call while the first prompt is pending, leaving the app stuck on
WhenInUse — and region entries are never delivered in the
background. Added locationManagerDidChangeAuthorization to
ProximityMonitor: escalate WhenInUse -> Always, and re-register
geofences on grant (regions registered under WhenInUse-only won't
deliver in the background).
2. The 18-region window was frozen in the background. Re-registration
lived in SwiftUI .onChange(of: locationManager.current), which
never runs while suspended. Added a delegate hook
(LocationManager.onLocationUpdate) fired from didUpdateLocations on
every fix including background significant-change wake-ups; the app
wires it to re-register geofences around the new position.
3. Region-registration failures were invisible. monitoringDidFailFor
was never implemented, so a failed startMonitoring (region budget,
auth, radius) silently stopped alerts. Now surfaced as
monitor.lastRegionError and shown in Settings -> Debug; cleared on
the next successful registration.
1. CarPlay widget taps could never launch Maps — FuelBoard is not a
CarPlay app, and Apple only lets a widget launch its own app in
CarPlay when that app is CarPlay-enabled. Marked the widget as a
disfavored CarPlay location (iOS 26+; no-op below): read-only in
the car, no dead tap. Header comment updated to match reality.
2. Widgets now track the user's location: timeline refresh 15m -> 5m,
and the app's location manager reloads all widget timelines after
every significant move (250m filter, throttled to 60s), so the
widget re-orders around the new position while driving instead of
waiting for the next tick.
3. Debug 'Cheapest in range' no longer shows the ALERT prediction
(alertsFuel + alert radius — a different pool by design). It now
mirrors the app list exactly: the TOP-badge station for the
selected fuel within the stationLimit radius (new DebugAppCheapest
computed in ContentView, passed to Settings). Alert-prediction
cheapest kept as fallback when the app view hasn't resolved.
- Relay envelope now carries source (api|csv), stations_count, and
data_updated (freshest price_last_updated the GOV.UK server reports,
independent of relay sync time).
- App decodes the new envelope metadata (RelayMeta), persists it after
each full fetch, and shows it in Settings → About → Data source:
Connection (API/CSV), Stations count, Data updated (local-formatted).
- Older relays without the fields decode cleanly (nil meta → em-dash).
- 2 new tests for meta decode + absent-meta tolerance; 37/37 pass.
- The Data page's probe was an HTTP GET /api/v1/stations (8s timeout)
that read as 'loading data' and could stall the slide. Replaced with a
bare TCP connect to the relay host:port — just enough to trigger the
iOS Local Network permission prompt, zero payload transferred.
- Grant -> auto-advance to the final slide (existing onChange); denial or
unreachable relay -> Continue re-enables and proceeds. 12s safety
timeout so the page can never dead-end on a spinner.
- NWConnection held on the prompter so the connect survives until the
prompt is answered. Status copy: 'Waiting for network permission…'.
Full dataset still downloads AFTER onboarding via forced refresh.
35 tests pass.
- The Prices-ready page button stays 'Continue': tapping it fires the
Local Network prompt + relay probe; on grant the page auto-advances to
the final slide (existing onChange), on denial it still advances.
- Removed the Open Settings branch from the Data page entirely (kept for
Location page, where the user can still be sent to Settings).
- Denied caption no longer suggests opening Settings. 35 tests pass.
- Region radius changed from fixed 300 m forecourt fence to the alert
radius (the Alerts-tab slider), clamped to the device's
maximumRegionMonitoringDistance.
- Notification now fires when you ENTER the winner's circle while
approaching, not when you reach the forecourt.
- Cheapest-gate unchanged: still only fires when the entered station is
the cheapest within the alert radius (fresh prices). Dedup still 1/
station/hour. 35 tests pass.
- Debug section now shows the last device fix (lat/lng to 5dp) and its age.
- In-range indicator uses the SAME criteria as live alerts: stations selling
the monitored fuel within the alert radius of the fix. Green dot + count
when in range, red when none, grey while waiting for a fix.
- Shows the cheapest in-range station (name, distance, price).
- ProximityMonitor recomputes the snapshot on every location/stations
change and on Debug-section appear; ContentView passes the live status.
- SettingsView extracted to its own property to keep TabView body within
the compiler type-check budget. 35 tests pass.
- Test-alert buttons moved out of the Alerts section into a Debug section.
- Debug section only renders when the debug flag is on; completely hidden
otherwise (no user-facing toggle).
- Hidden toggle: tap Settings → About → Version five times to flip the
flag (keychain + app-group storage, survives reinstall).
- About section added with version/build number.
- 35 tests pass.
- New Sort by value 'Favourites' in Edit Widget (per-widget config).
- ParameterSummary hides the Distance picker when Favourites is selected
(favourites are not radius-bound).
- Favourites mode reads keychain favourites (shared access group works on
SideStore free even without App Groups), filters to the widget's fuel,
refreshes prices from a focused relay fetch when in range, and ranks
cheapest-first with distance tiebreak. Not radius-filtered.
- App now re-saves refreshed favourites to keychain after every fetch so
the widget shows current prices instead of star-time snapshots.
- Empty state + headings adapt to favourites mode. 35 tests pass.
- The widget now uses the native maps://?daddr= scheme for taps in both
sizes. In CarPlay there is no containing app to relay through, but since
Apple Maps IS a CarPlay app, the system may route the tap straight to
Maps on the car display (WWDC25: widgets can launch apps in CarPlay).
- iOS keeps working via onOpenURL: the app now forwards maps:// URLs (if
the Home Screen delivers them to the app), plus legacy fuelboard:// and
http://maps.apple.com links from older cached timelines.
- No public API exists for a widget to detect CarPlay context, so the
single maps:// link serves both; the app-side forward is the iOS safety
net. 35 tests pass.
- WidgetKit widgets can only open their containing app — the Link URL is
delivered to FuelBoard via onOpenURL, never opened directly in Maps. The
widget now links to a registered fuelboard://directions?lat=&lng= URL,
and FuelBoardApp forwards it to native maps://?daddr= directions.
(http://maps.apple.com and raw maps:// both silently dropped in-app.)
- Widget rows (small + medium) now show the full station name (station.name)
instead of just the brand.
- Onboarding network page: removed the 'Check again' secondary screen after
the Local Network prompt — Open Settings remains the only retry path.
35 tests pass.
- Widget tap-to-directions now uses the native maps:// scheme instead of
http://maps.apple.com. The universal-link http URL failed to open from a
widget and fell back to launching the containing app; maps:// opens
Apple Maps directly for both small (whole widget) and medium (per row).
- Onboarding: removed the Skip button (mandatory flow), and system prompts
(location, notifications, local network) now fire only when the user taps
Continue/Allow on each page — not when the page appears. Pages
auto-advance once a permission is granted.
- After 'Start Using FuelBoard' the first data fetch is forced, so a
reinstall that leaves a recent lastRefresh in app-group defaults can no
longer skip the fetch and leave the station list empty. 35 tests pass.
Location and network fetches no longer fire at launch before onboarding
reaches the relevant step: foreground tracking and the first refresh are
gated behind onboarding completion (scenePhase no longer starts tracking
while the cover is up, and onAppear defers its fetch until after
finish). A new Data page sits just before 'You're all set': it probes
the FuelBoard Relay, which surfaces the iOS Local Network permission
prompt in context (NSLocalNetworkUsageDescription added), verifies
connectivity, and offers Open Settings/Check again on denial. Onboarding
is now 5 pages: Welcome, Location, Notifications, Data, Done. 35 tests
pass.
LiveContainer shows its own icon in the notification header, so tied
alerts (which carry no map) now attach the app's compiled
AppIcon60x60@2x.png so the FuelBoard identity shows as the banner
thumbnail and in the expanded view. Single-station alerts keep the map
embed.
A tied-cheapest notification now delivers immediately with the choice
buttons and NO map snapshot (a map would only show one arbitrary
station). Tapping the notification body of a tie no longer opens
directions — nothing has been chosen; only the option buttons trigger
directions. Single-station alerts keep the embedded map + tap-to-route.
Action buttons for tied cheapest stations now read '<station name> ·
<distance>' (e.g. 'Tesco Express · 0.8 mi') instead of just the brand,
in both the live geofence path and the Settings real-data test.
When several stations share the cheapest price within the radius (within
0.01p), the alert registers a dynamic UNNotificationCategory with up to
4 action buttons (brand + distance, nearest first) and taps open
directions to the chosen station. Shared FuelStore.tiedStations helper
used by both the live geofence path and the Settings real-data test;
35 tests pass.
LiveContainer did not honour the replace-in-place contract: adding a
second request with the same identifier delivered a SECOND notification
instead of updating the first. Now the MKMapSnapshotter renders first
(10s timeout), then exactly ONE request is added with the map attached.
If the snapshot fails or times out, the notification still fires without
the image — never two notifications.
The notification used to be added only inside the MKMapSnapshotter
completion — if the snapshot hung or failed, no notification was ever
delivered and the result line never showed the map status. Now the
notification fires immediately (same identifier), the snapshot renders
in the background with an 8s timeout, and on success the delivered
notification is replaced in place with the map attached. Result line
always updates: preparing -> attached / failed / timed out.
- Plain test notification now picks the nearest station selling the
monitored fuel and carries its real name, price, distance and
coordinates (was a static no-station notification)
- addAlertRequest reports whether the map snapshot attached or why it
failed, appended to the Settings result line instead of silently
delivering a notification without the embedded map
- Settings routes both test buttons through the live monitor
- Alerts (live + real-data test) carry the station name + coordinates in
userInfo and attach a map snapshot with a red pin, so the expanded
notification shows where the station is
- Tapping a notification opens Apple Maps driving directions to the
station from the user's current location (maps:// daddr)
- If Apple Maps hand-off fails (LiveContainer), an in-app map sheet
(StationMapView) pins the station + user location with a Directions
button
Both the real alert path and the real-data Settings test now render the
notification body as 'Station · 1.2 mi away · 145.9p' — actual haversine
distance to the station in the user's unit, not just the radius.