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.
The pump glyph in the widget header (both the small single-station face and
the medium list face) was hardcoded green; it now uses the app's fuel
colour wheel — green #30D158 unleaded, yellow #FFD60A premium, cyan
#64D2FF diesel — mirroring the per-fuel pumps in the app's segmented
picker. Palette duplicated in the widget target (Shared is Foundation-only
by design); cross-referenced to StationsView.swift's FuelType.tintColor.
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).
Temporary FuelBoardTestSmallWidget kind: a small-face widget driven by
the PROVEN medium intent (fuel/sort/distance only — no favourite params,
no dependency, no custom entity query), registered first in the gallery.
Isolates small SIZE vs small INTENT: if it populates while
FuelPriceSmallWidget skeletons, the small intent's extra machinery is the
culprit; if it also skeletons, the failure is small-family/system-level.
Provider gains a beaconTag so the test widget's beacons are
distinguishable in the relay log and keychain slots. Removed once the
root cause is confirmed.
Final structural difference from the working medium widget. The
dependent-picker machinery resolves the favourite parameter through the
hidden favouriteFuel parameter during fresh-widget default-config
resolution (default Sort = Cheapest hides favouriteFuel), and that
resolution fails on-device — the whole default config fails and the
small widget strands on the skeleton. Favourite picker now lists ALL
favourites, each row labelled with its fuel (the fuel-scoped filter was
the dependency's only purpose). Configured Favourites-mode widgets are
unaffected. Per-intent beacons remain in place to verify the outcome.
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).
The small-widget skeleton (fresh widget stranded on the placeholder while
the medium populated in the gallery) traced to the small intent's default
configuration resolution: FavouriteFuelQuery/WidgetFavouriteQuery
defaultResult() read keychain in the gallery-preview/fresh-add path,
failing the whole default config and leaving no timeline. Configured
widgets bypassed defaultResult (stored values → lenient entities) — the
exact favourites-works/cheapest-closest-doesn't split.
- Remove defaultResult() from both queries: fresh configs now resolve
from the static @Parameter(default:) values through lenient
entities(for:) with ZERO storage reads.
- WidgetFavouriteQuery.entities(for:) skips the keychain name lookup for
id-only (empty station) values.
- Auto-populate moves to makeEntry: empty pinned favourite → first
favourite of the fuel (same rendered result as the old sheet default).
- Sheet keeps the dependent picker via suggestedEntities() + dependency;
fresh widgets show static defaults until picked (approved tradeoff).
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
- IPHONEOS_DEPLOYMENT_TARGET 17.0 -> 26.0 (iPhone 11+, Apple's current floor)
- Widget CarPlay disfavor is now unconditional (no #available guard)
- Live Activity gains supplementalActivityFamilies([.small]) so it renders
in the car's small slot on CarPlay Ultra (display-only: FuelBoard has no
CarPlay entitlement, car taps can't launch anything)
- 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.
The 3-row cap was hardcoded for the medium widget height; large widgets
have ~2x the height and fit 5 compact rows. Timeline now carries up to
8 stations (was 4) so the large list never starves. Distance picker
still controls which stations are in range; the visible row count now
depends on widget size, not the distance setting.
- 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 API relay (fuelboard-relay-api, official Fuel Finder source) passed
contract verification and live health (source=api, 8010 stations).
Switching the app's single relay URL to :8789; original CSV relay stays
running on :8788 if we need to roll back.
- 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.