ProximityMonitor now records each stage the live chain reaches (Settings →
Debug → Alert trace, in-memory, newest-first, capped at 8):
- didEnterRegion arrival (regionEventCount + entry line) — distinguishes
'geofence never fires' from 'gate blocked the alert'
- gate misses: station not in loaded list, hourly dedup (with minutes ago),
no fuel sellers within radius after fresh fetch, entered-not-cheapest
(with the cheaper station + price)
- the previously-silent fresh-fetch catch now logs the error
- 'alert scheduled' once UNUserNotificationCenter.add is called — if that
line appears but no banner shows, the failure is delivery/permission,
not alert logic
SettingsView shows the trace (symbol per kind) + region-event count; the
existing Test-alert button already probes notification permission and
reports denial inline. 83 tests, Release build green.
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.
- 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.
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.
The first Settings test button no longer sends a hardcoded placeholder —
it runs the same criteria as a live alert (monitored fuel, alert radius,
current location) against the loaded stations and sends the REAL station
name, brand and price. Result line shown under the button; graceful
message when no stations or no sellers within radius.
- New 'Send plain test notification' button fires with zero criteria checks
(no geofence, no cheapest check, no permission gate) to isolate delivery
and tap behaviour
- ProximityMonitor is now the UNUserNotificationCenter delegate: banners
show even in the foreground (system suppresses them otherwise), and taps
complete the 'open FuelBoard' action
Fires a local notification shaped exactly like a real price alert (uses the
currently configured alerts fuel + radius), with permission-gated handling:
prompts on first use, guides to Settings when denied. Tapping it opens
FuelBoard, same as a real alert.
The proximity monitor was tied to the Stations-tab fuel selection, so
switching the list to another fuel silently re-targeted geofences. Now the
Alerts tab has its own 'Fuel to monitor' picker (persisted as
fuelboard.alertsFuel):
- Monitor reads loadAlertsFuel() on init/background relaunch
- Changing the alerts fuel re-registers geofences around stations selling
that fuel only
- Favourites priority slots stay scoped to the monitored fuel
- Tests: alerts fuel round-trip + default (33/33 passing)
Fixing the bug where favouriting an Unleaded entry also created a Diesel
favourite. Favourites are now [FavouriteEntry] = (station, fuel) pairs:
- Starring a row pins it only for the fuel being viewed
- Favourites tabs show only fuels that actually have favourites
- Geofence monitor gives priority slots only to favourites matching the
monitored fuel
- Tests updated + new fuel-scoping test (31/31 passing)
- FuelType labels: 'Unleaded (E10)'/'Premium (E5)' -> 'Unleaded'/'Premium'
- New DistanceUnit (miles/km) preference; all app + widget distances,
radii and alert text convert/display in the chosen unit
- Settings tab (4th tab): segmented miles/km picker, 'Show introduction'
replay button (onboarding moved out of Alerts tab), StoreKit tip £4.99
- ProximityMonitor + widget notifications use the chosen unit
- Tests: label expectations updated, DistanceUnit conversion + format tests