Author SHA1 Message Date
FuelBoard Contributor 90fb3b7385 road distance: cover all in-radius stations, not just nearest 12
Stations past the nearest-12 cutoff silently fell back to straight-line,
which explained the remaining app-vs-Apple-Maps gaps (e.g. 3.5 straight-line
vs 5.9 road). Raise the per-pass cap to 40 and route every station within a
25 km straight-line radius (covers the 15 mi max search window) so any station
the widget/Live Activity/list can show gets a real road distance.

Also harden the new cache tests against persistent keychain state across
invocations (reset in setUp) so they pass deterministically.
2026-08-20 11:54:23 +01:00
FuelBoard Contributor 64ef260959 widget + live activity: prefer Apple-Maps road distance, computed by the app
The widget/Live Activity showed straight-line haversine distance (0.8 mi)
while Apple Maps routes 1.8 mi. Road routing is too heavy for the widget
execution + ~40-70/day refresh budget, so the APP now computes it:

- New RoadDistanceService (app target): for the nearest 12 stations each
  pass, calls MapKit MKDirections (free, no API key, matches Apple Maps)
  and caches metres keyed by station ID.
- Cache stored in KEYCHAIN (fuelboard.roadDistances) so the widget reads it
  even on free SideStore accounts with no app-group container; only valid
  within 600 m of the location it was built from.
- Throttled: recompute max every 10 min, or when the user moves > 400 m;
  wired into the location-update hook + location onChange.
- Widget face, app station rows + Live Activity show the cached road
  distance, falling back to straight-line when absent.
- 4 new cache tests (105 total).
2026-08-20 11:33:47 +01:00
FuelBoard Contributor 28bb245c93 live activity: fix vertical centering on lock screen
GeometryReader as the layout root pinned content top-left, and forcing
maxHeight:.infinity over-claimed the whole proposed height, centring the
content below true vertical centre (bigger gap above) on the Lock Screen.

Demote GeometryReader to a background side-channel that only measures the
slot width for the rich-vs-compact branch; the content stays intrinsic-sized
so the system vertically centres it itself.
2026-08-20 10:16:21 +01:00
FuelBoard Contributor d8412c132a live activity: remove the white/grey background circle behind the pump
User: 'Remove the white background circle from the live activity'. Drop
the explicit Circle badge and render the fuel-tinted fuelpump.fill on its
own (sized up 19 -> 28 since it no longer sits inside a disc).
2026-08-19 22:52:50 +01:00
FuelBoard Contributor 1d62204a7a live activity: explicit badge (dark circle + bright pump); fix alignment look
fuelpump.circle.fill's first hierarchical style hits the PUMP, not the
circle, so the earlier darkened colour landed on the pump and the circle
stayed white (user: 'I didn't want the fuel pump darker I wanted the
white circle darker'). Rebuild the brand glyph from two explicit shapes:
a light-grey (.86) Circle badge + a bright fuel-tinted fuelpump.fill, so
circle vs pump colours are unambiguous and the badge centres cleanly with
the two-line text block.

Add a DEBUG-only -qaLiveActivity <fuel> launch hook that starts a Live
Activity with a long station name so the Lock Screen can be rendered in
the Simulator and verified by pixels (pump bright green 43,193,81; badge
grey 202,201,202).
2026-08-19 22:46:57 +01:00
FuelBoard Contributor bdfbbc605f live activity: centre content vertically + darken pump circle
A GeometryReader hands its content the full proposed frame and pins it
top-left, so the Lock Screen card sat high instead of vertically centred
when the activity container offered extra height. Wrap the branch in
.frame(maxWidth:.infinity, maxHeight:.infinity, alignment:.center).

Also darken the pump circle to 82% of the raw fuel tint (mix 18% black) so
the white pump symbol keeps contrast against bright Lock Screen/card, esp.
for the light yellow premium / cyan diesel palette colours.
2026-08-19 22:26:37 +01:00
FuelBoard Contributor 2c6e63f6b1 live activity: tint pump glyph by monitored fuel
Move FuelType.tintColor (green=unleaded #30D158, yellow=premium #FFD60A,
cyan=diesel #64D2FF) into Shared/FuelStore.swift so the app, widget, and
Live Activity all read one definition (was app-only in StationsView, so the
widget couldn't see it). Tint the Live Activity rich card pump icon and the
Dynamic Island compactLeading pump by context.state.fuel.tintColor.
2026-08-19 22:18:56 +01:00
FuelBoard Contributor a6620d36f7 live activity: select rich vs compact by actual GeometryReader width
ViewThatFits measures each child's IDEAL (untruncated) width to decide
fit, and a flexible .frame(maxWidth:.infinity) middle column does not cap
that ideal — so a long station name (e.g. 5-mi Phoenix Filling Stations)
inflated richBody's ideal past the iPhone Lock Screen width and fell back
to the compact strip. Replace the ViewThatFits branch with a
GeometryReader that reads the ACTUAL proposed width and shows the full
three-column card at/above 280pt, compact below. Content-independent, so a
long name can no longer collapse the iPhone card.
2026-08-19 22:08:07 +01:00
FuelBoard Contributor 227cce4aa6 live activity: fix compact strip string interpolation (double backslash)
The compact CarPlay/Watch strip's second line had escaped interpolation
(\\( instead of \\() so it rendered the literal placeholder text
"context.state.stationName · context.state.distanceText" instead of the
actual station name and distance. Normalize to single-backslash interpolation.
2026-08-19 20:01:14 +01:00
FuelBoard Contributor 01bb3ba905 live activity: compact CarPlay strip shows fuel + price + station distance
Restore .supplementalActivityFamilies([.small]) + a ViewThatFits compact
fallback so CarPlay's small slot renders our shared compact strip (fuel type,
bold price, station · distance) instead of falling back to the Dynamic Island
compact closure, which showed the app name and omitted the distance the user
wants. Full iPhone/iPad card is preserved via the richMinWidth gate on
richBody plus its flexible, truncating middle column (long names can no longer
collapse it).
2026-08-19 18:31:36 +01:00
FuelBoard Contributor 5d26509034 live activity: always render full card (drop compact fallback)
Long station names inflated richBody's IDEAL width, so ViewThatFits (which
measures ideal, untruncated text) decided 'rich doesn't fit' and fell back to
the compact strip — that's why 5-mi / long-named activities rendered small
while 10-15-mi / short names stayed full. With .supplementalActivityFamilies
already removed, the body is only ever Lock-Screen width, so drop ViewThatFits
and compactBody entirely and always render the full three-column card; the
station caption line-limits + scale-downs + tail-truncates in place.
2026-08-19 16:39:49 +01:00
FuelBoard Contributor 221b96097d live activity: drop .small family so iPhone/iPad show full-width card
The active activity was rendering the narrow .small form on the iPhone Lock
Screen (squeezed, small text) because .supplementalActivityFamilies([.small])
made iOS eligible to present it that way. Remove it so iPhone/iPad always get
the full three-column card; CarPlay's small form still comes from the Dynamic
Island compact closures. Keep compactBody as a narrow-width safety fallback.
2026-08-19 16:09:02 +01:00
FuelBoard Contributor b012232012 live activity: robust full-vs-compact split; scale long station names
The adaptive layout picks the full three-column view when there's Lock
Screen width and a compact price strip in the small CarPlay/Watch slot.
Tighten the discriminant (rich ≥290pt, compact ≤220pt) so iPhone/iPad
always get the full view and only genuinely small slots get the compact
strip. Long station names used to clip at inconsistent points across
the rich/compact/Dynamic Island views; both bodies now scale + tail-
truncate so the caption stays consistent regardless of station name.
2026-08-19 13:58:54 +01:00
FuelBoard Contributor 727ef6e58f banner: explicit opacity fade (transitions were unreliable)
SwiftUI's removal transition never actually played despite .id keying — the
tip banner vanished instantly on dismiss. Replace it with explicit opacity/
offset driven by withAnimation in reflectBanner (gated by a .task(id:
activeBanner) so presentation is reliable and prompt): a dismissal now
fades out + slides up over 0.35s, and a tip→network swap crossfades. Uses
the same state-driven pattern that fixed the HermesCall ticker fades.
2026-08-19 11:57:32 +01:00
FuelBoard Contributor 4c222cdf55 banner: key by value so tip fades out on dismiss
The banner sits in a single if-let slot; without an identity key, clearing a
tip that was covering a network/offline banner swapped content in place with
no transition (instantly vanishing). Keying the banner by its value (.id)
makes every change — tip→network or tip→nil — run the shared removal
transition, so the tip slides up and fades out when it auto-dismisses or is
tapped.
2026-08-19 11:10:29 +01:00
FuelBoard Contributor cfd435d12a banner: top-floating; fix offline-trigger cache; onboarding prompt timing
- Banner floats at the very top (over the nav/title area), never pushing the
  content below and staying clear of the list/pill.
- Offline banner now fires on refresh in airplane mode / no data: the mirror
  live chain bypassed the HTTP cache (+10s timeout) so an offline fetch really
  fails instead of silently re-serving a cached pointer+dump as a 'success'
  (which kept dataStatus .live and hid the banner).
- Onboarding permissions fire at the Continue/Allow tap BEFORE advancing, so
  the system prompt appears after the page is read and never covers the next
  page's animation (grant auto-advances).
2026-08-19 10:17:24 +01:00
FuelBoard Contributor 8936643e0e banner: float below nav bar (no glass pass-through) + slide-up dismiss
Position the floating banner just below the nav bar so it no longer overlays the iOS 26 Liquid Glass back/toolbar chrome behind it — tapping retry can no longer also select the control beneath. Banner now slides up + fades on dismiss/timeout (one style for all notifications); full-rect contentShape for a solid tap target.
2026-08-19 08:43:35 +01:00
FuelBoard Contributor ba83f9e1c0 banner: unify network+tip into ONE floating overlay (never layout-shifting)
Extract AppBanner; render offline/connection/tip through the single statusBannerCard chrome as a ZStack top overlay so it floats over content instead of shifting layout. Tip (auto-dismiss ~3.5s) takes precedence over network state.
2026-08-19 08:17:50 +01:00
FuelBoard Contributor 57dffb6c5e tip: show outcome in the shared status banner (auto-dismiss)
Tip purchase results now ride the SAME banner chrome as the network/offline
strip above the tabs instead of a hard-to-see Settings footnote. TipStore is
lifted to ContentView (the banner owner) and shared down to Settings. Each
outcome maps to an icon/tint; auto-dismisses after ~3.5 s; tap to dismiss.
Renderer refactored into a shared statusBannerCard(icon:tint:title:subtitle:
trailingIcon:action:) used by both the network strip (retry) and tips (x).
2026-08-19 07:48:10 +01:00
FuelBoard Contributor 2205a585f1 onboarding: animated feature icons 2026-08-19 07:09:54 +01:00
FuelBoard Contributor f9b70dbb9d anim: consistent bold crossfade in fuel-type picker 2026-08-19 07:09:54 +01:00
FuelBoard Contributor e00e3ff365 backlog: refine foresight live activity notes 2026-08-18 19:33:54 +01:00
FuelBoard Contributor 4f8bf96ca2 backlog: add foresight live activity investigation 2026-08-18 18:15:33 +01:00
FuelBoard Contributor 8945190da5 fix: keep live activity in sync with motion 2026-08-18 17:59:03 +01:00
FuelBoard Contributor 2e23dcb9ae ui: keep station brand badges white 2026-08-18 12:56:49 +01:00
FuelBoard Contributor db916cafcb ui: fix fallback station badge visibility 2026-08-18 12:47:49 +01:00
FuelBoard Contributor 1dd7836b8b assets: expand station brand icon coverage 2026-08-18 12:35:03 +01:00
FuelBoard Contributor e512cec958 data: refresh bundled fuel dump 2026-08-18 11:05:09 +01:00
FuelBoard Contributor dcd5517825 Live Activity: add distance to compact CarPlay layout 2026-08-18 10:00:19 +01:00
FuelBoard Contributor cd2632377a Expand watch app backlog 2026-08-17 20:23:20 +01:00
FuelBoard Contributor f1c5923c88 Add favourite price-drop Check now action 2026-08-17 19:59:45 +01:00
FuelBoard Contributor 276e0ff99e Swap Live Activity header and toggle labels 2026-08-17 17:28:14 +01:00
FuelBoard Contributor bed39956a3 Move cheapest station label to Live Activity header 2026-08-17 17:21:31 +01:00
FuelBoard Contributor b24b59df6f Rename Live Activity fuel label 2026-08-17 17:14:51 +01:00
FuelBoard Contributor 2495c7e47e Finalize alerts tab labels 2026-08-17 16:58:23 +01:00
FuelBoard Contributor 923209b933 Reorder alerts sections and refine labels 2026-08-17 16:53:26 +01:00
FuelBoard Contributor 02ac8540f3 Tweak alerts tab copy 2026-08-17 16:45:55 +01:00
FuelBoard Contributor 6f0fc43085 Add cheapest-favourite price-drop alerts 2026-08-17 16:33:46 +01:00
FuelBoard Contributor 63084322f6 Refine trends and debug harness; clean up warnings 2026-08-17 14:06:26 +01:00
FuelBoard Contributor 68d970720a backlog: CT 111 rootfs grown to 8 GB (pve); open item retired 2026-08-16 21:19:39 +01:00
FuelBoard Contributor 9952a096a4 docs: CT 111 is source of truth; Mac clone read-only mirror 2026-08-16 21:04:44 +01:00
FuelBoard Contributor 49503bef99 backlog: relay+mirror moved to Proxmox CT 111 (fuelboard-relay), live push verified 2026-08-16 18:55:07 +01:00
FuelBoard Contributor e0e5b36503 Trends: price-history fetch failure with no data raises the connection banner
A history fetch that comes back empty BECAUSE the mirror is unreachable
(pointer probe failed) was only an in-sheet retry card — no global signal.
Now it also raises the red connection banner ('Check your internet
connection / Tap to try again'), so a network problem is visible on every
tab, not just inside the Trends sheet. A successful load clears the
banner (only when the banner is the connection banner — never clobbers
the offline-dump banner).

- TrendsView: onHistoryUnavailable/onHistoryRecovered closures fired from
  load() (loadFailed -> unavailable; hasAnyData -> recovered);
  -forceHistoryFailure QA hook (forces the unreachable state, skips
  auto-refresh like the other force-* hooks)
- FavouritesView: closures threaded through the Trends sheet init
- ContentView: wires them to dataStatus (.live -> .connectionProblem on
  failure; recovered clears only .connectionProblem)
- fuelboard-development skill: hook + wiring documented
2026-08-16 12:31:35 +01:00
FuelBoard Contributor a6dbe8c46e Banners: mention Stations-tab refresh; add connection-problem banner
Fix 1 — the offline banner's subtitle claimed 'pull to refresh' while that
gesture only exists on the Stations tab, yet the banner pins across ALL
tabs. Copy now says where the gesture lives: 'Pull to refresh on the
Stations tab'. The banner itself stays tappable everywhere (tap = retry),
so the retry path is never trapped on one tab.

Fix 2 — fetch-failed-with-cache case showed stale saved prices with NO
feedback (looked like a working live app). New second banner state
'Check your internet connection / Tap to try again' (red wifi.exclamation
mark, tap = retry) now appears whenever the fetch fails but a saved cache
is on screen. It clears on the next successful fetch.

- ContentView: offlineDataDate:String? -> dataStatus enum (.live /
  .offlineDump(date) / .connectionProblem) driving a shared statusBanner
  (icon/tint/title/subtitle); -forceConnectionProblem QA launch hook;
  auto-refresh skipped under either force-* hook
- Localizable.strings: subtitle key updated, +5 new keys (incl. date-less
  'Offline data' fallback + a11y labels)
- Tests unchanged (101 green) — pure view-layer change
2026-08-16 12:14:13 +01:00
FuelBoard Contributor bfeb494c03 Offline banner: label the bundled-dump fallback with its snapshot date
The no-network last resort (bundled real dump) now shows a tappable banner
above the tabs — 'Offline data from 15 Aug / Pull to refresh to update
prices' — so stale snapshot data is never presented as live. Banner date
comes from the dump's own data_updated stamp (FuelStore.offlineDataLabel,
ISO 8601 with/without fractional seconds). Cleared on any successful fetch.

- BundledDumpProvider.dataUpdatedStamp (JSONSerialization, app target)
- ContentView: offlineDataDate state, offlineBanner view (tip-card styling,
  orange wifi.slash + arrow.clockwise, tap = force refresh), refresh()
  clears/sets it, -forceOfflineDump QA launch hook (serves the dump and
  skips auto-refresh so the banner stays up for captures)
- Localizable.strings: 3 keys (banner title/subtitle/a11y)
- +3 tests: OfflineDataLabelTests (101 total)
2026-08-16 11:52:19 +01:00
FuelBoard Contributor 430006be94 Trends: chart key shows average price per station + descriptor footer
Legend rows now show each station's average over the days shown
in brackets (pence; signed +X.Xp in vs-cheapest mode, matching the
list-row deltas). Footer under the key explains the figure per mode.
averagePence helper (nil for empty series) + 3 tests; -showTrends
becomes a permanent QA hook like -showKeySheet.
2026-08-16 10:39:49 +01:00
FuelBoard Contributor 9aea478317 Trends: explicit series so each favourite draws its own line
LineMark lacked a series: value, so Swift Charts merged every
station's points into one polyline — only the first station's
line was recognisable. Adding series: per station splits the
chart into N coloured lines (legend colours now match).
2026-08-16 10:19:51 +01:00
FuelBoard Contributor 3416ad563e Key sheet: taller detent + opaque background
Sheet grows from .medium to .fraction(0.6) so the footer ('Colours
match each station's rating on the list') clears the bottom edge with
comfortable margin (verified: footer clearance +33pt). Sheet background
switched from the translucent default to a solid
systemGroupedBackground via .presentationBackground so the stations
list no longer shows through.
2026-08-16 09:57:58 +01:00
FuelBoard Contributor 87d66eae94 Key sheet: padding on TOP + star cards
The two bottom cards (TOP, star) previously had no padding — their
background hugged the text. Add .padding(.horizontal, 14).padding(.vertical, 12),
matching (slightly exceeding) the RAG cards' vertical padding so the
info cards read as proper cards.
2026-08-16 09:48:33 +01:00
FuelBoard Contributor 86385d53a2 Key sheet: card redesign matching the tip section
Price-rating legend now renders as five accent cards (tip-section style):
Best value (green, checkmark) / Okay (orange, equal) / Pricey (red,
exclamation) each with a RAG dot + threshold (≤1.5p / ≤4p / >4p) on the
right in the row's accent colour; TOP (blue capsule) and star (yellow)
as accent cards below a divider. Footer: 'Colours match each station's
rating on the list'. Copy split into title/blurb keys in Localizable.
Sheet chrome (medium detent, Done, info button) unchanged.
2026-08-16 09:18:27 +01:00
FuelBoard Contributor 9fdcc7370e Tip section: card design per user mockup
Three stacked rounded cards, one per tier, each with an accent-tinted
fuel-pump icon and price (Splash & Dash #64D2FF blue, Half a Tank
#30D158 green, Fill 'Er Up #FFD60A amber — the app's fuel palette),
name + blurb on the left, bold accent price on the right. Cards sit on
clear list rows with their own rounded fill + subtle accent tint.
Purchase flow, per-tier live prices, disabled state and footer copy
unchanged.
2026-08-16 08:57:40 +01:00
FuelBoard Contributor 5060aa9df5 APPSTORE: tip jar checklist — three consumables + IAP capability
Adds the three tip IAPs (tip099/tip299/tip499 at £0.99/£2.99/£4.99) to
the USER-owner ASC tasks, and the In-App Purchase capability to the
App ID blocker alongside the App Group.
2026-08-16 08:37:33 +01:00
FuelBoard Contributor 41e4c913fb Tip jar: three tiers with names and blurbs
Splash & Dash (£0.99), Half a Tank (£2.99), Fill 'Er Up (£4.99) —
each a consumable (com.apt.fuelboard.tip099/tip299/tip499) with its own
blurb row. TipStore now loads all three products, prices fall back to
the intended storefront values until ASC products exist, and the
Transaction.updates listener acknowledges any of the three tiers.
Footer copy: 'A tip helps keep this app free and supports development.
Thank you'.
2026-08-16 08:34:27 +01:00
FuelBoard Contributor 2ffa90d879 Tip jar: finish transactions + Transaction.updates listener
The £4.99 consumable was never finished — StoreKit kept re-delivering
it through Transaction.updates on every launch. Verified purchases now
call transaction.finish() (direct path), and a Transaction.updates
listener finishes + acknowledges purchases that complete outside the
direct call (Ask to Buy approvals, other-device purchases on the same
Apple ID). Untrusted (unverified) transactions are never finished or
delivered; only our own product ID is handled.
2026-08-16 08:23:18 +01:00
FuelBoard Contributor a490bcf7b5 backlog: plan relay+mirror move to dedicated Debian LXC (2026-08-16) 2026-08-15 23:26:56 +01:00
FuelBoard Contributor 29bf2f98d6 Widget shots: use the shipping app icon (light + dark variants)
- AppIconPreview now byte-identical to the real AppIcon.png (the preview
  had been pointing at an older Design/ export); added AppIconPreviewDark
  from AppIcon-dark.png so the mock shows the correct icon per appearance
- Re-captured 9-widgets-dark + 10-widgets-light
- 95/95 tests green; Release IPA rebuilt + re-served
2026-08-15 16:53:31 +01:00
FuelBoard Contributor 015368faaf Real iPhone wallpaper + populated Favourites captures
- PreviewWallpaper image set: the actual iPhone wallpaper extracted from the
  iOS 26.3 sim runtime (ActionButtonSelector Display.ca, 1320x2870 native
  res, JPEG ~180 KB) — replaces the gradient backdrop in the widget mock
- -seedFavourite <substring> launch hook: pins the first matching station
  for Unleaded via the normal keychain+app-group save path, so the
  Favourites tab can be captured populated (Ring Road Service Station,
  129.9, 59.1 mi, TOP badge)
- Re-captured 2-favourites (light), 6-favourites-dark, 9-widgets-dark,
  10-widgets-light with real data
- 95/95 tests green; Release IPA rebuilt + re-served
2026-08-15 16:49:09 +01:00
FuelBoard Contributor 6369eef249 Widget preview: real small-tile proportions + iOS-default-style wallpaper
- Small widget card sized to actual system-small content (158×158pt + padding)
  instead of filling available width; medium keeps 338×158pt
- Wallpaper now mimics the iOS default (dark navy→black in dark mode,
  soft blue-grey in light) via colorScheme-adaptive gradient
- Added 10-widgets-light.png; re-captured 9-widgets-dark.png with data
- 95/95 tests green; Release IPA rebuilt + re-served
2026-08-15 16:32:45 +01:00
FuelBoard Contributor 57121d5213 Widget preview screen + shared widget rendering; dark-mode screenshots with real data
- Move widget entry view into Shared/FuelPriceWidgetViews.swift (FuelPriceWidgetContent,
  FuelPriceWidgetEntryData) so the app can render the REAL widget faces; widget
  extension now wraps it (FuelPriceEntry.data) — one source of truth for the face
- WidgetMockScreen (-widgets launch arg): app icon + small + medium widget previews
  for App Store captures; AppIconPreview image set (1024 drip master)
- AppStoreScreenshots: 5-8 dark-mode tab captures with real Halifax data, 9 widgets
- 95/95 tests green; Release IPA rebuilt + re-served
2026-08-15 16:25:10 +01:00
FuelBoard Contributor 24b6ca9bb2 Copy: England → UK-wide, include Northern Ireland; migrate user-facing copy into Localizable.strings
- Onboarding feature: 'England-wide prices' → 'UK-wide prices — 8,000+ stations, updated twice a day' (key + value unified)
- Stations empty state: 'covers England, Scotland and Wales' → 'covers the whole UK' (dataset includes Northern Ireland)
- Settings Data source footer: source wording updated (CSV mirror or API), OGL attribution retained
- Empty state, browse button and OGL footer now keys in Localizable.strings so the web copy editor covers them
- APPSTORE.md + BACKLOG.md + code comments: England-wide → UK-wide, NI included
2026-08-15 16:04:56 +01:00
FuelBoard Contributor 4797886a1d Add raw App Store screenshot captures (6.9-inch, 1320x2868)
Raw tab captures for the yuzu-hub App Store Screenshot Generator:
stations, favourites, alerts, settings. Captured via simulator launch
args (-skipOnboarding / -tab).
2026-08-15 15:55:19 +01:00
FuelBoard Contributor b5a49fc68d App Store prep: privacy manifests, 1.0.1 (2), OGL attribution, UK empty state, screenshot harness
- PrivacyInfo.xcprivacy in app + widget targets (UserDefaults 1C8F.1;
  app declares Location linked + Diagnostics not-linked, no tracking)
- Version bump 1.0.1 (2) in App + Widget plists
- Settings → Data source footer: OGL attribution + 'refresh up to twice a day'
- Stations empty state: friendly UK-only explainer + 'Browse all UK stations'
- ContentView launch-arg harness (pattern of -showKeySheet): -skipOnboarding
  and -tab stations|favourites|alerts|settings for screenshot capture
- APPSTORE.md updated (blockers struck, tasks done, yuzu-hub workflow)
- BACKLOG.md hygiene: GitHub-first header, Trends merged note, offline-first-run DONE
2026-08-15 15:50:26 +01:00
FuelBoard Contributor 06dd8a919c APPSTORE.md: submission plan — P0 resolved old blockers, owner/timeline checklist 2026-08-15 14:57:20 +01:00
FuelBoard Contributor 44a8e7f42a Onboarding: prompts fire on forward-leave of their page, not arrival
b634c06 fired on page arrival, so swiping stacked Location + Notifications
before a screen was read. Now each permission fires only when the user
ADVANCES PAST its page (swipe forward or Continue) — the screen has been
read by then. Forward-only (swiping back never fires), idempotent
(already-determined → status refresh), so replay and auto-advance stay
clean. Continue buttons are now pure navigation; denied paths unchanged.
2026-08-15 14:44:18 +01:00
FuelBoard Contributor b634c0686d Onboarding: permission prompts fire on page arrival, not just Continue
Swiping past the Location/Notifications pages never triggered the system
prompts — only tapping Continue did. The prompt now fires the moment its
page comes into view (onChange(of: page)): swipe, auto-advance after a
grant, or Continue all land on the page and request the permission. Both
request methods are idempotent (already-determined → status refresh, no
duplicate alert), so replay and the auto-advance path stay clean.
2026-08-15 14:34:24 +01:00
FuelBoard Contributor 485ef782b6 Stations tab: explainer styled like the Favourites cheapest callout
The N/TOTAL stations-updated explainer now sits in a plain caption Section
cell at the bottom of the tab (font .caption, secondary) — matching the
Favourites tab's bottom callout instead of rendering as a section footer.
2026-08-15 14:22:43 +01:00
FuelBoard Contributor b072b62208 Stations tab: explainer moved below the list with N/TOTAL station count
The header section was removed entirely — the tab now opens with Fuel type.
The explainer lives as a section footer at the bottom of the tab:
- Cheapest: 'Cheapest Unleaded within 5 miles — 46/8022 stations updated ·
  tap a station for directions.'
- Closest: 'Closest Unleaded stations — nearest first, best value within
  5 miles · 46/8022 stations updated'
- no-location variant keeps mode + tap-for-directions hint
Numerator = stations in the current list pool (as before); denominator =
full UK station total from the last chain fetch (loadStationCount, 8,022
mirror snapshot) — ratio hidden until the first fetch lands.
2026-08-15 13:57:08 +01:00
FuelBoard Contributor 10ab1cebef Backlog: P0 Done entry updated for GitHub-only consumer chain 2026-08-15 13:33:53 +01:00
FuelBoard Contributor 535a504160 Consumer chain: GitHub-only — relay removed from app+widget, Local Network permission gone
The LAN relay is unreachable from any phone outside the developer's LAN, so
for App Store users a relay fallback was dead weight AND harmful: attempting
the private IP fires the iOS Local Network prompt on a stranger's phone and
adds a timeout before the bundled dump. GitHub raw is reachable anywhere the
relay would be, and more reliably.

- LiveChainProvider: GitHub mirror first EVERYWHERE (focused alert path no
  longer relay-first); LAN relay only joins behind the hidden dev flag
  fuelboard.relayFallback (Settings → Debug, keychain-first, off by default)
- MirrorFuelProvider: focused() projection replaces the relay's server-side
  radius endpoint (fuel filter + within-radius + nearest-first + limit 500)
  — the alert path re-checks radius/fuel itself, so behaviour is identical
- Widget: fetchFocused/fetchFuelOnly now GitHub-first via the chain (day-cache
  shared through the app group); relay + widget-diag GET gated on the same
  dev flag — off-LAN widgets no longer attempt local network at all
- FuelBeacon: fires only when the dev flag is on — consumers make zero
  local-network attempts; /stats app-hit spike = GitHub down + dev flag on
- Onboarding: Local Network machinery removed entirely (no probe, no prompt,
  no 12 s stall) — data page is now informational: prices download from the
  internet, no permissions needed. Replay on cellular can no longer hang.
- Info.plist: NSLocalNetworkUsageDescription copy → optional dev-only wording
- 95 tests (6 new: relay-skip-when-disabled x2, focused prefer-mirror, mirror
  focused projection x3, beacon dev-flag gate)
2026-08-15 13:33:02 +01:00
FuelBoard Contributor c447372d40 Backlog: P0 live chain DONE (moved to Done, P0 section cleared) 2026-08-15 13:01:37 +01:00
FuelBoard Contributor 366e21a1b4 P0: live provider chain — GitHub mirror primary, relay fallback, bundled dump last resort + app telemetry beacon
- MirrorFuelProvider: fetches CURRENT prices from raw.githubusercontent
  (latest.json pointer → history/<day>.json full dump), app-group day-cache
  so the ~2.8 MB dump is re-downloaded only when the mirror pushes a new day
- LiveChainProvider: full path (app refresh) GitHub → relay; focused path
  (background alert checks) relay-first so alerts never pull the full dump
  over mobile data; records which leg served for About + telemetry
- FuelBeacon: fire-and-forget X-Client app ping to the relay's existing
  widget-diag route (zero relay changes) — attribution + cadence on-LAN,
  silent skip off-LAN is the reachability datum; /stats app-hit spike =
  GitHub path failing
- Bundled dump: FuelBoardDump dataset (real 2026-08-15 snapshot, 8,022
  stations) + BundledDumpProvider + scripts/refresh_bundled_dump.sh —
  no-network last resort replaces the demo sample set
- ContentView: About meta from the chain; catch falls back cached → bundled
  → sample
- 89 tests (6 new: chain order x4, cache decision, beacon URL); Release
  build green; beacon route verified against the live relay (204 + logged)
2026-08-15 13:00:24 +01:00
FuelBoard Contributor 54aca14fd4 Trends: anchor empty/loading/error states to the top of the chart slot
The no-data states previously floated or centred vertically (Spacer
sandwich), so the sheet rendered them halfway down the page while the
chart-with-data anchors to the top. All chartArea states now share one
top-aligned container that fills the space below the pickers — the
empty state sits exactly where the chart will render, and the layout no
longer jumps between states. 83 tests, Release build green.
2026-08-15 12:30:25 +01:00
FuelBoard Contributor d1fbac3f05 New app icon (drip: £ fuel drop, green border, gradient bg) + Alerts radius guidance labels
- AppIcon / AppIcon-dark / AppIcon-tinted swapped for the drip design
  (1024px, dark + tinted variants), onboarding artwork matched
- Alerts tab: live explainer under the Radius picker — situational
  guidance per option (Follow search = set-and-forget mirror of the
  Stations distance; 1 = town driving; 2 = city/suburb commute;
  3 = mixed; 5 = A-roads/motorway exits; 8 = long-distance) — plus the
  trade-off line in the section footer
- 83 tests, Release build green
2026-08-15 12:18:56 +01:00
FuelBoard Contributor 6cbf1dfc8f Debug: 100 m fence delivery test + test-button trace logging
Settings → Debug → 'Debug fence (delivery test)': registers a 100 m
CLCircularRegion at the current fix that exists solely to prove iOS
delivers region events on this install. Entering it ticks 'Region events
this session', logs 'debug fence entered' in the Alert trace, and fires a
plain notification — zero alert gates. Cleared via button; survives the
station re-registration loop (update() now skips debug-fence regions);
cleared when alerts are disabled.

The Test alert / Plain test buttons now also write to the Alert trace
('test alert scheduled — …'), so the trace reflects every probe, not just
live geofence entries. 83 tests, Release build green.
2026-08-15 11:46:00 +01:00
FuelBoard Contributor 494412ba99 Debug: live alert trace — surface every gate decision from the alert chain
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.
2026-08-15 11:10:10 +01:00
FuelBoard Contributor d0d25811c7 chore: untrack build-sim artifacts (ignored now; were committed before the rule) 2026-08-15 10:12:46 +01:00
FuelBoard Contributor aab2d02ef5 P1: Favourites Trends graph — FuelHistoryStore + TrendsView (Swift Charts)
- Shared/FuelHistory.swift: GitHub mirror read path (latest.json pointer +
  history/YYYY-MM-DD.json day files); day math (UTC noon keys), slim day decode
  with the shared price band, series + deltaSeries (vs-cheapest rebase),
  favourites-only app-group cache (90-day prune), parallel per-day fetches,
  404/network = graph gap never error.
- FuelPriceProvider: shared priceBand + mapGrades (single source for live +
  history decoding).
- FuelBoard/TrendsView.swift: line chart in the Favourites tab via toolbar
  button + sheet; fuel capsule (fuels with favourites only), 7/30/90 range,
  Price/vs-cheapest toggle, per-station legend, price-display style on y-axis,
  empty/loading/retry states with honest copy; no widget in v1.
- Localizable.strings: Trends keys.
- 83 tests (13 history + URL regression): day math, band guard, series gaps,
  delta rebasing, prune, and the appendingPathComponent regression (relative
  URL resolution dropped /main — all fetches 404'd).
- Sim-verified: favourites rows + TOP/deltas; sheet controls + building-up
  state with live first-snapshot date (archive has 1 day; lines render once
  >=2 snapshots).
- Backlog: P1 Trends DONE (unmerged); P0 REMAINING = live provider chain +
  telemetry beacon.
2026-08-15 10:12:23 +01:00
FuelBoard Contributor 5b83d9f510 Backlog: P0 mirror push side DONE (repo live, deploy key, launchd daily job); size math corrected to full-dump 2.58MB/day @180d retention (Option 2) 2026-08-15 09:24:33 +01:00
FuelBoard Contributor 0545659c1b Backlog: P0 GitHub mirror — capture telemetry implications (widget beacon survives, opportunistic app beacon, /stats canary + fallback-outage signal) 2026-08-15 08:43:59 +01:00
FuelBoard Contributor 817a829107 Backlog: P0 archive reshaped to GitHub price-history mirror (relay pushes daily latest.json + history/YYYY-MM-DD.json to public fuelboard-data repo; app reads raw.githubusercontent); P3 production-mirror item absorbed into P0 2026-08-15 05:49:18 +01:00
FuelBoard Contributor 16bf9ba9dd Backlog: price-history direction — P0 relay archive (start now) → P1 Favourites Trends graph → P2 avg/min-max overlay → P3 prediction arrow + widget sparkline 2026-08-15 05:39:51 +01:00
FuelBoard Contributor bb6ee33254 Stations: move price-rating Key (legend) into a modal sheet opened by an info button in the nav header; list stays focused on stations. Sheet = medium detent, inline nav title 'Key', Done button; legend rows unchanged. Adds -showKeySheet launch arg (QA hook for sim). 2026-08-14 23:53:02 +01:00
FuelBoard Contributor 75506b48b7 Telemetry: tag relay fetches with X-Client (app/widget) — app via RelayFuelProvider, widget focused/fuel-only fetches + diag beacon; Siri untouched (cached-only). Purely observational for relay admin /stats. 2026-08-14 23:23:06 +01:00
FuelBoard Contributor d91652aedc Onboarding: space the welcome feature rows out vertically (per-row padding 5pt → 10pt) 2026-08-14 22:47:48 +01:00
FuelBoard Contributor 9571e0c876 Onboarding: enlarge welcome-page feature icons 16pt → 20pt (semibold, wider alignment frame) for readability 2026-08-14 22:43:10 +01:00
FuelBoard Contributor bfb59d498a Live Activity: price now respects the Price display style — style carried in ContentState (captured at push time, extension renders deterministically instead of re-reading storage), setting change pushes an in-place content update via explicit override (no save-ordering race) 2026-08-14 21:06:04 +01:00
FuelBoard Contributor 8a589a35c1 Settings: drop parenthesized example figures from Price display segmented buttons — 'Station sign' / 'Pounds & pence' (cleaner, roomier labels) 2026-08-14 20:56:01 +01:00
FuelBoard Contributor e317a1e451 Settings: rename 'Show introduction' row to 'Replay onboarding' with round-arrow icon (arrow.clockwise.circle); key stays the address, value changed in Localizable.strings 2026-08-14 19:34:11 +01:00
FuelBoard Contributor 80113456ae Price display: station sign drops the £ (134.9) — matches roadside signs; pounds & pence uses forecourt superscript style £1.29⁹/L (small superscript third digit, small /L); attributed Text on list/widgets/Live Activity, Unicode superscript in Siri cards 2026-08-14 18:14:10 +01:00
FuelBoard Contributor fd7fa5f9c9 Settings: price display toggle — station sign (£129.9) vs pounds & pence (£1.299); display-only, all surfaces (list, widgets, Live Activity, Siri cards); Siri dialog stays speech-safe pounds 2026-08-14 15:33:26 +01:00
FuelBoard Contributor 819d2e6152 BACKLOG: favourite-directions phrase drop + Directions-to-Cheapest allocation 2026-08-14 14:50:53 +01:00
FuelBoard Contributor 8a4c472ab3 Siri: allocation swap B — Directions-to-Cheapest voice phrases restored (per-fuel), favourite-directions phrases dropped (card button covers it) 2026-08-14 14:50:31 +01:00
382 changed files with 4676 additions and 835 deletions
+1
View File
@@ -1,5 +1,6 @@
build/
build-release/
build-sim/
.DS_Store
*.xcuserstate
xcuserdata/
+115 -72
View File
@@ -1,86 +1,129 @@
# FuelBoard App Store Approval Review
# FuelBoard App Store Submission Plan
Review date: 2026-08-11 · Target: App Store submission readiness.
This is the companion to SECURITY.md — focuses on App Store *review* gate
(guideline compliance), not code security.
Updated: 2026-08-16 · 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.
## Status: NOT SUBMISSION-READY
## Status: SUBMITTABLE after the checklist below
Two blockers remain before a real submission (see below). Everything else
checks out for review.
The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
- ~~LAN relay data source~~ → **app fetches from public HTTPS
(raw.githubusercontent.com mirror) with a bundled real 8,022-station dump
as the no-network fallback.** No private IP in the consumer path. Reviewers
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.
## Blockers
## Hard blockers (must happen before ANY submission)
### 1. Data source — LAN relay (BLOCKER)
- The app fetches from `http://192.168.1.131:8788` a private LAN host. Off
the user's network it fails and falls back to sample data. Reviewers on
Apple's network will see empty/sample results → 2.1 (performance) rejection,
and hardcoded IPs look like test builds.
- **Fix**: the govuk API switch (BACKLOG P0). App must talk to a public HTTPS
endpoint before submission. The relay (HTTPS domain) or direct gov.uk OAuth
both work; the code path is already stubbed.
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
a developer account.
2. **[USER] Register App ID `com.apt.fuelboard`** in the developer portal,
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).
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.
### 2. Always-location justification (BLOCKER without review notes)
- `NSLocationAlwaysAndWhenInUseUsageDescription` + `UIBackgroundModes: location`
present, but guideline 5.1.1 requires the feature to be obvious AND the app
must visibly use background location. The geofenced "cheapest station" alerts
satisfy this, but the reviewer must see it:
- Provide detailed App Review notes explaining the alert flow (enable Alerts →
geofence trigger → notification when approaching the cheapest station).
- Ensure the Alerts tab is discoverable and functional in review.
- Also: "Always" prompt should only appear after the user opts into alerts
(in-progress UX; currently the permission prompt may fire too early).
## Required tasks — owner [ME] (technical, do after account exists)
## Review-relevant configuration — PASS
- [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)
- [ ] Distribution archive + TestFlight upload (needs Apple account)
- [x] **Version bump**`1.0.1` (build 2), app + widget plists
- [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.
- [ ] 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,
Alerts, Settings) at `/tmp/fuelboard-shots/asc/`; assembled into ASC
frames by the user in the yuzu-hub App Store Screenshot Generator
(https://yuzu-hub.github.io/appscreen/). Screenshot harness: launch
args `-skipOnboarding` / `-tab stations|favourites|alerts|settings`
(ContentView, same pattern as `-showKeySheet`).
- [x] **App icon**: was MISSING entirely (invalid-binary rejection). Fixed —
1024×1024 `AppIcon.appiconset` + `ASSETCATALOG_COMPILER_APPICON_NAME`.
- [x] **Launch screen**: `UILaunchScreen` empty dict present (iOS 14+ style).
- [x] **Device family**: iPhone-only (`TARGETED_DEVICE_FAMILY = 1`) — no iPad
multitasking requirements.
- [x] **Versioning**: `CFBundleShortVersionString 1.0`, `CFBundleVersion 1`
(bump per release).
- [x] **Permissions**: only location (WhenInUse + Always), with clear usage
strings. No contacts/photos/mic/camera.
- [x] **No third-party SDKs** → no SDK privacy manifests or third-party
attribution required.
- [x] **No account creation / login** → no account-deletion requirement (5.1.1(v)).
- [x] **ATS**: no `NSAllowsArbitraryLoads` (raw-IP cleartext is ATS-exempt; once
the relay is HTTPS this is moot).
- [x] **Export compliance**: no crypto beyond keychain/SecItem → no
documentation needed.
## Required tasks — owner [USER] (App Store Connect, cannot be done by me)
## Content / IP review
- [ ] Apple Developer Program membership + App ID (blockers #1#2)
- [ ] **Tip jar: create the three consumable IAPs** in App Store Connect →
In-App Purchases (all type **Consumable**, exact product IDs, UK price
tier; tip jars are an accepted App Review pattern):
- `com.apt.fuelboard.tip099`**£0.99** — "Splash & Dash" (Just enough to keep things moving.)
- `com.apt.fuelboard.tip299`**£2.99** — "Half a Tank" (A generous top-up for development.)
- `com.apt.fuelboard.tip499`**£4.99** — "Fill 'Er Up" (Keeping the app on the road.)
App-side is ready (`41e4c91`): Settings → Support FuelBoard shows all
three tiers with per-tier prices; sideloaded builds gracefully show
"not available in this build yet" until the products exist.
- [ ] App Store Connect app record: name, bundle ID `com.apt.fuelboard`,
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)
- [ ] Age rating questionnaire (4+; no objectionable content)
- [ ] Export compliance: HTTPS-only + keychain → standard encryption
exemption (5A992a), no documentation needed
- [ ] Approve the TestFlight build and do a real-device pass before submit
- [ ] Final "Submit for Review" click + stand by to answer review questions
- **Brand logos (Shell, BP, Tesco…)**: bundled from Simple Icons (CC0) +
Wikimedia Commons. Nominative use (identifying stations) is standard practice
(cf. GasBuddy). Low risk, but keep the sources documented.
- **Price data**: gov.uk Fuel Finder data is OGL-licensed — include attribution
in the app (About/credits) once live data ships. Sample fallback data must be
clearly demo-labelled (or removed for review) so reviewers don't think the
prices are fake — 2.3.1 accuracy risk.
- **"Cheapest" claims**: data is 12h-old cache. The UI says "cheapest" — fine,
but a small "prices from the UK Fuel Finder dataset, updated twice daily"
footnote is good hygiene for 2.3.1.
## Suggested review notes (copy-paste for App Store Connect)
## Review notes (copy-paste into App Store Connect)
> FuelBoard shows the cheapest petrol, diesel, and premium fuel near the user
> using the UK government Fuel Finder dataset (OGL). Prices are fetched up to
> twice a day; the app filters 5/10/15-mile radii on-device. Location is used
> to (a) rank stations by distance and (b) power the optional Alerts feature:
> when enabled, the app geofences the cheapest station in the user's chosen
> radius and sends a notification when they approach it. Always-location is
> only requested when the user enables alerts. The app also provides a
> home-screen widget showing the current cheapest station nearby.
> using the UK government Fuel Finder dataset (Open Government Licence).
> 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.
## Release checklist (before every submission)
## Review risks (know before submitting)
1. [ ] Relay/govuk data source live on a public HTTPS endpoint
2. [ ] Always-permission flow tied to explicit user opt-in
3. [ ] Bump `CFBundleShortVersionString` / `CFBundleVersion`
4. [ ] Sample-data fallback labelled or removed in the release build
5. [ ] OGL attribution present in-app
6. [ ] `swift test` green (FuelBoardTests, 28 tests)
7. [ ] Security audit pass (see SECURITY.md recurring checks)
8. [ ] Icon + screenshots for all required sizes in App Store Connect
- **Always-location (5.1.1)** — the #1 question. The feature is visible
(Alerts tab) and opt-in; the review notes above say so explicitly.
- **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.
- **Live Activities** — reviewers may ask why; answer: price alerts at a
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.
## Timeline (best case)
| Day | Owner | Milestone |
|-----|-------|-----------|
| 02 | USER | Developer Program enrollment + App ID + app group capability |
| 23 | ME | Signing (automatic), PrivacyInfo.xcprivacy, attribution, empty state, version bump |
| 34 | ME | Archive → TestFlight; screenshots captured |
| 45 | USER | TestFlight install + real-device pass (onboarding, alerts, widget, offline) |
| 56 | USER | App Store Connect metadata + labels + age rating + privacy URL |
| 67 | USER/ME | Submit for review |
| 714 | Apple | Review (typically 13 days, can be a week); answer questions same-day |
Rollback note: nothing about submission changes the repo's rollback posture
(`main` history is intact; release builds are tagged per submission).
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

+133 -20
View File
@@ -1,8 +1,10 @@
# FuelBoard Backlog
FuelBoard — England-wide petrol-station price widget + app.
Relay mirrors the UK gov data (hourly CSV); app fetches the full-UK dump
(~8,038 stations, all prices) once per 12 h and filters/sorts on-device.
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).
Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea.
Status: TODO / IN PROGRESS / DONE / BLOCKED.
@@ -11,18 +13,60 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
## P1 — Soon
- [x] **Move relay + mirror off the Mac mini (DONE 2026-08-16)** — dedicated
Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh`
(CT **111**, hostname **`fuelboard-relay`**, 1 core/512 MB/2 GB, static IP
**192.168.1.113**, plain LXC — NO Docker layer). Scope: relay AND mirror
both moved (full Mac independence; relays bind **loopback only**, GitHub is
the only public surface). What landed: Python 3.13.5 (trixie) boots both
relays (relay 8788 demo/CSV + relay-api 8789 `source: api`, 8,024 stations,
~2 min first sync, 0 failures); source at `/root/workspace/fuelboard-{relay,relay-api}`
(venvs + pip, `.env` chmod 600); deploy key `fuelboard_deploy` +
`github-fuelboard-data` ssh config + `fuelboard-data` clone at
`/root/workspace/fuelboard-data` (origin = ssh alias, green + synced);
`mirror_push.py``/root/scripts/mirror_push.py` (RELAY line already
`127.0.0.1:8789`); systemd `fuelboard-relay.service` +
`fuelboard-relay-api.service` (Restart=always, `--host 127.0.0.1`) +
`fuelboard-mirror.service` (oneshot) + `.timer` (10:00 + 16:00, Persistent,
armed — next Mon 10:00); first live push VERIFIED from outside
(commit `735a292`, latest.json `data_updated 16:50:53Z`); Mac launchd
`com.apt.fuelboard-{relay,relay-api,mirror}` unloaded + plists parked in
`~/Library/LaunchAgents/fuelboard-migrated-to-ct/`; editor 8790 + IPA 8765
stay on the Mac; price watchdog `relay_price_watchdog.py` now fetches via
SSH (`~/.ssh/fuelboard_ct``/root/scripts/watchdog_fetch.py`). Logs via
`journalctl -u fuelboard-*` on the CT; SSH into CT: `ssh -i ~/.ssh/fuelboard_ct root@192.168.1.113`.
**Open: rootfs is 2 GB (1.1 GB free) — mirror retention ~940 MB will get
tight; rootfs grown to 8 GB on pve 2026-08-16 (5.9 GB free).**
- [x] **Siri: "Cheapest [fuel] near me"**`AppShortcutsProvider` + `CheapestFuelIntent`
(App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first
with freshness label ("as of 6am"), last-known-location fallback. Constraint:
fresh-data path needs a reachable source off-LAN — cached answers work anywhere
the app has previously fetched (see P3 data mirror). Interim: the relay on
Tailscale (100.120.217.98:8789) is reachable off-LAN for testing; relay will
move to a permanent box or the GitHub mirror long-term. *DONE — radius-scoped
the app has previously fetched (the GitHub mirror serves this now). *DONE —
radius-scoped
lookup `037c241` (real-data regression: GULF HISTON 100.9p 132 mi away),
per-fuel literal-word shortcuts `e55078c`, names = app words `a3b6f37`, plus
Directions-to-cheapest `caadf18` + spoken hand-off `aed99e3`.*
- [ ] **Siri: "Closest [fuel] station"** — same plumbing as cheapest, sort by
distance instead of price. Free second phrase in the same `AppShortcutsProvider`.
- [x] **Favourites Trends graph** — price history chart for starred stations.
Swift Charts (iOS 26 target) line chart in the Favourites tab; one series per
favourite, fuel-scoped (station, fuel) pairs map 1:1 to the archive. Default =
absolute price lines; toggle "vs cheapest favourite" re-baselines to 0 as signed
pence (mirrors the Cheapest/Closest delta pattern users already know). Reuse the
capsule fuel picker; range control 7/30/90 days; respects the price-display
toggle; honest empty-state copy in Localizable.strings ("Prices are recorded from
each refresh — check back in a few days"). No widget in v1 (one-kind rule).
Depends on the P0 archive. *DONE 2026-08-15, merged to main 2026-08-16:
`Shared/FuelHistory.swift` (FuelHistoryStore: day math, slim day decode w/ shared
band, series + deltaSeries, app-group favourites-only cache, parallel per-day
fetches) + `FuelBoard/TrendsView.swift` (chart, fuel capsule, 7/30/90, Price/vs
cheapest, per-station legend, empty/loading/retry states) + toolbar entry in
FavouritesView + strings. 83 tests incl. URL regression (appendingPathComponent
— URL(string:relativeTo:) dropped /main, all fetches 404'd). Sim-verified:
favourites rows + TOP/deltas; sheet controls + honest empty state w/ live first-
snapshot date. Pending: lines render once ≥2 snapshots (archive warming daily,
~Aug 17).*
## P2 — Later
@@ -33,8 +77,10 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
`FavouriteFuelPriceIntent`: TOP favourite per fuel (first in the manual
Favourites order — `SiriCheapestLookup.topFavourite`, refresh-vs-dump with
offline cached-snapshot fallback); no location required (distance shown only
when the saved fix exists). Also `DirectionsToFavouriteFuelIntent` (Maps
hand-off, station announced first). 4+4 App Shortcuts (generic + per-fuel).
when the saved fix exists). `DirectionsToFavouriteFuelIntent` (Maps
hand-off, station announced first) stays in code but carries no voice
phrases — every favourite price card has the interactive Directions button
(`6d7254e`), and the voice slots went to Directions-to-Cheapest (`8a4c472`).
*DONE — favourites already carried station snapshots, so no `StationEntity`
dependency.*
- [ ] **Siri: on-screen awareness (View Annotations + StationEntity)** — iOS
@@ -60,13 +106,30 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
scene (CPListTemplate) for navigation deep-links.
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
user-configurable (currently fixed 300 m trigger / 1 per station per hour).
- [ ] **Price-drop alerts: threshold control + Check now** — keep the current
cache-first / 12 h refresh policy, but add a per-alert sensitivity control
for favourite price-drop alerts instead of a fetch-frequency slider. Scope:
`Minimum change` picker (e.g. 0.5p / 1.0p / 2.0p), plus a `Check now`
action in the Alerts tab that forces a fetch and immediately re-evaluates the
cheapest-favourite alert. Copy should set expectation honestly: standard
mode checks when FuelBoard refreshes prices (up to twice daily).
- [ ] **Price-drop alerts: optional frequent-check mode** — if the app ever
relaxes the current "twice daily max" data policy, expose this as a clearly
separate mode rather than a freeform frequency slider. Example framing:
`Standard` (current behaviour) vs `Frequent` (best-effort extra checks),
with conservative caps (e.g. every 46 h, not hourly) and copy that does not
promise exact timing under iOS background scheduling.
- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
(currently `refreshable` fires but no visible progress in the row list).
- [ ] **Offline first-run**first launch without network currently shows empty
states; bundle a sample dump so the app demos offline.
- [x] **Offline first-run**bundled REAL 8,022-station dump (`FuelBoardDump`,
refreshed by `scripts/refresh_bundled_dump.sh`) is the no-network last resort
in the live chain (P0, 2026-08-16); first launch demos the full dataset offline.
- [ ] **Localization** — UK English only today; audit string catalog for l10n.
- [ ] **Dark mode polish** — verify brand logos (white-circle clips) and RAG dot
contrast under dark appearance on-device.
- [ ] **Trends: national average overlay + per-day min/max** — the full dump is
already held, so the UK/regional average line is computable locally; archive can
store per-day min/max so the chart shows intraday spikes, not just the daily point.
## P3 — Ideas / Research
@@ -76,19 +139,55 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
- [ ] **App Intents testing** — AppIntentsTesting (iOS 27) for intent coverage
instead of XCUITest when the Siri features land.
- [ ] **Production data mirror (GitHub)** — relay pushes the synced dump
(`stations.json` + `latest.json` version pointer) to a PUBLIC GitHub repo; the
app fetches `raw.githubusercontent.com` with the LAN relay as fallback source.
Public repo required for App Review (reviewer must fetch the data themselves);
HTTPS/ATS fine; plain JSON data is not "remote code" (2.5.2-safe); bundle a small
fallback dump so the app never demos empty. Needs GitHub PAT (aptonline account).
- [ ] **Fuel-price prediction** — trend from historical dumps (relay could keep a
rolling archive) to show "price tends up/down".
- [ ] **Fuel-price prediction** — "price tends up/down" arrow derived from the
price-history archive (P0). Cheap once the archive + Trends graph exist; keep a
separate phase. Backlog ancestry: this was the original "relay could keep a
rolling archive" idea — the archive is now P0 and the Trends graph (P1) is its
visible half.
- [ ] **Foresight Live Activity (heading-aware next station)** — investigate a
driving-time surface that predicts the likely next station ahead of the user
from cached data only (no extra source-data refresh on motion). Best v1 shape:
Live Activity option, not widget-first, because widget space/refresh budgets
are tight and the lock-screen/CarPlay compact slot better matches the use case.
Prefer ONE FuelBoard Live Activity with a mode selector (e.g. `Nearby cheapest`
vs `Foresight / next ahead`) rather than trying to run two simultaneous
FuelBoard activities; iOS can support multiple activities, but FuelBoard's
current implementation is single-activity and the compact surfaces are too
constrained for two competing driving modes anyway. Viable MVP: infer heading
from recent motion/course, keep only stations in a forward cone, score
candidates by forward distance + lateral offset, and show the next-ahead
station with all three fuel prices or a compact rotating/focused fuel
treatment if space allows. Key caveat: heading is not full route awareness,
so avoid overpromising "the next station you will reach" on motorways/opposite
carriageways; frame as "next ahead" / "likely next stop" unless route-quality
heuristics improve. Must include hysteresis/speed thresholds to avoid flapping,
and should be tested specifically against compact Live Activity/CarPlay layouts
where space is extremely limited.
- [ ] **Widget sparkline** — medium-widget mini sparkline of a favourite's 30-day
trend, after the Trends UI lands (one-kind rule preserved).
- [ ] **Price alerts for a specific station** — notify when a starred station's
price drops below a user threshold.
- [ ] **Price-drop alerts: mode = Cheapest favourite / All favourites** — keep
`Cheapest favourite` as the default low-noise mode, but explore an optional
`All favourites` mode for one fuel. Guardrails if built: threshold required,
per-station dedupe based on last-alerted price (not just time), and combine
multiple drops from one refresh into a single summary notification rather
than one alert per station.
- [ ] **Share sheet** — share "Cheapest Unleaded near me" as text/image card.
- [ ] **Watch app** — glanceable cheapest-price complication (WatchKit + shared
app-group store; watch target would need to be added to the project).
- [ ] **Watch app v1 — favourites + Check now** — small Apple Watch companion,
not a phone-app clone. Favourites-first home screen: fuel picker, `Cheapest
favourite` summary card, top few favourites for that fuel, last-updated time,
and a prominent `Check now` action that forces a refresh + re-evaluates
favourite prices. Keep the watch UX pull-based and glanceable; avoid implying
high-frequency background refresh. Shared-store architecture preferred over a
second watch-only data model.
- [ ] **Watch complications / Smart Stack** — complication/widget surfaces for
the same watch concept: show the cheapest favourite price, fuel, and short
station name at a glance. Keep copy minimal and honest about freshness.
- [ ] **Watch app v2 — favourite detail + handoff** — tap a favourite row for a
detail screen (station, brand, price, last updated) with actions like
`Directions` and `Open on iPhone`. Reuse existing maps handoff patterns where
possible instead of inventing watch-only navigation logic.
- [ ] **App Clip** — 10 MB budget for "find cheapest fuel nearby" without install.
- [ ] **Widget picker: dual-unit distance labels** — the Edit-Widget Distance row
label is cached by the system at sheet-render time and only re-resolves on
@@ -107,6 +206,20 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
## Done (recent)
- [x] **GitHub price-history mirror + LIVE provider chain (P0, DONE 2026-08-16)**
push side (relay → public `aptonline/fuelboard-data`, daily 10:00 launchd, SSH
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.
- [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
+7 -7
View File
@@ -16,6 +16,8 @@
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.1</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -27,18 +29,16 @@
</array>
</dict>
</array>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>2</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
<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>NSLocalNetworkUsageDescription</key>
<string>FuelBoard connects to the FuelBoard Relay on your local network to download the latest fuel prices.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIBackgroundModes</key>
+4 -4
View File
@@ -17,15 +17,15 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>1.0.1</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>FuelBoard uses your location to show the cheapest nearby petrol stations.</string>
<string>2</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string>FuelBoard uses your location to show the cheapest nearby petrol stations.</string>
</dict>
</plist>
+53 -4
View File
@@ -12,6 +12,8 @@ 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 favouriteDropEnabled: Bool
@Binding var favouriteDropFuel: FuelType
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
@Binding var followsSearch: Bool
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
@@ -30,6 +32,7 @@ struct AlertsView: View {
let distanceUnit: DistanceUnit
let monitoredCount: Int
let lastAlert: String?
let onCheckFavouriteDropNow: () -> Void
/// The Stations-tab search distance shown in the user's unit, e.g.
/// "5 miles" or "8 km" for the "Follow search" rows.
@@ -46,6 +49,34 @@ struct AlertsView: View {
return "\(shown) \(distanceUnit.label(for: Double(shown)))"
}
/// Situational guidance for the currently-selected alert radius, shown
/// under the picker so users know what each choice is best for. Labels
/// mirror the picker row text (unit-aware) and explain the trade-off:
/// smaller radii ping more often but later; larger radii ping rarely in
/// town but earlier from afar.
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)."
case .fixed(let value):
let optionLabel = "\(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."
case 2:
body = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town."
case 3:
body = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns."
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."
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."
}
return "\(optionLabel)\(body)"
}
}
private enum AlertRadiusChoice: Hashable {
case followSearch
case fixed(Int)
@@ -103,7 +134,22 @@ struct AlertsView: View {
NavigationStack {
List {
Section {
Toggle("Cheapest-station alerts", isOn: $enabled)
Toggle("Cheapest favourite", isOn: $favouriteDropEnabled)
Picker("Fuel", selection: $favouriteDropFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
Button("Check now", action: onCheckFavouriteDropNow)
.disabled(!favouriteDropEnabled)
} header: {
Text("Price-drop alerts")
} 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.")
}
Section {
Toggle("Cheapest station", isOn: $enabled)
Picker("Fuel", selection: $fuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
@@ -115,15 +161,18 @@ struct AlertsView: View {
Text("\(value) \(distanceUnit.label(for: Double(value)))").tag(AlertRadiusChoice.fixed(value))
}
}
Text(alertRadiusGuidance)
.font(.caption)
.foregroundStyle(.secondary)
if enabled && monitoredCount == 0 {
Text("No stations monitored yet — open the Stations tab to load prices first.")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Cheapest-station alerts")
Text("Proximity alerts")
} 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.")
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.")
}
if enabled, let lastAlert {
@@ -134,7 +183,7 @@ struct AlertsView: View {
}
Section {
Toggle("Live Activity", isOn: $liveActivityEnabled)
Toggle("Cheapest station", isOn: $liveActivityEnabled)
Picker("Fuel", selection: $liveActivityFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "AppIconPreview.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "AppIconPreviewDark.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"data" : [
{
"filename" : "fuelboard_dump.json",
"idiom" : "universal"
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "PreviewWallpaper.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_essar_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_essar_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_gleaner_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_gleaner_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_maxol_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_maxol_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_murco_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_murco_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_spar_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_spar_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_valero_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_valero_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+28
View File
@@ -0,0 +1,28 @@
// BundledDumpProvider.swift no-network last resort (P0 live chain).
//
// A REAL snapshot of the mirror dump is bundled into the app (FuelBoardDump
// dataset, refreshed before builds by scripts/refresh_bundled_dump.sh) so the
// app never demos empty and App Review always sees real data even fully
// offline. Same relay envelope, so the shared decode applies unchanged.
import UIKit
enum BundledDumpProvider {
/// The bundled real snapshot, decoded with the shared relay decode
/// nil only if the asset is missing or corrupt (callers then fall back
/// to the England-wide sample set).
static var stations: [FuelStation]? {
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
return try? FuelPriceProvider.decodeStations(from: asset.data)
}
/// The GOV.UK `data_updated` stamp from the bundled envelope (ISO 8601).
/// Labels the offline-data banner honestly ("Offline data from 15 Aug")
/// instead of presenting the build's snapshot as live.
static var dataUpdatedStamp: String? {
guard let asset = NSDataAsset(name: "FuelBoardDump"),
let obj = try? JSONSerialization.jsonObject(with: asset.data)
as? [String: Any] else { return nil }
return obj["data_updated"] as? String
}
}
+529 -87
View File
@@ -2,6 +2,15 @@ import SwiftUI
import CoreLocation
import WidgetKit
/// The single floating notification banner. Network/offline state and tip
/// outcomes all funnel through ONE style and render via the same chrome in
/// ContentView never pushing layout, always overlaid.
enum AppBanner: Hashable {
case offlineDump(date: String)
case connectionProblem
case tip(TipStore.TipOutcome)
}
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@@ -10,10 +19,13 @@ struct ContentView: View {
@State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
@State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch()
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
@@ -29,11 +41,73 @@ struct ContentView: View {
private var effectiveAlertsRadiusKM: Double {
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
}
private func installLocationUpdateHook() {
locationManager.onLocationUpdate = { [weak monitor] in
monitor?.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
// The Live Activity follows the same wake-ups this hook
// fires on every fix incl. background significant-change
// wake-ups, so the Lock Screen pill stays live while driving.
updateLiveActivity()
refreshRoadDistancesIfNeeded()
}
}
/// Kicks off a (throttled) Apple-Maps road-distance recompute for the
/// stations around the current fix. The app owns routing the widget and
/// Live Activity only read the cached result.
private func refreshRoadDistancesIfNeeded() {
guard let location else { return }
Task {
await RoadDistanceService.refreshIfNeeded(
stations: stations,
lat: location.lat,
lng: location.lng
)
}
}
private func ensureBackgroundMotionTrackingIfNeeded() {
if alertsEnabled || liveActivityEnabled {
locationManager.startBackgroundTracking()
}
}
@State private var isLoading = false
@State private var statusMessage = ""
/// What data is on screen, driving which (if any) status banner shows
/// above the tabs:
/// - `.live`: fetched or cached data nothing to say.
/// - `.offlineDump(date)`: serving the BUNDLED no-network snapshot
/// the banner labels it honestly with the snapshot's own date.
/// - `.connectionProblem`: fetch failed but a saved cache is showing
/// the banner says to check connectivity (tap = retry).
enum DataSourceStatus: Equatable {
case live
case offlineDump(date: String)
case connectionProblem
}
@State private var dataStatus: DataSourceStatus = .live
@State private var showOnboarding = false
@State private var showWidgetMock = false
@State private var selectedTab = 0
@State private var locationManager = LocationManager()
@StateObject private var monitor = ProximityMonitor()
/// Owns tip purchases; the single instance is shared down to Settings.
/// Its transient outcome rides the SAME status-banner chrome as the
/// network/offline strip (ContentView renders it above the tabs).
@StateObject private var tipStore = TipStore()
// --- Explicit banner animation state (robust fade-out) ---
// SwiftUI's removal transition for the banner proved unreliable here
// (the tip faded to nothing only in the cross-nil case, and even then
// inconsistently). Drive opacity/offset explicitly instead so a
// dismissal ALWAYS fades + slides up on ANY banner change.
@State private var currentBanner: AppBanner?
@State private var bannerOpacity: Double = 0
@State private var bannerOffset: CGFloat = 0
@State private var bannerClearTask: Task<Void, Never>?
/// The pool the list draws from. In Cheapest mode the chosen miles radius
/// bounds it ("best price within X miles"); in Closest mode the radius is
@@ -136,87 +210,140 @@ struct ContentView: View {
}
var body: some View {
TabView {
StationsView(
stations: displayedStations,
totalCount: sortedStations.count,
isLoading: isLoading,
selectedFuel: $selectedFuel,
sortMode: $sortMode,
stationLimit: $stationLimit,
distanceUnit: distanceUnit,
baselinePrice: baselinePrice,
topStationID: topStationID,
location: location,
favouriteIDs: favouriteIDs,
onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) }
)
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
configuredContent
}
FavouritesView(
favourites: refreshedFavourites,
selectedFuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites
)
.tabItem { Label("Favourites", systemImage: "star.fill") }
private var configuredContent: AnyView {
interactionObservedContent
}
AlertsView(
enabled: $alertsEnabled,
radius: $alertsRadius,
fuel: $alertsFuel,
followsSearch: $alertsFollowsSearch,
liveActivityEnabled: $liveActivityEnabled,
liveActivityFuel: $liveActivityFuel,
liveActivityRadiusMiles: $liveActivityRadiusMiles,
liveActivityFollowsSearch: $liveActivityFollowsSearch,
stationLimit: stationLimit,
distanceUnit: distanceUnit,
monitoredCount: monitor.monitoredStationIDs.count,
lastAlert: monitor.lastAlert
)
.tabItem { Label("Alerts", systemImage: "bell.fill") }
private var presentedContent: AnyView {
AnyView(
mainContent
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView {
showOnboarding = false
}
}
.fullScreenCover(isPresented: $showWidgetMock) {
WidgetMockScreen()
}
.sheet(item: $monitor.pendingStationMap) { request in
StationMapView(request: request)
}
)
}
settingsTab
}
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView {
showOnboarding = false
private var lifecycleObservedContent: AnyView {
AnyView(
presentedContent
.onAppear {
// Launch-arg hooks for the screenshot/UI-test harness (same
// pattern as StationsView's `-showKeySheet`): `-tab <name>`
// opens the given tab; `-skipOnboarding` skips onboarding
// without touching the stored flag.
let args = ProcessInfo.processInfo.arguments
#if DEBUG
showWidgetMock = args.contains("-widgets")
// `-seedFavourite <substring>` pins the first matching station
// for Unleaded via the normal save path (keychain + app group)
// so screenshot captures can show a populated Favourites tab.
// Repeatable: each occurrence adds another favourite (QA temp).
// Optional ":fuel" suffix (e10|e5|diesel) picks the
// fuel, so the fuel-aware Trends open can be verified (QA temp).
let seedQueries = args.enumerated()
.filter { $0.element == "-seedFavourite" }
.compactMap { i, _ in
i + 1 < args.count ? args[i + 1] : nil
}
if !seedQueries.isEmpty {
let stations = FuelStore.loadStations()
var favs = seedQueries.compactMap { query in
let parts = query.split(separator: ":", maxSplits: 1)
let name = String(parts[0])
let fuel = parts.count > 1
? FuelType(rawValue: String(parts[1])) ?? .e10
: FuelType.e10
return stations.first { $0.name.localizedCaseInsensitiveContains(name) }
.map { FavouriteEntry(station: $0, fuel: fuel) }
}
if favs.isEmpty, let q = seedQueries.first,
let s = stations.first(where: { $0.name.localizedCaseInsensitiveContains(q.split(separator: ":").first.map(String.init) ?? q) }) {
favs = [FavouriteEntry(station: s, fuel: .e10)]
}
if !favs.isEmpty {
FuelStore.saveFavourites(favs)
}
}
}
.sheet(item: $monitor.pendingStationMap) { request in
StationMapView(request: request)
}
.onAppear {
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
switch args[i + 1] {
case "favourites": selectedTab = 1
case "alerts": selectedTab = 2
case "settings": selectedTab = 3
default: selectedTab = 0
}
}
// QA temp: `-fuel <e10|e5|diesel>` sets the app-wide selected
// fuel so the fuel-aware Trends open can be verified (simulates
// the user having switched to the diesel tab).
if let i = args.firstIndex(of: "-fuel"), i + 1 < args.count,
let f = FuelType(rawValue: args[i + 1]) {
selectedFuel = f
FuelStore.saveSelectedFuel(f)
}
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
// failure legs for the screenshot harness. The auto-refresh below
// is skipped so the banner stays up (a live fetch would clear it).
if args.contains("-forceOfflineDump") {
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
}
if args.contains("-forceConnectionProblem") {
stations = FuelStore.loadStations().isEmpty
? (BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations)
: FuelStore.loadStations()
dataStatus = .connectionProblem
}
#else
showWidgetMock = false
#endif
// Onboarding runs first on a fresh install it owns the initial
// permission prompts (location, notifications, and the data/local
// network probe on the Data page). Location tracking and the first
// network fetch start once it's done.
if FuelStore.loadHasCompletedOnboarding() {
// Launch-arg hook (UI-testing/screenshot harness, same pattern as
// StationsView's `-showKeySheet`): skip onboarding without
// touching the stored flag.
#if DEBUG
let shouldSkipOnboarding = args.contains("-skipOnboarding")
#else
let shouldSkipOnboarding = false
#endif
if FuelStore.loadHasCompletedOnboarding()
|| shouldSkipOnboarding {
locationManager.startForegroundTracking()
// Geofences must follow the user even in the background:
// wire the delegate hook (fires on every fix incl. background
// significant-change wake-ups) to re-register the region
// window around the new position. SwiftUI onChange alone
// never runs while suspended, so alerts would otherwise stay
// frozen around the last foreground fix.
locationManager.onLocationUpdate = { [weak monitor] in
monitor?.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
// The Live Activity follows the same wake-ups this hook
// fires on every fix incl. background significant-change
// wake-ups, so the Lock Screen pill stays live while driving.
updateLiveActivity()
}
// Geofences and the Live Activity must follow the user even in
// the background: wire the delegate hook (fires on every fix
// incl. background significant-change wake-ups) so SwiftUI's
// foreground-only onChange isn't the only update path.
installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
monitor.setEnabled(alertsEnabled)
updateLiveActivity()
// Refresh only when the cache is stale (twice-a-day policy).
Task { await refresh() }
// Skipped under the force-* hooks so the banner stays up.
#if DEBUG
let shouldSkipInitialRefresh = args.contains("-forceOfflineDump")
|| args.contains("-forceConnectionProblem")
|| args.contains("-forceHistoryFailure")
#else
let shouldSkipInitialRefresh = false
#endif
if !shouldSkipInitialRefresh {
Task { await refresh() }
}
} else {
showOnboarding = true
}
@@ -231,6 +358,8 @@ struct ContentView: View {
// empty on a brand-new install.
if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
@@ -263,9 +392,16 @@ struct ContentView: View {
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
refreshRoadDistancesIfNeeded()
}
}
.onChange(of: selectedFuel) { _, _ in
)
}
private var interactionObservedContent: AnyView {
AnyView(
lifecycleObservedContent
.onChange(of: selectedFuel) { _, _ in
// No re-fetch needed one response carries E5/E10/DIESEL prices.
WidgetCenter.shared.reloadAllTimelines()
}
@@ -288,9 +424,7 @@ struct ContentView: View {
monitor.setEnabled(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
if newValue {
locationManager.startBackgroundTracking()
}
ensureBackgroundMotionTrackingIfNeeded()
}
.onChange(of: alertsRadius) { _, newValue in
FuelStore.saveAlertsRadius(newValue)
@@ -311,10 +445,17 @@ struct ContentView: View {
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
}
.onChange(of: favouriteDropAlertsEnabled) { _, newValue in
FuelStore.saveFavouriteDropAlertsEnabled(newValue)
}
.onChange(of: favouriteDropAlertsFuel) { _, newValue in
FuelStore.saveFavouriteDropAlertsFuel(newValue)
}
.onChange(of: liveActivityEnabled) { _, newValue in
// Toggling the Live Activity on starts it with the current best
// station; toggling off ends any running activity.
FuelStore.saveLiveActivityEnabled(newValue)
ensureBackgroundMotionTrackingIfNeeded()
updateLiveActivity()
}
.onChange(of: liveActivityFuel) { _, newValue in
@@ -342,13 +483,220 @@ struct ContentView: View {
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
}
}
.onChange(of: priceDisplayStyle) { _, newValue in
// The pill carries its own display style in content state push
// an update so the running activity re-renders in the new style
// immediately. Pass the style explicitly: SettingsView saves it
// in its own onChange, whose ordering vs this one isn't guaranteed,
// so reading storage here could push the OLD style.
updateLiveActivity(priceDisplayStyleOverride: newValue)
}
)
}
/// The one banner to show right now. Network/offline and tip outcomes all
/// funnel here and render through the SAME floating chrome. A transient
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
/// network state for its ~3.5 s.
private var activeBanner: AppBanner? {
if let outcome = tipStore.outcome { return .tip(outcome) }
switch dataStatus {
case .live: return nil
case .offlineDump(let date): return .offlineDump(date: date)
case .connectionProblem: return .connectionProblem
}
}
private var mainContent: some View {
GeometryReader { geo in
ZStack(alignment: .top) {
rootTabView
if let banner = currentBanner {
// Floating near the top of the screen, over the nav area
// overlays content (never pushes it) and sits above the
// main content so it doesn't cover or block the list/pill
// beneath it. Opacity/offset are driven explicitly by
// reflectBanner so the fade-out reliably animates.
floatingBanner(banner)
.padding(.top, geo.safeAreaInsets.top + 10)
.opacity(bannerOpacity)
.offset(y: bannerOffset)
}
}
}
.task(id: activeBanner) {
// Fires on launch with the current banner AND whenever it changes
// unlike onChange(computed) which can miss the first non-nil value
// when dataStatus is set slightly after the view appears.
reflectBanner(activeBanner)
}
}
/// Explicitly animates the banner in/out a robust replacement for the
/// SwiftUI removal transition (which was silently not firing). Handles:
/// - first appearance fade + settle down from slightly above
/// - any content change (tipnetwork, new tip) fade the old out, then
/// fade the new in so a tip ALWAYS visibly fades away
/// - dismissal fade out + slide up, then clear after the fade
private func reflectBanner(_ newBanner: AppBanner?) {
guard newBanner != currentBanner else { return }
bannerClearTask?.cancel()
if let newBanner {
if currentBanner == nil {
present(newBanner)
} else {
// Crossfade: slide+fade the current out, then present the new.
withAnimation(.easeOut(duration: 0.2)) {
bannerOpacity = 0
bannerOffset = -28
}
bannerClearTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 250_000_000)
guard !Task.isCancelled else { return }
present(newBanner)
}
}
} else {
withAnimation(.easeOut(duration: 0.35)) {
bannerOpacity = 0
bannerOffset = -28
}
bannerClearTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled else { return }
currentBanner = nil
bannerOpacity = 0
bannerOffset = 0
}
}
}
/// Fade a banner in from slightly above and settle it into place.
private func present(_ banner: AppBanner) {
currentBanner = banner
bannerOpacity = 0
bannerOffset = -16
withAnimation(.easeOut(duration: 0.3)) {
bannerOpacity = 1
bannerOffset = 0
}
}
private var rootTabView: some View {
TabView(selection: $selectedTab) {
stationsTab
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
.tag(0)
favouritesTab
.tabItem { Label("Favourites", systemImage: "star.fill") }
.tag(1)
alertsTab
.tabItem { Label("Alerts", systemImage: "bell.fill") }
.tag(2)
settingsTab
.tag(3)
}
}
/// The banner title for the bundled-snapshot case: date when the stamp
/// parsed, plain "Offline data" otherwise.
private func offlineTitle(date: String) -> String {
date.isEmpty
? NSLocalizedString("Offline data", comment: "")
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
}
/// ONE floating banner style for all notifications. Network/offline and
/// tip outcomes share the same chrome; only the tap action differs
/// network retry the fetch, tip dismiss (TipStore auto-dismisses too).
@ViewBuilder
private func floatingBanner(_ banner: AppBanner) -> some View {
switch banner {
case .offlineDump(let date):
let title = offlineTitle(date: date)
statusBannerCard(
icon: "wifi.slash", tint: .orange, title: title,
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
trailingIcon: "arrow.clockwise",
accessibilityLabel: date.isEmpty
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
) {
Task { await refresh(force: true) }
}
case .connectionProblem:
statusBannerCard(
icon: "wifi.exclamationmark", tint: .red,
title: NSLocalizedString("Check your internet connection", comment: ""),
subtitle: NSLocalizedString("Tap to try again", comment: ""),
trailingIcon: "arrow.clockwise",
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
) {
Task { await refresh(force: true) }
}
case .tip(let outcome):
statusBannerCard(
icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
trailingIcon: "xmark", accessibilityLabel: outcome.message
) {
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
}
}
}
/// The single floating-banner chrome shared by every notification
/// (network/offline + tip). Overlaid never in flow so it never moves
/// the content beneath it.
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
trailingIcon: String, accessibilityLabel: String,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: trailingIcon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.secondarySystemGroupedBackground))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(tint.opacity(0.35), lineWidth: 1)
)
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
)
.padding(.horizontal, 12)
}
.contentShape(Rectangle())
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
}
/// Pushes the current best-in-radius station into the Live Activity.
/// No-op (or ends the activity) when the toggle is off or there's no
/// location/data yet. Uses the Live Activity's OWN fuel + radius (set in
/// Settings) independent of the Stations-tab fuel/distance.
private func updateLiveActivity() {
private func updateLiveActivity(priceDisplayStyleOverride: PriceDisplayStyle? = nil) {
// "Follow search" mirrors the Stations-tab distance exactly (no cap
// the pill only displays, it doesn't geofence).
let radiusKM = liveActivityFollowsSearch
@@ -359,7 +707,69 @@ struct ContentView: View {
fuel: liveActivityFuel,
radiusKM: radiusKM,
location: location,
enabled: liveActivityEnabled
enabled: liveActivityEnabled,
priceDisplayStyle: priceDisplayStyleOverride
)
}
/// The Stations tab, extracted from `body` so the TabView expression stays
/// within the compiler's type-check budget.
private var stationsTab: some View {
StationsView(
stations: displayedStations,
totalCount: sortedStations.count,
isLoading: isLoading,
selectedFuel: $selectedFuel,
sortMode: $sortMode,
stationLimit: $stationLimit,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice,
topStationID: topStationID,
location: location,
favouriteIDs: favouriteIDs,
onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) }
)
}
/// The Favourites tab, extracted from `body` for the same type-check
/// budget reason.
private var favouritesTab: some View {
FavouritesView(
favourites: refreshedFavourites,
selectedFuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites,
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
onHistoryRecovered: { if dataStatus == .connectionProblem { dataStatus = .live } }
)
}
/// The Alerts tab, extracted from `body` so the TabView expression stays
/// within the compiler's type-check budget.
private var alertsTab: some View {
AlertsView(
enabled: $alertsEnabled,
radius: $alertsRadius,
fuel: $alertsFuel,
favouriteDropEnabled: $favouriteDropAlertsEnabled,
favouriteDropFuel: $favouriteDropAlertsFuel,
followsSearch: $alertsFollowsSearch,
liveActivityEnabled: $liveActivityEnabled,
liveActivityFuel: $liveActivityFuel,
liveActivityRadiusMiles: $liveActivityRadiusMiles,
liveActivityFollowsSearch: $liveActivityFollowsSearch,
stationLimit: stationLimit,
distanceUnit: distanceUnit,
monitoredCount: monitor.monitoredStationIDs.count,
lastAlert: monitor.lastAlert,
onCheckFavouriteDropNow: {
Task { await refresh(force: true) }
}
)
}
@@ -367,7 +777,9 @@ struct ContentView: View {
/// within the compiler's type-check budget.
private var settingsTab: some View {
SettingsView(
tipStore: tipStore,
distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle,
alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult,
@@ -378,7 +790,12 @@ struct ContentView: View {
debugStatus: monitor.debugStatus,
appCheapest: appCheapestStatus,
regionError: monitor.lastRegionError,
monitoredCount: monitor.monitoredStationIDs.count
monitoredCount: monitor.monitoredStationIDs.count,
alertLog: monitor.alertLog,
regionEventCount: monitor.regionEventCount,
debugFenceIdentifier: monitor.debugFenceIdentifier,
onDebugFence: { monitor.registerDebugFenceAroundMe() },
onClearDebugFence: { monitor.clearDebugFence() }
)
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
}
@@ -416,6 +833,7 @@ struct ContentView: View {
isLoading = true
defer { isLoading = false }
do {
let previousFavouriteSnapshots = FuelStore.loadFavouriteAlertSnapshots()
let fetched = try await FuelPriceProvider.active.fetchStations(
near: location?.lat, lng: location?.lng,
fuel: selectedFuel,
@@ -424,9 +842,10 @@ struct ContentView: View {
stations = fetched
FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh()
// Persist relay envelope metadata (source, station count, GOV.UK
// dataset update time) for the Settings About section.
if let meta = RelayFuelProvider.latestMeta {
// Persist envelope metadata (source, station count, GOV.UK
// dataset update time) for the Settings About section the
// live chain records whichever leg served the fetch.
if let meta = LiveChainProvider.latestMeta {
FuelStore.saveRelayMeta(meta)
}
// Keep the keychain favourites fresh with the new prices the
@@ -434,11 +853,35 @@ struct ContentView: View {
// channel shared on SideStore free), so stale star-time snapshots
// would otherwise show old prices.
FuelStore.saveFavourites(refreshedFavourites)
monitor.evaluateFavouritePriceDropAlert(
favourites: refreshedFavourites,
previousSnapshots: previousFavouriteSnapshots,
monitoredFuel: favouriteDropAlertsFuel,
enabled: favouriteDropAlertsEnabled
)
WidgetCenter.shared.reloadAllTimelines()
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
// Live data restored any status banner no longer applies.
dataStatus = .live
} catch {
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached/sample data."
stations = FuelStore.loadStations().isEmpty ? SampleFuelProvider.sampleStations : FuelStore.loadStations()
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
if FuelStore.loadStations().isEmpty {
// No cached prices last resort is the bundled REAL dump
// (stale but genuine), then the demo sample set. The banner
// labels the bundled snapshot honestly with its own date.
if let dump = BundledDumpProvider.stations {
stations = dump
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
} else {
stations = SampleFuelProvider.sampleStations
dataStatus = .live
}
} else {
// Saved prices are still on screen but the fetch failed, so
// say so: a stale cache must not look like a live app.
stations = FuelStore.loadStations()
dataStatus = .connectionProblem
}
}
// Keep monitor geofences in sync with the freshest data.
monitor.update(stations: stations, favourites: refreshedFavourites,
@@ -455,6 +898,7 @@ struct StationRow: View {
let fuel: FuelType
let location: Coordinate?
let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let baselinePrice: Double?
let isTopResult: Bool
let isFavourite: Bool
@@ -489,14 +933,14 @@ struct StationRow: View {
.padding(5)
} else {
Image(systemName: "fuelpump.fill")
.font(.system(size: 22))
.foregroundStyle(.white)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.black.opacity(0.82))
}
}
.frame(width: 46, height: 46)
.background(Circle().fill(.white))
.background(Circle().fill(Color.white))
.clipShape(Circle())
.overlay(Circle().stroke(Color.primary.opacity(0.08), lineWidth: 1))
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
// MIDDLE name, full address, distance
@@ -521,7 +965,7 @@ struct StationRow: View {
.lineLimit(1)
.truncationMode(.tail)
if let location {
Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
@@ -536,9 +980,7 @@ struct StationRow: View {
Circle()
.fill(ragColor)
.frame(width: 8, height: 8)
Text(String(format: "£%.3f", price / 100))
.font(.title3.bold().monospaced())
.monospacedDigit()
FuelStore.priceTextAttributed(price, style: priceDisplayStyle, size: 20, weight: .bold)
}
if let deltaText {
Text(deltaText)
+43 -2
View File
@@ -13,9 +13,14 @@ struct FavouritesView: View {
let selectedFuel: FuelType
let location: Coordinate?
let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
/// Propagated from ContentView Trends' history failure with no data
/// raises the global connection banner; recovery clears it.
var onHistoryUnavailable: (() -> Void)? = nil
var onHistoryRecovered: (() -> Void)? = nil
/// Fuel types that currently have at least one favourite these are the
/// only tabs shown (a fuel with no favourites gets no tab).
@@ -29,6 +34,9 @@ struct FavouritesView: View {
/// favourites (e.g. the last one is un-starred while viewing it).
@State private var fuel: FuelType = .e10
/// Trends sheet (price history chart) presentation state.
@State private var showTrends = false
private var activeFuel: FuelType {
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
}
@@ -56,14 +64,20 @@ struct FavouritesView: View {
selectedFuel: FuelType,
location: Coordinate?,
distanceUnit: DistanceUnit,
priceDisplayStyle: PriceDisplayStyle,
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }) {
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
onHistoryUnavailable: (() -> Void)? = nil,
onHistoryRecovered: (() -> Void)? = nil) {
self.favourites = favourites
self.selectedFuel = selectedFuel
self.location = location
self.distanceUnit = distanceUnit
self.priceDisplayStyle = priceDisplayStyle
self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder
self.onHistoryUnavailable = onHistoryUnavailable
self.onHistoryRecovered = onHistoryRecovered
_fuel = State(initialValue: selectedFuel)
}
@@ -111,6 +125,7 @@ struct FavouritesView: View {
fuel: activeFuel,
location: location,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: cheapestPrice,
isTopResult: index == 0,
isFavourite: activeFuelFavouriteIDs.contains(station.id),
@@ -136,9 +151,35 @@ struct FavouritesView: View {
.navigationTitle("Favourites")
.toolbar {
if !favourites.isEmpty {
EditButton()
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
showTrends = true
} label: {
Image(systemName: "chart.xyaxis.line")
.accessibilityLabel("Trends")
}
EditButton()
}
}
}
.sheet(isPresented: $showTrends) {
TrendsView(
favourites: favourites,
selectedFuel: activeFuel,
priceDisplayStyle: priceDisplayStyle,
onHistoryUnavailable: onHistoryUnavailable,
onHistoryRecovered: onHistoryRecovered
)
}
.onAppear {
#if DEBUG
// QA hook: launch with `-showTrends` to open the sheet
// without a tap (same pattern as -showKeySheet).
if ProcessInfo.processInfo.arguments.contains("-showTrends") {
showTrends = true
}
#endif
}
}
}
}
+43
View File
@@ -1,7 +1,22 @@
import ActivityKit
import SwiftUI
@main
struct FuelBoardApp: App {
init() {
#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
// layout can be rendered in the Simulator for visual QA.
let args = ProcessInfo.processInfo.arguments
if let idx = args.firstIndex(of: "-qaLiveActivity"),
args.indices.contains(idx + 1),
let fuel = FuelType(rawValue: args[idx + 1]) {
startQALiveActivity(fuel: fuel)
}
#endif
}
var body: some Scene {
WindowGroup {
ContentView()
@@ -9,6 +24,34 @@ struct FuelBoardApp: App {
}
}
#if DEBUG
private func startQALiveActivity(fuel: FuelType) {
let state = FuelBoardLiveActivityAttributes.ContentState(
fuel: fuel,
stationID: "qa-phoenix",
stationName: "Phoenix Filling Stations",
brand: "Phoenix",
pricePence: 1499,
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
distanceKM: 8.0,
lat: 51.5,
lng: -0.12,
updatedAt: Date()
)
let attrs = FuelBoardLiveActivityAttributes()
do {
let activity = try Activity.request(
attributes: attrs,
content: .init(state: state, staleDate: nil),
pushType: nil
)
print("QA-LIVE-ACTIVITY STARTED id=\(activity.id)")
} catch {
print("QA-LIVE-ACTIVITY FAILED: \(error)")
}
}
#endif
/// Handles deep links that end up in the app. Widget taps arrive here in
/// two cases:
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
+6 -2
View File
@@ -28,12 +28,15 @@ enum LiveActivityManager {
/// Recomputes the cheapest-in-radius station and updates the activity.
/// Passing `enabled: false` (or no usable data) ends any running activity.
/// `priceDisplayStyle` overrides the saved style (used when the setting
/// just changed and the save may not have landed yet); nil = read storage.
static func update(
stations: [FuelStation],
fuel: FuelType,
radiusKM: Double,
location: Coordinate?,
enabled: Bool
enabled: Bool,
priceDisplayStyle: PriceDisplayStyle? = nil
) {
guard enabled, let location else {
end()
@@ -53,7 +56,8 @@ enum LiveActivityManager {
stationName: best.name,
brand: best.brand,
pricePence: price,
distanceKM: best.distanceKM(to: location.lat, lng2: location.lng),
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
lat: best.lat,
lng: best.lng,
updatedAt: Date()
+144 -222
View File
@@ -47,40 +47,30 @@ struct OnboardingView: View {
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
// Auto-advance once the user grants a permission the prompt itself
// only fires when the Continue button is tapped (after reading the
// page's description), never when the page merely appears.
// Auto-advance once the user grants a permission.
.onChange(of: prompter.locationGranted) { _, granted in
if granted, page == 1 { page = 2 }
}
.onChange(of: prompter.notificationsGranted) { _, granted in
if granted, page == 2 { page = 3 }
}
.onChange(of: prompter.dataGranted) { _, granted in
if granted, page == 3 {
// The probe result lands while the Local Network prompt is
// still dismissing, so without a beat the green "Connected"
// tick is skipped and the page jumps straight to Done
// unlike the Location/Notifications pages where the granted
// tick is visible. Hold the tick on screen, then advance.
Task { @MainActor in
try? await Task.sleep(nanoseconds: 800_000_000)
if page == 3 { page = 4 }
}
}
}
// Replay path (test button / returning user): the Local Network
// permission is already decided, so probe silently up front. The
// probe fires no prompt once the permission is granted or denied
// only the FIRST attempt (undetermined) shows the system alert, and
// that only happens on first run. By the time the data page appears
// its status is known, so it shows the green tick (or denied text)
// immediately, matching the Location and Notifications pages.
.onAppear {
if FuelStore.loadHasCompletedOnboarding() {
prompter.preflightDataAccess()
// Fire each permission when the user LEAVES its page by advancing
// forward (swipe or Continue) by then the screen has been read.
// Never on arrival: a fast swipe can't stack prompts before a page
// is seen. Forward-only: swiping back never fires. Idempotent
// an already-determined permission just refreshes, so the
// auto-advance path and replays stay clean (no duplicate alerts).
.onChange(of: page) { oldPage, newPage in
guard newPage > oldPage else { return }
if oldPage == 1 {
prompter.requestLocation()
} else if oldPage == 2 {
prompter.requestNotifications()
}
}
// No data-permission step: prices download from the internet (GitHub
// mirror) with no permission at all Local Network was removed from
// the app when the relay left the consumer chain.
.background(
LinearGradient(
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
@@ -95,7 +85,7 @@ struct OnboardingView: View {
private var welcomePage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
AnimatedOnboardingIcon(tint: .accentColor) {
// The app icon itself, with the standard iOS rounded-corner
// treatment (~22% of the size), so onboarding matches the
// Home Screen.
@@ -118,7 +108,7 @@ struct OnboardingView: View {
.padding(.horizontal, 32)
.padding(.top, 12)
featureRow(icon: "globe.europe.africa.fill", text: "England-wide prices — 8,000+ stations, updated twice a day")
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")
@@ -132,8 +122,7 @@ struct OnboardingView: View {
private var locationPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
Image(systemName: "location.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.blue)
@@ -164,8 +153,7 @@ struct OnboardingView: View {
private var notificationsPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
Image(systemName: "bell.badge.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.orange)
@@ -196,8 +184,7 @@ struct OnboardingView: View {
private var dataPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.teal)
@@ -207,47 +194,17 @@ struct OnboardingView: View {
Text("Prices, ready when you are")
.font(.largeTitle.bold())
Text("FuelBoard downloads the latest prices from a relay on your local network — the full UK dataset, refreshed twice a day. Local network access is needed for that first download.")
Text("FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.top, 12)
dataStatusLabel
.padding(.top, 24)
Spacer()
}
}
@ViewBuilder
private var dataStatusLabel: some View {
if prompter.dataLoading {
VStack(spacing: 10) {
ProgressView()
Text("Waiting for network permission…")
.font(.footnote)
.foregroundStyle(.secondary)
}
} else if prompter.dataGranted {
// Same confirmation styling as the Location/Notifications pages.
Label("Connected", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.subheadline)
} else if prompter.dataDenied {
Text("Local network access was denied — prices won't load until it's allowed, but you can keep using FuelBoard.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
} else {
Text("The system prompt will appear next.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var donePage: some View {
VStack(spacing: 0) {
Spacer()
@@ -288,39 +245,31 @@ struct OnboardingView: View {
) {
if prompter.locationDenied {
openSettings()
} else if !prompter.locationGranted {
// The system prompt fires HERE after the user has read
// the description and tapped Continue not on page appear.
prompter.requestLocation()
} else if prompter.locationGranted {
page = 2 // already decided just move on
} else {
page = 2
// Fire the system prompt NOW, while this page is still on
// 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.
prompter.requestLocation()
}
}
case 2:
primaryButton(
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
) {
if prompter.notificationsDenied {
page = 3
} else if !prompter.notificationsGranted {
prompter.requestNotifications()
if prompter.notificationsDenied || prompter.notificationsGranted {
page = 3 // decided either way move on
} else {
page = 3
// Same rule as Location: prompt now, before advancing.
prompter.requestNotifications()
}
}
case 3:
primaryButton(prompter.dataLoading ? "Waiting…" : "Continue") {
if prompter.dataGranted || prompter.dataDenied {
// Prompt answered (allowed or denied) move on to the
// final slide. Never dead-end on an Open Settings button.
page = 4
} else if !prompter.dataLoading {
// Fires the Local Network prompt + relay probe here,
// after the user has read the page and tapped Continue.
prompter.requestDataAccess()
}
}
.disabled(prompter.dataLoading)
// No permission on this page prices need none. Straight on.
primaryButton("Continue") { page = 4 }
default:
primaryButton("Start Using FuelBoard") { finish() }
}
@@ -339,15 +288,15 @@ struct OnboardingView: View {
private func featureRow(icon: String, text: LocalizedStringKey) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 28)
.frame(width: 32)
Text(text)
.font(.subheadline)
Spacer(minLength: 0)
}
.padding(.horizontal, 36)
.padding(.vertical, 5)
.padding(.vertical, 10)
}
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: LocalizedStringKey) -> some View {
@@ -382,21 +331,107 @@ struct OnboardingView: View {
}
}
private struct AnimatedOnboardingIcon<Content: View>: View {
enum Motion {
case gentle
case locationArrow
case alarmBell
case downloadArrow
}
let tint: Color
var motion: Motion = .gentle
@ViewBuilder var content: Content
@State private var settled = false
@State private var iconRotation: Double = 0
@State private var iconOffsetY: CGFloat = 0
@State private var iconScale: CGFloat = 0.92
var body: some View {
ZStack {
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)
content
.scaleEffect(iconScale)
.rotationEffect(.degrees(iconRotation))
.offset(y: iconOffsetY)
}
.shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2)
.animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled)
.onAppear {
playAnimation()
}
}
private func playAnimation() {
settled = false
iconRotation = 0
iconOffsetY = 4
iconScale = 0.92
switch motion {
case .gentle:
withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
settled = true
iconOffsetY = 0
iconScale = 1.0
}
case .locationArrow:
iconRotation = -120
withAnimation(.linear(duration: 0.45)) {
settled = true
iconRotation = 24
iconOffsetY = 0
iconScale = 1.02
}
withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) {
iconRotation = 0
iconScale = 1.0
}
case .alarmBell:
settled = true
iconOffsetY = 0
iconScale = 1.0
withAnimation(.linear(duration: 0.10)) { iconRotation = -14 }
withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 }
withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 }
withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 }
withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 }
case .downloadArrow:
iconOffsetY = -22
withAnimation(.easeIn(duration: 0.22)) {
settled = true
iconScale = 1.0
iconOffsetY = 8
}
withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) {
iconOffsetY = 0
}
}
}
}
/// Owns the system permission requests during onboarding and publishes
/// their outcomes so the pages can reflect them live.
@MainActor
final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
final class OnboardingPermissionPrompter: NSObject, ObservableObject, CLLocationManagerDelegate {
@Published private(set) var locationGranted = false
@Published private(set) var locationDenied = false
@Published private(set) var notificationsGranted = false
@Published private(set) var notificationsDenied = false
@Published private(set) var dataGranted = false
@Published private(set) var dataDenied = false
@Published private(set) var dataLoading = false
/// Held while the Local Network permission prompt is pending keeps the
/// bare TCP connect alive until the user answers (nil after resolve).
private var dataConnection: NWConnection?
private let manager = CLLocationManager()
@@ -421,11 +456,14 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
Task { @MainActor in
switch settings.authorizationStatus {
case .notDetermined:
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
Task { @MainActor in
self.notificationsGranted = granted
self.notificationsDenied = !granted
}
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge])
self.notificationsGranted = granted
self.notificationsDenied = !granted
} catch {
self.notificationsGranted = false
self.notificationsDenied = true
}
case .authorized:
self.notificationsGranted = true
@@ -438,125 +476,9 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
}
}
/// Triggers the Local Network permission prompt WITHOUT loading any data.
/// iOS shows the prompt on the first attempt to reach a local-network
/// address, so a bare TCP connect to the relay host:port is enough no
/// HTTP request, no payload, nothing to wait on. Once the user answers
/// the prompt the connect either succeeds (granted) or fails (denied);
/// either way onboarding proceeds. The full dataset download happens
/// AFTER onboarding completes (ContentView's forced refresh).
func requestDataAccess() {
guard !dataLoading else { return }
dataLoading = true
dataDenied = false
let baseURL = RelayFuelProvider().baseURL
guard let host = baseURL.host,
let port = baseURL.port else {
dataLoading = false
dataDenied = true
return
}
let connection = NWConnection(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: UInt16(port))!,
using: .tcp
)
dataConnection = connection
connection.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self else { return }
switch state {
case .ready:
// Connect succeeded local network allowed.
self.dataConnection = nil
self.dataLoading = false
self.dataGranted = true
self.dataDenied = false
case .failed, .cancelled:
// Connect failed (denied, or relay unreachable). Either
// way we do NOT dead-end: onboarding continues.
self.dataConnection = nil
self.dataLoading = false
self.dataGranted = false
self.dataDenied = true
default:
break // .preparing / .waiting prompt pending, hold on
}
}
}
connection.start(queue: .main)
// Safety timeout: if the prompt is ignored or the connect stalls, drop
// back to the enabled Continue button instead of an endless spinner.
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 12_000_000_000)
guard let self, self.dataLoading else { return }
self.dataLoading = false
self.dataGranted = false
self.dataDenied = true
self.dataConnection = nil
connection.cancel()
}
}
/// Replay pre-flight: determines the Local Network status WITHOUT any
/// user-visible check. Once the permission is decided (granted or denied)
/// a probe resolves silently granted connects in milliseconds, denied
/// reports `.waiting` with `unsatisfiedReason == .localNetworkDenied`.
/// Only the FIRST-ever attempt (undetermined) shows the system alert; in
/// that case we cancel and leave the state unknown so the data page's
/// Continue triggers the prompt in context. First run never calls this.
func preflightDataAccess() {
guard !dataLoading, !dataGranted, !dataDenied else { return }
dataLoading = true
let baseURL = RelayFuelProvider().baseURL
guard let host = baseURL.host,
let port = baseURL.port else {
dataLoading = false
return
}
let connection = NWConnection(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: UInt16(port))!,
using: .tcp
)
dataConnection = connection
connection.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self else { return }
switch state {
case .ready:
self.dataConnection = nil
self.dataLoading = false
self.dataGranted = true
self.dataDenied = false
case .waiting:
if connection.currentPath?.unsatisfiedReason == .localNetworkDenied {
// Denied in Settings resolved silently, no prompt.
self.dataConnection = nil
self.dataLoading = false
self.dataGranted = false
self.dataDenied = true
} else {
// Prompt pending (permission undetermined only
// possible after a reinstall, where the onboarding
// flag persisted but the permission reset). Cancel so
// page 4's Continue fires the prompt in context.
self.dataConnection = nil
self.dataLoading = false
connection.cancel()
}
case .failed, .cancelled:
self.dataConnection = nil
self.dataLoading = false
self.dataGranted = false
self.dataDenied = true
default:
break // .preparing probe starting, hold on
}
}
}
connection.start(queue: .main)
}
/// Replay pre-flight: NO data permission exists anymore prices download
/// from the internet with no prompt, so there is nothing to preflight.
/// (The LAN relay left the consumer chain; Local Network is gone.)
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
Task { @MainActor in
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeLocation</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeDiagnostics</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+186 -7
View File
@@ -18,6 +18,27 @@ struct DebugLocationStatus: Equatable {
var cheapestDistanceKM: Double?
}
/// One recorded event in the live alert trace (Settings Debug). Lets the
/// tester see exactly which stage of the alert chain ran whether a region
/// event arrived at all, which gate (if any) suppressed the alert, and when
/// an alert was scheduled. In-memory only: resets on relaunch.
struct AlertLogEntry: Identifiable, Equatable {
enum Kind: String, Equatable {
/// A geofence entry event reached the monitor (didEnterRegion).
case entry
/// A gate blocked the alert (dedup, not-cheapest, station missing).
case gate
/// A non-recoverable failure (fresh-price fetch threw).
case error
/// An alert was scheduled with UNUserNotificationCenter.
case fired
}
let id = UUID()
let date: Date
let kind: Kind
let text: String
}
/// App-view cheapest for the Debug section: mirrors EXACTLY what the Stations
/// list computes (selected fuel, stationLimit radius, current location), so
/// the debug "Cheapest in range" row matches the TOP badge in the app
@@ -58,6 +79,7 @@ struct TieChoice {
/// entering the winner's circle not at the forecourt.
@MainActor
final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate {
private static let favouriteDropThresholdPence: Double = 1.0
@Published var monitoredStationIDs: [String] = []
@Published var lastAlert: String?
@Published private(set) var lastTestResult: String?
@@ -69,6 +91,17 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// Debug so a silently-failed `startMonitoring` (region budget exceeded,
/// auth not granted, radius too large) is visible instead of invisible.
@Published private(set) var lastRegionError: String?
/// Live alert trace (Settings Debug): every stage the chain reached,
/// newest first, capped at 8. Empty = no region event has ever arrived.
@Published private(set) var alertLog: [AlertLogEntry] = []
/// How many `didEnterRegion` callbacks this session proof the geofences
/// actually fire (vs. a gate silently blocking every alert).
@Published private(set) var regionEventCount = 0
/// Identifier of the armed debug fence (Settings Debug "Register
/// 100 m fence"), nil when not armed. The fence is a 100 m circle at the
/// current location that exists only to prove iOS delivers region events
/// on this install; entering it fires a plain notification, no gates.
@Published private(set) var debugFenceIdentifier: String?
private let manager = CLLocationManager()
private var stations: [FuelStation] = []
@@ -119,7 +152,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
refreshDebugStatus()
for region in manager.monitoredRegions {
// Drop every region EXCEPT the debug fence (if armed) the fence is
// a fixed 100 m circle at a fixed point that must survive re-registration
// churn; stations re-register from scratch every pass.
for region in manager.monitoredRegions where !region.identifier.hasPrefix("debug-fence") {
manager.stopMonitoring(for: region)
}
monitoredStationIDs = []
@@ -173,6 +209,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
manager.stopMonitoring(for: region)
}
monitoredStationIDs = []
debugFenceIdentifier = nil
} else {
requestPermissions()
// Re-register geofences from restored cache immediately.
@@ -182,6 +219,88 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
var isEnabled: Bool { enabled }
/// 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.
func evaluateFavouritePriceDropAlert(
favourites: [FavouriteEntry],
previousSnapshots: [FuelType: FavouriteAlertSnapshot],
monitoredFuel: FuelType,
enabled: Bool
) {
var snapshots = previousSnapshots
guard let current = FuelStore.cheapestFavourite(in: favourites, fuel: monitoredFuel),
let currentPrice = current.station.prices[monitoredFuel] else {
snapshots.removeValue(forKey: monitoredFuel)
FuelStore.saveFavouriteAlertSnapshots(snapshots)
return
}
let currentSnapshot = FavouriteAlertSnapshot(
fuel: monitoredFuel,
stationID: current.station.id,
stationName: current.station.name,
price: currentPrice
)
defer {
snapshots[monitoredFuel] = currentSnapshot
FuelStore.saveFavouriteAlertSnapshots(snapshots)
}
guard enabled else { return }
guard let previous = previousSnapshots[monitoredFuel] else { return }
let winnerChanged = previous.stationID != currentSnapshot.stationID
let priceDropped = previous.stationID == currentSnapshot.stationID
&& currentSnapshot.price <= previous.price - Self.favouriteDropThresholdPence
guard winnerChanged || priceDropped else { return }
fireFavouriteDropAlert(current: current, previous: previous, price: currentPrice, winnerChanged: winnerChanged)
}
/// Records a stage in the live alert trace, newest first, capped at 8.
private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) {
alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0)
if alertLog.count > 8 { alertLog.removeLast() }
}
// MARK: - Debug fence
/// Debug-only (Settings Debug "Register 100 m fence"): arms a 100 m
/// circle around the last known fix purely to prove iOS delivers region
/// events on this install. Stepping 100 m away and back should tick
/// "Region events this session" and fire a plain notification no alert
/// gates apply. If that produces nothing, region delivery is broken at the
/// system level and no amount of alert-logic fixing will help.
func registerDebugFenceAroundMe() {
guard let fix = FuelStore.loadLocationWithDate() else {
logAlert(.error, "debug fence — no location fix yet (wait for one, then retry)")
return
}
clearDebugFence()
let id = "debug-fence-\(UUID().uuidString.prefix(8))"
let region = CLCircularRegion(
center: CLLocationCoordinate2D(latitude: fix.coordinate.lat, longitude: fix.coordinate.lng),
radius: 100,
identifier: id
)
region.notifyOnEntry = true
region.notifyOnExit = false
manager.startMonitoring(for: region)
debugFenceIdentifier = id
logAlert(.entry, "debug fence armed — 100 m circle at current location (step away and back in)")
}
func clearDebugFence() {
guard let id = debugFenceIdentifier else { return }
for region in manager.monitoredRegions where region.identifier == id {
manager.stopMonitoring(for: region)
}
debugFenceIdentifier = nil
logAlert(.gate, "debug fence cleared")
}
/// Recomputes the Settings Debug location snapshot from current state.
/// "In range" uses the SAME criteria as live alerts: stations selling the
/// monitored fuel within the alert radius of the last known location.
@@ -250,6 +369,20 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
nonisolated func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
Task { @MainActor in
// Trace: prove the geofence event itself arrived. This is the
// single point that distinguishes "no region delivery" from
// "delivery fine, a gate blocked the alert".
self.regionEventCount += 1
self.logAlert(.entry, "didEnterRegion — \(region.identifier)")
// Debug fence (Settings Debug): exists only to prove iOS
// delivers region events on this install. Entering it fires a
// plain notification with no alert gates if the counter ticks
// and a banner shows after stepping out and back, delivery works.
if region.identifier.hasPrefix("debug-fence") {
self.logAlert(.fired, "debug fence entered — iOS delivered the region event (100 m)")
self.sendPlainTestNotification()
return
}
self.handleEntry(region)
}
}
@@ -277,10 +410,16 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
// Fall back to cached data on background wake (stations may not be
// fetched yet when the app is relaunched by a region event).
if stations.isEmpty { stations = FuelStore.loadStations() }
guard let station = stations.first(where: { $0.id == stationID }) else { return }
guard let station = stations.first(where: { $0.id == stationID }) else {
logAlert(.gate, "region \(stationID) — station not in loaded list (Stations tab not fetched yet?)")
return
}
// Dedup: one alert per station per hour.
if let last = lastNotified[stationID], Date().timeIntervalSince(last) < 3600 { return }
if let last = lastNotified[stationID], Date().timeIntervalSince(last) < 3600 {
logAlert(.gate, "\(station.name) — dedup: already alerted \(Int(Date().timeIntervalSince(last) / 60)) min ago")
return
}
Task {
do {
@@ -293,9 +432,16 @@ 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]! }),
cheapest.id == station.id,
let price = cheapest.prices[fuel] else { return }
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(
@@ -306,7 +452,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
fireAlert(for: cheapest, price: price, ties: tied)
lastNotified[stationID] = Date()
} catch {
// Silent geofence state stays valid for next entry.
// Trace the silent catch a relay failure drops the alert
// with no banner, which previously looked like "no alert".
logAlert(.error, "\(station.name) — fresh-price fetch failed: \(error.localizedDescription)")
}
}
}
@@ -341,9 +489,35 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.body = "\(station.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
addAlertRequest(content: content, station: station)
}
logAlert(.fired, "alert scheduled — \(brand) · \(String(format: "%.1fp", price))")
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away"
}
private func fireFavouriteDropAlert(
current: FavouriteEntry,
previous: FavouriteAlertSnapshot,
price: Double,
winnerChanged: Bool
) {
let content = UNMutableNotificationContent()
content.sound = .default
let fuel = current.fuel
let previousPriceText = String(format: "%.1fp", previous.price)
let currentPriceText = String(format: "%.1fp", price)
if winnerChanged {
content.title = "New cheapest \(fuel.displayName.lowercased()) favourite"
content.body = "\(current.station.name) is now your cheapest favourite at \(currentPriceText), ahead of \(previous.stationName)."
} else {
content.title = "Cheapest favourite just dropped"
content.body = "\(current.station.name) fell from \(previousPriceText) to \(currentPriceText) for \(fuel.displayName.lowercased())."
}
addAlertRequest(content: content, station: current.station)
logAlert(.fired, "favourite alert scheduled — \(current.station.name) · \(currentPriceText)")
lastAlert = "Favourite · \(current.station.name) · \(currentPriceText)"
}
/// Adds a notification whose tap opens directions to `station`. The
/// station's coordinates ride in `userInfo` so the tap handler can route
/// to Apple Maps (or an in-app fallback), and a map snapshot with a pin is
@@ -531,6 +705,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.body = "\(choices.count) stations at \(String(format: "%.1fp", price)) within \(unit.format(radiusKM)). Pick one for directions."
let baseResult = "\(fuel.displayName) · \(choices.count) tied at \(String(format: "%.1fp", price)) · radius \(unit.format(radiusKM))"
lastTestResult = baseResult
logAlert(.fired, "test alert scheduled — \(choices.count) tied at \(String(format: "%.1fp", price))")
addAlertRequest(content: content, station: candidate, ties: choices) { status in
self.lastTestResult = "\(baseResult) · \(status)"
}
@@ -539,6 +714,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
let baseResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))"
lastTestResult = baseResult
logAlert(.fired, "test alert scheduled — \(brand) · \(String(format: "%.1fp", price))")
addAlertRequest(content: content, station: candidate) { status in
self.lastTestResult = "\(baseResult) · \(status)"
}
@@ -555,6 +731,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
logAlert(.fired, "test alert scheduled — no \(fuel.displayName.lowercased()) candidate, fallback copy sent")
lastTestResult = sellers.isEmpty
? "No stations loaded — open the Stations tab first"
: "No \(fuel.displayName.lowercased()) seller within \(unit.format(radiusKM))"
@@ -588,6 +765,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.title = "\(brand) — plain test notification"
content.body = "\(nearest.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
content.sound = .default
logAlert(.fired, "plain test notification scheduled — \(brand)")
addAlertRequest(content: content, station: nearest)
} else {
content.title = "FuelBoard test notification"
@@ -595,6 +773,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
logAlert(.fired, "plain test notification scheduled — no station data")
}
}
+93
View File
@@ -0,0 +1,93 @@
// RoadDistanceService.swift computes Apple-Maps-matched ROAD distances for
// nearby stations and caches them (keychain) so widgets + Live Activity can
// show real driving distance instead of straight-line haversine.
//
// Runs only in the APP: MKDirections is network-bound and the widget extension
// has a tiny execution budget + a ~40-70/day refresh budget, so routing belongs
// 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.
import Foundation
import MapKit
import WidgetKit
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
/// 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).
static let throttleMinutes: Double = 10
/// Recompute when the user moves more than this (metres) from the last
/// source location.
static let moveThresholdMeters: Double = 400
/// 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 }
// Throttle: keep cached values when fresh and the user hasn't moved far.
if let cache = FuelStore.loadRoadDistances() {
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
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 }
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
var entries: [String: Double] = [:]
for station in nearest {
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
if let meters = await roadMeters(from: origin, to: dest) {
entries[station.id] = meters
}
}
guard !entries.isEmpty else { return }
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
// Wake the widgets so the new road distances surface immediately.
WidgetCenter.shared.reloadAllTimelines()
}
/// 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.transportType = .automobile
request.requestsAlternateRoutes = false
do {
let response = try await MKDirections(request: request).calculate()
return response.routes.first?.distance
} catch {
return nil
}
}
/// Straight-line haversine distance between two coordinates, in metres.
private static func haversineMeters(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
let r = 6371000.0
let dLat = (lat2 - lat1) * .pi / 180
let dLng = (lng2 - lng1) * .pi / 180
let a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
sin(dLng / 2) * sin(dLng / 2)
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
}
+283 -58
View File
@@ -13,7 +13,11 @@ import WidgetKit
/// freshness) is debug-only too moved here so release builds don't expose
/// relay plumbing.
struct SettingsView: View {
/// Shared tip-purchase store owned by ContentView so its outcome can
/// ride the app-wide status banner (above the tabs), not a local card.
@ObservedObject var tipStore: TipStore
@Binding var distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle
/// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say.
var alertsFuel: FuelType = .e10
@@ -40,14 +44,25 @@ struct SettingsView: View {
/// Live geofence count (monitor.monitoredStationIDs.count) Debug-only
/// status; the user-facing Alerts tab no longer exposes fence plumbing.
var monitoredCount: Int = 0
/// Live alert trace (monitor.alertLog) every stage the alert chain
/// reached this session (region event / gate / scheduled). Debug-only.
var alertLog: [AlertLogEntry] = []
/// Region events received this session (monitor.regionEventCount).
var regionEventCount: Int = 0
/// Debug fence state (monitor.debugFenceIdentifier) armed vs cleared.
var debugFenceIdentifier: String?
/// Arms a 100 m fence at the current location (monitor.registerDebugFenceAroundMe).
var onDebugFence: () -> Void = {}
/// Clears the armed fence (monitor.clearDebugFence).
var onClearDebugFence: () -> Void = {}
@StateObject private var tipStore = TipStore()
@State private var showTipAlert = false
@State private var tipAlertMessage = ""
@State private var testAlertMessage: String?
/// 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()
@State private var versionTapCount = 0
@State private var lastVersionTap = Date.distantPast
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
@@ -89,17 +104,27 @@ struct SettingsView: View {
FuelStore.saveDistanceUnit(newValue)
WidgetCenter.shared.reloadAllTimelines()
}
Picker("Price display", selection: $priceDisplayStyle) {
ForEach(PriceDisplayStyle.allCases) { style in
Text(style.displayName).tag(style)
}
}
.pickerStyle(.segmented)
.onChange(of: priceDisplayStyle) { _, newValue in
FuelStore.savePriceDisplayStyle(newValue)
WidgetCenter.shared.reloadAllTimelines()
}
} header: {
Text("Units")
} footer: {
Text("Distances and search radii across the app, widget and alerts are shown in this unit.")
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 {
Button {
onShowOnboarding()
} label: {
Label("Show introduction", systemImage: "sparkles")
Label("Show introduction", systemImage: "arrow.clockwise.circle")
}
} footer: {
Text("Replay the welcome screen, including the location and notification permission prompts.")
@@ -107,6 +132,10 @@ struct SettingsView: View {
if debugMode {
Section {
Toggle("LAN relay fallback (dev)", isOn: $relayFallbackEnabled)
.onChange(of: relayFallbackEnabled) { _, enabled in
FuelStore.saveRelayFallbackEnabled(enabled)
}
Button {
sendTestAlert()
} label: {
@@ -200,7 +229,7 @@ struct SettingsView: View {
} header: {
Text("Data source")
} footer: {
Text("Connection shows whether prices come from the official Fuel Finder API or the CSV mirror. Data updated is the latest price change reported by the GOV.UK server itself.")
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 {
@@ -217,26 +246,103 @@ struct SettingsView: View {
} header: {
Text("Geofence monitoring")
}
Section {
if debugFenceIdentifier != nil {
LabeledContent("Debug fence", value: "armed (100 m)")
Text("Step 100 m away from your current position, then walk back in. 'Region events this session' should tick and a plain notification fires — proof iOS delivers region events on this install, with no alert gates involved.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Clear debug fence", role: .destructive) {
onClearDebugFence()
}
} else {
Button("Register 100 m fence at current location") {
onDebugFence()
}
Text("A debug-only circle around your current position that proves geofence delivery end-to-end: step out 100 m and back in, and watch 'Region events this session' tick.")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Debug fence (delivery test)")
}
Section {
LabeledContent("Region events this session", value: "\(regionEventCount)")
if alertLog.isEmpty {
Text("No alert activity yet — drive across a geofence boundary, or tap the test-alert button above to exercise the chain without a geofence.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(alertLog) { entry in
HStack(alignment: .firstTextBaseline) {
Image(systemName: alertSymbol(for: entry.kind))
.foregroundStyle(alertColor(for: entry.kind))
VStack(alignment: .leading, spacing: 1) {
Text(entry.text)
.font(.caption2)
.textSelection(.enabled)
Text(entry.date, format: .dateTime.hour().minute().second())
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
}
} header: {
Text("Alert trace")
} footer: {
Text("One line per stage the live chain reached: a geofence entry, each gate that passed or blocked the alert (dedup, cheapest-within-radius, fresh fetch), then the scheduled alert. If the trace shows 'alert scheduled' but no banner appears, the failure is in notification delivery itself (permission or background delivery), not the alert logic.")
}
}
Section {
Button {
Task { await tipStore.purchase() }
} label: {
HStack {
Label("Leave a tip", systemImage: "heart.fill")
.foregroundStyle(.pink)
Spacer()
Text(tipStore.displayPrice)
.foregroundStyle(.secondary)
.monospacedDigit()
VStack(spacing: 10) {
ForEach(TipStore.tiers) { tier in
Button {
Task { await tipStore.purchase(tier) }
} label: {
HStack(spacing: 12) {
Image(systemName: "fuelpump.fill")
.font(.title3)
.foregroundStyle(tier.accent)
.frame(width: 34)
VStack(alignment: .leading, spacing: 2) {
Text(tier.name)
.font(.headline)
Text(tier.blurb)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Text(tipStore.displayPrice(for: tier))
.font(.headline.weight(.bold))
.foregroundStyle(tier.accent)
.monospacedDigit()
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
.overlay(
RoundedRectangle(cornerRadius: 12)
.fill(tier.accent.opacity(0.05))
)
)
}
.buttonStyle(.plain)
.disabled(tipStore.purchaseInProgress)
}
}
.disabled(tipStore.purchaseInProgress)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
} header: {
Text("Support FuelBoard")
} footer: {
Text("A small tip helps keep the data relay and app development going. Thank you!")
Text("A tip helps keep this app free and supports development. Thank you")
}
Section {
@@ -253,13 +359,6 @@ struct SettingsView: View {
Text("About")
}
if let message = tipStore.message {
Section {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Settings")
.onAppear {
@@ -302,7 +401,7 @@ struct SettingsView: View {
Label("In range", systemImage: "location.circle.fill")
Spacer()
HStack(spacing: 6) {
if let coord = status.coordinate {
if status.coordinate != nil {
Circle()
.fill(inRangeColor(status.inRangeCount))
.frame(width: 10, height: 10)
@@ -431,6 +530,26 @@ struct SettingsView: View {
}
}
// MARK: - Alert trace rendering
private func alertSymbol(for kind: AlertLogEntry.Kind) -> String {
switch kind {
case .entry: return "location.circle.fill"
case .gate: return "arrow.right.circle"
case .error: return "xmark.octagon.fill"
case .fired: return "bell.badge.fill"
}
}
private func alertColor(for kind: AlertLogEntry.Kind) -> Color {
switch kind {
case .entry: return .green
case .gate: return .orange
case .error: return .red
case .fired: return .blue
}
}
private func sendTestAlert() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
Task { @MainActor in
@@ -441,13 +560,17 @@ struct SettingsView: View {
testAlertMessage = NSLocalizedString("Notifications are turned off for FuelBoard. Enable them in Settings → Notifications → FuelBoard, then try again.", comment: "")
default:
// First time ask, then fire if granted.
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
Task { @MainActor in
Task { @MainActor in
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
onTestAlert()
} else {
testAlertMessage = NSLocalizedString("Notifications weren't allowed, so no test alert was sent.", comment: "")
}
} catch {
testAlertMessage = NSLocalizedString("Couldn't request notification permission right now. Please try again.", comment: "")
}
}
}
@@ -456,34 +579,113 @@ struct SettingsView: View {
}
}
/// Loads the £4.99 tip product and drives its purchase.
/// Loads the three tip tiers and drives their purchases.
@MainActor
final class TipStore: ObservableObject {
/// Product ID for the £4.99 tip (App Store Connect consumable).
static let productID = "com.apt.fuelboard.tip499"
@Published private(set) var product: Product?
@Published private(set) var purchaseInProgress = false
@Published private(set) var message: String?
var displayPrice: String {
product?.displayPrice ?? "£4.99"
/// A purchasable tip tier (App Store Connect all consumables).
struct TipTier: Identifiable {
let id: String // product ID
let name: String
let blurb: String
let fallbackPrice: String
let accent: Color // card/price accent (matches the fuel palette)
}
func load() async {
// Refreshes product state on every visit so a newly-approved product
// (or a restored transaction) is picked up.
do {
let products = try await Product.products(for: [Self.productID])
product = products.first
} catch {
// No product yet (sideloaded build) the button still shows the
// intended price and reports the purchase attempt gracefully.
product = nil
static let tiers: [TipTier] = [
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99",
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",
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",
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
]
/// A banner outcome: the exact copy to show plus its icon + tint.
struct TipOutcome: Hashable {
let message: String
let icon: String
let tint: Color
}
@Published private(set) var products: [String: Product] = [:]
@Published private(set) var purchaseInProgress = false
@Published private(set) var outcome: TipOutcome?
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
private var dismissTask: Task<Void, Never>?
/// Listens for transactions that complete OUTSIDE the direct purchase()
/// call Ask to Buy approvals, payments finished on another device
/// signed into the same Apple ID. Without this, those purchases land in
/// the queue but are never finished or acknowledged.
private var updatesTask: Task<Void, Never>?
init() {
updatesTask = Task { [weak self] in
for await update in Transaction.updates {
await self?.handle(update)
}
}
}
func purchase() async {
deinit {
updatesTask?.cancel()
dismissTask?.cancel()
}
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
// Never deliver or finish an untrusted purchase.
guard case .verified(let transaction) = update else { return }
// Only acknowledge our own consumables (future products get their own).
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
await transaction.finish()
showOutcome(
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
icon: "checkmark.circle.fill",
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
)
}
/// Sets the banner outcome and schedules its auto-dismiss (~3.5 s).
/// Re-triggering cancels the previous timer so rapid taps don't collide.
private func showOutcome(_ message: String, icon: String, tint: Color) {
dismissTask?.cancel()
outcome = TipOutcome(message: message, icon: icon, tint: tint)
dismissTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 3_500_000_000)
guard !Task.isCancelled else { return }
withAnimation(.easeOut(duration: 0.25)) { self?.outcome = nil }
}
}
/// Tap-to-dismiss clears the banner and cancels the pending auto-dismiss.
func dismissOutcome() {
dismissTask?.cancel()
dismissTask = nil
withAnimation(.easeOut(duration: 0.2)) { outcome = nil }
}
func displayPrice(for tier: TipTier) -> String {
products[tier.id]?.displayPrice ?? tier.fallbackPrice
}
func load() async {
// Refreshes product state on every visit so newly-approved products
// (or restored transactions) are picked up.
do {
let fetched = try await Product.products(for: Self.tiers.map(\.id))
products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
} catch {
// No products yet (sideloaded build) the buttons still show the
// intended prices and report purchase attempts gracefully.
products = [:]
}
}
func purchase(_ tier: TipTier) async {
guard !purchaseInProgress else { return }
purchaseInProgress = true
defer { purchaseInProgress = false }
@@ -491,8 +693,12 @@ final class TipStore: ObservableObject {
// 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 else {
message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "")
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
}
@@ -501,20 +707,39 @@ final class TipStore: ObservableObject {
switch result {
case .success(let verification):
switch verification {
case .verified:
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
case .verified(let transaction):
// Consume the consumable otherwise StoreKit re-delivers
// it through Transaction.updates on every launch.
await transaction.finish()
showOutcome(
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
icon: "checkmark.circle.fill",
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
)
case .unverified:
message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "")
showOutcome(
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
icon: "exclamationmark.triangle.fill",
tint: .orange
)
}
case .userCancelled:
message = nil // silent the user just closed the sheet
dismissOutcome() // silent the user just closed the sheet
case .pending:
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
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:
message = nil
dismissOutcome()
}
} catch {
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
showOutcome(
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
icon: "xmark.circle.fill",
tint: .red
)
}
}
}
+28 -36
View File
@@ -22,18 +22,6 @@ import SwiftUI
/// "super unleaded") so Siri matches how people actually ask. The app UI's
/// FuelType.displayName is untouched.
// MARK: - Fuel parameter
extension FuelType: AppEnum {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [
.e10: "Unleaded",
.e5: "Premium",
.diesel: "Diesel",
]
}
// MARK: - Intent
struct CheapestFuelIntent: AppIntent {
@@ -102,14 +90,15 @@ struct CheapestFuelIntent: AppIntent {
let distanceKM = station.distanceKM(to: coordinate.lat, lng2: coordinate.lng)
let distanceText = FuelStore.loadDistanceUnit().format(distanceKM)
let priceText = String(format: "£%.3f", price / 100)
let priceText = FuelStore.priceText(price)
let priceSpoken = FuelStore.priceTextSpoken(price)
let freshness = SiriCheapestLookup.freshnessLabel(
updated: FuelStore.loadDataUpdated(),
lastRefresh: FuelStore.loadLastRefresh()
)
let summary = "\(station.name): \(priceText), \(distanceText)"
let freshnessClause = freshness.isEmpty ? "" : " — prices \(freshness)"
let dialog = "The cheapest \(fuel.displayName.lowercased()) near you is \(station.name) at \(priceText), \(distanceText) away\(freshnessClause)."
let dialog = "The cheapest \(fuel.displayName.lowercased()) near you is \(station.name) at \(priceSpoken), \(distanceText) away\(freshnessClause)."
return .result(
value: summary,
@@ -246,7 +235,8 @@ struct FavouriteFuelPriceIntent: AppIntent {
)
}
let priceText = String(format: "£%.3f", price / 100)
let priceText = FuelStore.priceText(price)
let priceSpoken = FuelStore.priceTextSpoken(price)
let freshness = SiriCheapestLookup.freshnessLabel(
updated: FuelStore.loadDataUpdated(),
lastRefresh: FuelStore.loadLastRefresh()
@@ -270,7 +260,7 @@ struct FavouriteFuelPriceIntent: AppIntent {
summary = "\(favourite.station.name): \(priceText)"
}
let dialog = "Your favourite \(fuelName) station, \(favourite.station.name), is at \(priceText)\(distanceClause)\(freshnessClause)."
let dialog = "Your favourite \(fuelName) station, \(favourite.station.name), is at \(priceSpoken)\(distanceClause)\(freshnessClause)."
return .result(
value: summary,
@@ -578,41 +568,43 @@ struct FuelBoardShortcuts: AppShortcutsProvider {
systemImageName: "star.fill"
)
// Directions only the FAVOURITE directions carry App Shortcut
// phrases: Apple caps app shortcuts at 10 (the metadata processor
// hard-fails past that), and the per-fuel cheapest-price + favourite
// price/directions entries cover the user's actual asks. The cheapest-
// directions and generic favourite intents below remain in code
// still runnable from the Shortcuts action list, just without Siri
// phrases. Phrases use direction verbs ("directions to", "get
// directions") so they never collide with the price shortcut phrases.
// Directions per-fuel CHEAPEST directions carry the App Shortcut
// phrases (the user's "directions to the cheapest X" asks). Apple caps
// app shortcuts at 10 (the metadata processor hard-fails past that);
// the favourite-directions path no longer needs phrases because every
// favourite price card carries the interactive Directions button
// (OpenDirectionsIntent, zero slots) so the favourite directions
// intents below remain in code, still runnable from the Shortcuts
// action list and via the card button, just without Siri phrases.
// Phrases use direction verbs ("directions to", "get directions") so
// they never collide with the price shortcut phrases.
AppShortcut(
intent: DirectionsToFavouriteFuelIntent(fuel: .e10),
intent: DirectionsToCheapestFuelIntent(fuel: .e10),
phrases: [
"Ask \(.applicationName) for directions to my favourite unleaded station",
"Get directions to my favourite petrol station \(.applicationName)",
"Ask \(.applicationName) for directions to the cheapest unleaded near me",
"Get directions to the cheapest petrol near me \(.applicationName)",
],
shortTitle: "Directions to Favourite Unleaded",
shortTitle: "Directions to Cheapest Unleaded",
systemImageName: "map"
)
AppShortcut(
intent: DirectionsToFavouriteFuelIntent(fuel: .e5),
intent: DirectionsToCheapestFuelIntent(fuel: .e5),
phrases: [
"Ask \(.applicationName) for directions to my favourite premium station",
"Get directions to my favourite super unleaded station \(.applicationName)",
"Ask \(.applicationName) for directions to the cheapest premium near me",
"Get directions to the cheapest super unleaded near me \(.applicationName)",
],
shortTitle: "Directions to Favourite Premium",
shortTitle: "Directions to Cheapest Premium",
systemImageName: "map"
)
AppShortcut(
intent: DirectionsToFavouriteFuelIntent(fuel: .diesel),
intent: DirectionsToCheapestFuelIntent(fuel: .diesel),
phrases: [
"Ask \(.applicationName) for directions to my favourite diesel station",
"Get directions to my favourite diesel station \(.applicationName)",
"Ask \(.applicationName) for directions to the cheapest diesel near me",
"Get directions to the cheapest diesel near me \(.applicationName)",
],
shortTitle: "Directions to Favourite Diesel",
shortTitle: "Directions to Cheapest Diesel",
systemImageName: "map"
)
}
+203 -66
View File
@@ -10,6 +10,7 @@ struct StationsView: View {
@Binding var sortMode: SortMode
@Binding var stationLimit: Int
let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let baselinePrice: Double?
let topStationID: String?
let location: Coordinate?
@@ -24,27 +25,37 @@ struct StationsView: View {
@State private var pageSize = 10
@State private var visibleCount = 10
/// Price-rating legend (the "Key") lives in a sheet, opened from the
/// info button in the header keeps the list focused on stations.
@State private var showKey = false
/// 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
/// the last chain fetch (8,022 mirror snapshot) hidden until known.
private var footerCaption: String {
let miles = distanceUnit.displayMiles(stationLimit)
let unit = distanceUnit.label(for: Double(miles))
let fuel = selectedFuel.displayName
let mode = sortMode == .closest ? "Closest" : "Cheapest"
let ratio: String
if let total = FuelStore.loadStationCount() {
ratio = "\(totalCount)/\(total)"
} else {
ratio = "\(totalCount)"
}
if location != nil {
if sortMode == .closest {
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
}
return "\(mode) \(fuel) within \(miles) \(unit)\(ratio) stations updated · tap a station for directions."
}
return "\(mode) \(fuel)\(ratio) stations updated · tap a station for directions."
}
var body: some View {
NavigationStack {
List {
Section {
if let location {
if sortMode == .closest {
Text("Closest \(selectedFuel.displayName) stations — nearest first, best value within \(distanceUnit.displayMiles(stationLimit)) \(distanceUnit.label(for: Double(distanceUnit.displayMiles(stationLimit)))) · \(totalCount) stations")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
Text("Cheapest \(selectedFuel.displayName) within \(distanceUnit.displayMiles(stationLimit)) \(distanceUnit.label(for: Double(distanceUnit.displayMiles(stationLimit))))\(totalCount) stations · tap a station for directions.")
.font(.footnote)
.foregroundStyle(.secondary)
}
} else {
Text("\(sortMode == .closest ? "Closest" : "Cheapest") \(selectedFuel.displayName) — tap a station for directions.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("Fuel type") {
FuelTypeSegmentedPicker(selection: $selectedFuel) { newValue in
FuelStore.saveSelectedFuel(newValue)
@@ -90,8 +101,20 @@ struct StationsView: View {
Text("Fetching prices…")
}
} else if stations.isEmpty {
Text("No \(selectedFuel.displayName) stations found.")
.foregroundStyle(.secondary)
let miles = distanceUnit.displayMiles(stationLimit)
let unit = distanceUnit.label(for: Double(miles))
VStack(alignment: .leading, spacing: 6) {
Text(String(format: NSLocalizedString("No %@ stations within %lld %@.", comment: "Empty state when the radius finds nothing nearby"), selectedFuel.displayName, miles, unit))
.foregroundStyle(.secondary)
Text("FuelBoard covers the whole UK — try Closest to browse every station.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Browse all UK stations") {
sortMode = .closest
}
.font(.caption)
}
.padding(.vertical, 4)
} else {
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
StationRow(
@@ -99,6 +122,7 @@ struct StationsView: View {
fuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice,
isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id),
@@ -125,40 +149,15 @@ struct StationsView: View {
}
}
Section("Key") {
HStack(spacing: 8) {
Circle().fill(.green).frame(width: 12, height: 12)
Text("Best value — within 1.5p of the cheapest")
.font(.caption)
}
HStack(spacing: 8) {
Circle().fill(.orange).frame(width: 12, height: 12)
Text("Okay — within 4p of the cheapest")
.font(.caption)
}
HStack(spacing: 8) {
Circle().fill(.red).frame(width: 12, height: 12)
Text("Pricey — more than 4p over the cheapest")
.font(.caption)
}
HStack(spacing: 8) {
Text("TOP")
.font(.caption2.bold())
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Capsule().fill(.blue.opacity(0.15)))
.foregroundStyle(.blue)
Text("Top result for the current sort")
.font(.caption)
}
HStack(spacing: 8) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
Text("Star a station to add it to Favourites")
.font(.caption)
}
// Bottom-of-tab explainer (moved from the top 2026-08-16),
// styled like the Favourites tab's cheapest callout: a caption
// paragraph in its own section cell.
Section {
Text(footerCaption)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.refreshable {
// Manual override for the twice-a-day cache policy.
@@ -174,8 +173,136 @@ struct StationsView: View {
// only renders text, which is exactly what we want here.
.navigationTitle("FuelBoard")
.navigationBarTitleDisplayMode(.large)
.onAppear {
#if DEBUG
// QA hook: launch with `-showKeySheet` to verify the legend
// sheet without driving a tap (simctl has no tap command).
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
showKey = true
}
#endif
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showKey = true
} label: {
Image(systemName: "info.circle")
}
.accessibilityLabel("Key")
}
}
.sheet(isPresented: $showKey) {
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")
keyCard(
icon: "equal.circle.fill", color: .orange,
title: "Okay", blurb: "Within 4p of the cheapest",
threshold: "≤4p")
keyCard(
icon: "exclamationmark.circle.fill", color: .red,
title: "Pricey", blurb: "More than 4p over the cheapest",
threshold: ">4p")
Rectangle()
.fill(Color(.separator))
.frame(height: 0.5)
.padding(.vertical, 2)
HStack(spacing: 12) {
Text("TOP")
.font(.caption2.bold())
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(.blue.opacity(0.16)))
.foregroundStyle(.blue)
Text("Top result for the current sort")
.font(.subheadline.weight(.semibold))
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.keyCardFill(color: .blue)
HStack(spacing: 12) {
Image(systemName: "star.fill")
.font(.title3)
.foregroundStyle(.yellow)
.frame(width: 34)
Text("Star a station to add it to Favourites")
.font(.subheadline.weight(.semibold))
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.keyCardFill(color: .yellow)
Text("Colours match each station's rating on the list")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.top, 4)
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
}
.navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showKey = false }
}
}
}
.presentationDetents([.fraction(0.6)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
}
}
}
/// One price-rating card for the Key sheet: accent icon, title + blurb,
/// and the rating threshold in the accent colour (mirrors the tip cards).
private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
.frame(width: 34)
VStack(alignment: .leading, spacing: 2) {
Text(LocalizedStringKey(title)).font(.headline)
Text(LocalizedStringKey(blurb))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
HStack(spacing: 6) {
Circle().fill(color).frame(width: 10, height: 10)
Text(threshold)
.font(.headline.weight(.bold))
.foregroundStyle(color)
.monospacedDigit()
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.keyCardFill(color: color)
}
}
/// Card background shared by the Key sheet cards subtle accent tint over
/// the grouped background, matching the tip-section card style.
private extension View {
func keyCardFill(color: Color) -> some View {
background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
.overlay(RoundedRectangle(cornerRadius: 12).fill(color.opacity(0.08)))
)
}
}
// MARK: - Fuel-type iconography (app target only FuelStore.swift is Foundation-only)
@@ -190,17 +317,6 @@ extension FuelType {
case .diesel: return "Diesel"
}
}
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Used for the
/// fuel-type tab icons and the title icon.
var tintColor: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
}
/// Fuel-type selector styled like a segmented control, with a coloured pump
@@ -215,20 +331,26 @@ struct FuelTypeSegmentedPicker: View {
var fuels: [FuelType] = FuelType.allCases
var onSelect: (FuelType) -> Void = { _ in }
@Namespace private var selectionSlider
var body: some View {
HStack(spacing: 3) {
ForEach(fuels) { fuel in
let isSelected = fuel == selection
Button {
selection = fuel
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
selection = fuel
}
onSelect(fuel)
} label: {
HStack(spacing: 5) {
Image(systemName: "fuelpump.fill")
.font(.caption2)
.foregroundStyle(fuel.tintColor)
.scaleEffect(isSelected ? 1.08 : 0.94)
Text(fuel.shortName)
.font(.subheadline.weight(isSelected ? .semibold : .regular))
.contentTransition(.opacity)
}
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
.frame(maxWidth: .infinity)
@@ -237,14 +359,29 @@ struct FuelTypeSegmentedPicker: View {
if isSelected {
Capsule()
.fill(Color(UIColor.systemBackground))
.shadow(color: .black.opacity(0.08), radius: 1, y: 0.5)
.overlay {
Capsule()
.stroke(fuel.tintColor.opacity(0.28), lineWidth: 1)
}
.shadow(color: fuel.tintColor.opacity(0.16), radius: 6, y: 2)
.matchedGeometryEffect(id: "fuelSelectionSlider", in: selectionSlider)
}
}
.scaleEffect(isSelected ? 1.02 : 0.98)
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(3)
.background(Capsule().fill(Color(.secondarySystemBackground)))
.animation(.spring(response: 0.42, dampingFraction: 0.78), value: selection)
}
}
#Preview("Fuel Picker") {
@Previewable @State var selectedFuel: FuelType = .e10
FuelTypeSegmentedPicker(selection: $selectedFuel)
.padding()
.background(Color(.systemGroupedBackground))
}
+527
View File
@@ -0,0 +1,527 @@
import SwiftUI
import Charts
/// Trends the Favourites price-history chart.
///
/// Plots one line per favourited station for the active fuel across the
/// selected range (7/30/90 days), fed by the GitHub price mirror
/// (`FuelHistoryStore`). Default shows absolute prices; "vs cheapest" rebases
/// each day to the cheapest favourite (0 baseline, signed pence above it)
/// the same delta pattern the list rows already use. Missing days are gaps,
/// never fabricated. No widget in v1 (one-kind rule).
struct TrendsView: View {
/// All favourites (fuel-scoped entries) the sheet derives the active
/// fuel's stations and which fuels have favourites.
let favourites: [FavouriteEntry]
/// The fuel the Favourites tab is showing when the Trends button is
/// tapped the sheet must open on the same fuel, not always .e10.
let selectedFuel: FuelType
let priceDisplayStyle: PriceDisplayStyle
/// Propagated up to ContentView so a price-history fetch that fails with
/// NO data raises the global connection banner (same red banner as the
/// stations fetch). `onHistoryRecovered` fires once data loads again.
var onHistoryUnavailable: (() -> Void)? = nil
var onHistoryRecovered: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var fuel: FuelType = .e10
@State private var rangeDays: Int = 30
@State private var mode: TrendsMode = .price
@State private var series: [StationHistory] = []
@State private var isLoading = false
@State private var loadFailed = false
@State private var firstSnapshot: String?
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
/// opens where the user was same pattern as FavouritesView.
init(favourites: [FavouriteEntry],
selectedFuel: FuelType,
priceDisplayStyle: PriceDisplayStyle,
onHistoryUnavailable: (() -> Void)? = nil,
onHistoryRecovered: (() -> Void)? = nil) {
self.favourites = favourites
self.selectedFuel = selectedFuel
self.priceDisplayStyle = priceDisplayStyle
self.onHistoryUnavailable = onHistoryUnavailable
self.onHistoryRecovered = onHistoryRecovered
_fuel = State(initialValue: selectedFuel)
}
enum TrendsMode: String, CaseIterable, Identifiable {
case price
case vsCheapest
var id: String { rawValue }
}
/// Fuels that currently have at least one favourite only these tabs show.
private var availableFuels: [FuelType] {
FuelType.allCases.filter { fuel in
favourites.contains { $0.fuel == fuel }
}
}
/// The active fuel, with the same fallback as the Favourites tab.
private var activeFuel: FuelType {
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
}
/// Stations favourited for the active fuel, in the user's stored order
/// the chart keeps this order so line colours are stable.
private var orderedStations: [(id: String, name: String)] {
favourites.filter { $0.fuel == activeFuel }.map { ($0.station.id, $0.station.name) }
}
private var displaySeries: [StationHistory] {
mode == .price ? series : FuelHistoryStore.deltaSeries(series)
}
/// A line needs at least two points to draw; anything less is the
/// "building up" state, not a broken chart.
private var hasEnoughData: Bool {
series.contains { $0.points.count >= 2 }
}
private var hasAnyData: Bool {
series.contains { !$0.points.isEmpty }
}
private func seriesColor(_ index: Int) -> Color {
let palette: [Color] = [.blue, .orange, .purple, .pink, .teal, .indigo, .brown, .green]
return palette[index % palette.count]
}
/// X-axis tick density a tick per day for short ranges, monthly for the
/// 90-day view so labels never collide.
private var xStride: Int {
switch rangeDays {
case ...14: return 1
case 15...60: return 7
default: return 30
}
}
/// Y-domain hugging the displayed data (RevenueCat-style tight scale).
/// Without it Swift Charts pads a sparse series (e.g. 3 days of ~150.7p)
/// out to a 0200 axis, wasting the plot area. Pad by ~4% of the range
/// (min 0.5p) so the line doesn't kiss the top edge.
private var yDomain: ClosedRange<Double> {
let values = displaySeries.flatMap { $0.points.map(\.pence) }
guard let lo = values.min(), let hi = values.max() else {
return 0...1
}
let pad = max((hi - lo) * 0.04, 0.5)
return (lo - pad)...(hi + pad)
}
/// Depth of each series' gradient fill band, in pence. Band instead of
/// fill-to-axis: with 2+ favourites, full-height unstacked fills overlap
/// and wash out the lower lines (observed on device 2026-08-17). A band
/// of ~18% of the y-range under each line keeps every area visible and
/// gives the RevenueCat "fade under the line" look.
private var fillBand: Double {
let values = displaySeries.flatMap { $0.points.map(\.pence) }
guard let lo = values.min(), let hi = values.max(), hi > lo else {
return 1.0
}
return max((hi - lo) * 0.18, 0.8)
}
private func load() async {
#if DEBUG
// QA hook: force the unreachable state for screenshots (same pattern
// as -showTrends / -forceConnectionProblem). Runs before the fetch so
// the retry state renders immediately with no spinner flash.
if ProcessInfo.processInfo.arguments.contains("-forceHistoryFailure") {
series = []
loadFailed = true
onHistoryUnavailable?()
return
}
#endif
isLoading = true
loadFailed = false
defer { isLoading = false }
// The pointer is a non-fatal hint for the empty state; history fetch
// failures surface as the retry state.
firstSnapshot = await FuelHistoryStore.fetchLatest()?.availableFrom
let fetched = await FuelHistoryStore.fetchHistory(
stations: orderedStations,
fuel: activeFuel,
days: rangeDays
)
if fetched.allSatisfy({ $0.points.isEmpty }), !orderedStations.isEmpty {
// All days missing either the mirror is unreachable (retry) or
// genuinely empty (the building-up state). Distinguish by a quick
// pointer probe already done above.
loadFailed = firstSnapshot == nil
}
series = fetched
// A failure with no data IS a connection problem raise the global
// banner so the user isn't stuck with a silent retry state. Success
// clears it (only if the banner is the connection banner).
if loadFailed {
onHistoryUnavailable?()
} else if hasAnyData {
onHistoryRecovered?()
}
}
private func yLabel(_ pence: Double) -> String {
switch mode {
case .price:
return FuelStore.priceText(pence, style: priceDisplayStyle)
case .vsCheapest:
return String(format: "%.1fp", pence)
}
}
// MARK: - RevenueCat-style headline metric
/// Overall average across displayed series (mean of per-station means)
/// the big figure in the card header, Price and vs-cheapest mode both.
private var headlineAverage: Double? {
let avgs = displaySeries.compactMap { FuelHistoryStore.averagePence($0.points) }
guard !avgs.isEmpty else { return nil }
return avgs.reduce(0, +) / Double(avgs.count)
}
/// Change over the window: mean of each series' (last first) point.
/// Positive = prices/gaps rose; negative = fell. Hidden while loading or
/// when any displayed series lacks two points.
private var headlineDelta: Double? {
let deltas = displaySeries.compactMap { history -> Double? in
guard let first = history.points.first?.pence,
let last = history.points.last?.pence else { return nil }
return last - first
}
guard deltas.count == displaySeries.count else { return nil }
return deltas.reduce(0, +) / Double(deltas.count)
}
/// Good = prices fell (saving money) or the gap to cheapest narrowed.
private var deltaIsGood: Bool {
(headlineDelta ?? 0) <= 0
}
private var periodLabel: String {
"Last \(rangeDays) days"
}
var body: some View {
NavigationStack {
VStack(spacing: 14) {
FuelTypeSegmentedPicker(selection: $fuel, fuels: availableFuels)
HStack(spacing: 10) {
Picker("Range", selection: $rangeDays) {
ForEach(FuelHistoryStore.rangeOptions, id: \.self) { days in
Text("\(days) days").tag(days)
}
}
.pickerStyle(.segmented)
Picker("Mode", selection: $mode) {
Text("Price").tag(TrendsMode.price)
Text("vs cheapest").tag(TrendsMode.vsCheapest)
}
.pickerStyle(.segmented)
}
chartArea
}
.padding()
.navigationTitle("Trends")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
.task(id: "\(activeFuel.rawValue)-\(rangeDays)") {
await load()
}
}
}
@ViewBuilder
private var chartArea: some View {
// Every state anchors to the TOP of the chart slot the same spot the
// chart occupies when data exists. The empty/loading/error states must
// not float or centre in the sheet, or the layout jumps between states.
Group {
if isLoading {
VStack(spacing: 12) {
ProgressView("Fetching price history…")
}
.padding(.top, 24)
} else if loadFailed {
VStack(spacing: 10) {
Image(systemName: "wifi.exclamationmark")
.font(.system(size: 32))
.foregroundStyle(.secondary)
Text("Couldn't load price history")
.font(.headline)
Button("Retry") { Task { await load() } }
.buttonStyle(.bordered)
}
.padding(.top, 24)
} else if !hasAnyData {
emptyState
} else if !hasEnoughData {
emptyState // single point nothing to draw yet
} else {
revenueCatCard
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "chart.xyaxis.line")
.font(.system(size: 32))
.foregroundStyle(.secondary)
Text("No price history yet")
.font(.headline)
if let firstSnapshot {
Text("First snapshot \(firstSnapshot) — a few days are needed to draw a trend.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
Text("Prices are recorded each day FuelBoard's relay runs — check back in a few days.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}
/// RevenueCat-style card: rounded surface with a headline metric (period
/// average + change), the multi-series area chart, then the key. Mirrors
/// the mockup approved after the dashboard reference screenshot.
private var revenueCatCard: some View {
VStack(alignment: .leading, spacing: 10) {
metricHeader
metricFooter
chart
legend
legendFooter
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(.secondarySystemGroupedBackground))
)
}
/// Headline row: big average figure, signed change, right-aligned period.
/// Colours stay on the app palette green when the move is good
/// (prices fell / gap narrowed), the accent tint when it rose.
@ViewBuilder
private var metricHeader: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
if let headAvg = headlineAverage {
Text(yLabel(headAvg))
.font(.system(size: 30, weight: .bold, design: .default))
.monospacedDigit()
.foregroundStyle(.primary)
if let delta = headlineDelta, delta != 0 {
Label(
"\(deltaIsGood ? "" : "+")\(abs(delta), specifier: "%.1f")p",
systemImage: deltaIsGood ? "arrow.down.right" : "arrow.up.right"
)
.font(.system(size: 13, weight: .semibold))
.labelStyle(.titleAndIcon)
.foregroundStyle(deltaIsGood ? Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) : Color.accentColor)
}
}
Spacer()
Text(periodLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
/// One-line descriptor under the headline so the big figure (period
/// average across favourites) and the arrowed delta (change over the
/// window) are self-explanatory.
private var metricFooter: some View {
Text(chartFooterText)
.font(.caption2)
.foregroundStyle(.secondary)
}
private var chartFooterText: String {
switch mode {
case .price:
return "Average across favourites · change since the first day"
case .vsCheapest:
return "Average gap above the day's cheapest · change since the first day"
}
}
private var chart: some View {
// NOTE: each LineMark MUST carry an explicit `series:` without it
// Swift Charts merges every station's points into ONE polyline
// (points connect across stations, so only the first station's line
// is recognisable). The outer ForEach keeps one chart with N series;
// per-mark foregroundStyle then colours each series from the palette.
// The AreaMark under each LineMark adds the gradient fill.
//
// stacking: .unstacked is essential the standard stacking mode
// piles each series' area ON TOP of the previous series' area, so
// with 2+ favourites the fills land in the wrong place (seen on
// device 2026-08-17: fills shifted/overlapping vs the lines).
// .unstacked draws each area from its own line down to the axis,
// the RevenueCat look.
// Two passes: ALL AreaMarks first, then ALL LineMarks. Same-series
// marks composite in insertion order if fills and lines interleave,
// a later series' fill paints OVER an earlier series' line (the
// bottom favourite's line vanished under the next fill; seen on
// device 2026-08-17). Drawing every fill before every line keeps all
// lines on top of all fills, RevenueCat style. (Mark builders are
// extracted into small helpers the inline expression grew past the
// type-checker's budget.)
Chart {
ForEach(displaySeries) { history in
ForEach(history.points) { point in
areaMark(point, series: history.name,
color: seriesColor(index(of: history.stationID)))
}
}
ForEach(displaySeries) { history in
ForEach(history.points) { point in
lineMark(point, series: history.name,
color: seriesColor(index(of: history.stationID)))
}
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: xStride)) { _ in
AxisGridLine()
AxisTick()
AxisValueLabel(format: .dateTime.month().day())
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartYScale(domain: yDomain)
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let pence = value.as(Double.self) {
Text(yLabel(pence))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
.frame(height: 190)
}
/// One gradient-filled area band under a single point of a series.
/// yStart/yEnd bound the fill to `fillBand` pence under the line
/// instead of to the axis: full-height fills overlap and bury lower
/// lines when several favourites overlap (observed on device
/// 2026-08-17).
private func areaMark(_ point: PricePoint, series: String,
color: Color) -> some ChartContent {
AreaMark(
x: .value("Date", point.date),
yStart: .value("Price start", point.pence),
yEnd: .value("Price end", point.pence - fillBand),
series: .value("Station", series)
)
.foregroundStyle(
LinearGradient(
colors: [color.opacity(0.30), color.opacity(0.02)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.monotone)
}
/// One line segment point for a series, drawn above every fill.
private func lineMark(_ point: PricePoint, series: String,
color: Color) -> some ChartContent {
LineMark(
x: .value("Date", point.date),
y: .value("Price", point.pence),
series: .value("Station", series)
)
.foregroundStyle(color)
.lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
.interpolationMethod(.monotone)
}
private func index(of stationID: String) -> Int {
orderedStations.firstIndex(where: { $0.id == stationID }) ?? 0
}
/// Key rows in the same vertical order as the chart: most expensive at the
/// top working down to cheapest at the bottom (the chart's y-axis puts
/// the cheapest line lowest). Sorts by average price the bracketed
/// figure descending, so the first key row matches the top line.
private var legendSeries: [StationHistory] {
displaySeries.sorted {
(FuelHistoryStore.averagePence($0.points) ?? 0)
> (FuelHistoryStore.averagePence($1.points) ?? 0)
}
}
private var legend: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(legendSeries, id: \.stationID) { history in
HStack(spacing: 8) {
Circle()
.fill(seriesColor(index(of: history.stationID)))
.frame(width: 8, height: 8)
Text(history.name)
.font(.caption)
.lineLimit(1)
if let avg = FuelHistoryStore.averagePence(history.points) {
Text(legendFigure(avg))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Spacer()
}
}
}
.padding(.horizontal, 4)
}
/// The bracket figure in the chart key: absolute pence in Price mode,
/// signed pence above the day's cheapest in vs-cheapest mode always
/// pence, matching the list rows (the y-axis follows the display toggle).
private func legendFigure(_ pence: Double) -> String {
switch mode {
case .price:
return String(format: "%.1fp", pence)
case .vsCheapest:
return pence > 0 ? String(format: "+%.1fp", pence) : String(format: "%.1fp", pence)
}
}
/// One-line descriptor under the key so the brackets are self-explanatory.
private var legendFooter: some View {
Group {
if mode == .price {
Text("Average price over the days shown")
} else {
Text("Average pence above the day's cheapest favourite")
}
}
.font(.caption2)
.foregroundStyle(.secondary)
}
}
+89
View File
@@ -0,0 +1,89 @@
import SwiftUI
import WidgetKit
/// Full-screen widget preview (launch arg `-widgets`): the real app icon
/// plus the REAL widget faces (Shared/FuelPriceWidgetViews.swift the same
/// code the widget extension renders) at small + medium sizes. Used for App
/// Store screenshot captures; not reachable from the UI.
struct WidgetMockScreen: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
ZStack {
// Real iPhone wallpaper (extracted from the iOS 26.3 sim runtime
// ActionButtonSelector Display.ca asset, 1320×2870 native res).
Image("PreviewWallpaper")
.resizable()
.scaledToFill()
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 26) {
// App icon + name, home-screen style. Uses the SAME artwork
// the app ships (AppIcon.appiconset), with the dark variant
// when the device is in dark mode matching iOS behaviour.
HStack(spacing: 16) {
Image(colorScheme == .dark ? "AppIconPreviewDark" : "AppIconPreview")
.resizable()
.frame(width: 76, height: 76)
.clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text("FuelBoard")
.font(.title2.bold())
.foregroundStyle(.white)
.shadow(radius: 2, y: 1)
Text("Cheapest fuel near you")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.9))
.shadow(radius: 2, y: 1)
}
}
.padding(.top, 8)
widgetCard(.systemSmall, label: "Small widget")
widgetCard(.systemMedium, label: "Medium widget")
Spacer(minLength: 0)
}
.padding(30)
}
}
private func widgetCard(_ family: WidgetFamily, label: String) -> some View {
// Real widget proportions: system small content is 158×158pt,
// medium 338×158pt (card adds the 14pt padding each side).
let contentW: CGFloat = family == .systemSmall ? 158 : 338
let cardW: CGFloat = contentW + 28
let cardH: CGFloat = contentW + 28
return VStack(alignment: .leading, spacing: 10) {
Text(label.uppercased())
.font(.caption.weight(.semibold))
.tracking(1)
.foregroundStyle(.secondary)
FuelPriceWidgetContent(entry: mockEntry(family: family), family: family, isWidget: false)
.padding(14)
.frame(width: cardW, height: cardH, alignment: .leading)
.background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 24, style: .continuous))
}
}
/// Real stations (loaded dump), cheapest-first for the chosen fuel the
/// same selection the widget's own provider makes.
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) }
let count = family == .systemSmall ? 1 : 3
return FuelPriceWidgetEntryData(
date: Date(),
stations: Array(selling.prefix(count)),
fuel: fuel,
sort: .cheapest,
isFavourites: false,
location: location,
locationSource: location != nil ? "live" : "none",
unit: FuelStore.loadDistanceUnit()
)
}
}
+47 -12
View File
@@ -23,11 +23,15 @@
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
"All %lld stations shown" = "All %lld stations shown";
"Key" = "Key";
"Best value — within 1.5p of the cheapest" = "Best value — within 1.5p of the cheapest";
"Okay — within 4p of the cheapest" = "Okay — within 4p of the cheapest";
"Pricey — more than 4p over the cheapest" = "Pricey — more than 4p over the cheapest";
"Best value" = "Best value";
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
"Okay" = "Okay";
"Within 4p of the cheapest" = "Within 4p of the cheapest";
"Pricey" = "Pricey";
"More than 4p over the cheapest" = "More than 4p over 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";
/* Favourites tab */
"No favourites yet" = "No favourites yet";
@@ -50,8 +54,9 @@
/* Settings tab */
"Units" = "Units";
"Distances and search radii across the app, widget and alerts are shown in this unit." = "Distances and search radii across the app, widget and alerts are shown in this unit.";
"Show introduction" = "Show introduction";
"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).";
"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";
@@ -97,13 +102,21 @@
/* Onboarding */
"Welcome to FuelBoard" = "Welcome to FuelBoard";
"The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data." = "The cheapest petrol and diesel fuel near you";
"England-wide prices — 8,000+ stations, updated twice a day" = "England-wide prices — 8,000+ stations, updated daily";
"The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data." = "The cheapest fuel near you";
"UK-wide prices — 8,000+ stations, updated twice a day" = "UK-wide prices — 8,000+ stations, updated twice a day";
"Cheapest within your chosen radius, or closest station first" = "Cheapest within your chosen radius, or closest station";
"Favourites with instant price comparison" = "Favourites with instant price comparison";
"Green/amber/red rating vs the best nearby price" = "Rating vs the best nearby price";
"Home-screen widget showing the cheapest nearby" = "Customisable Home-screen widgets";
"Alerts when you approach the cheapest station" = "Alerts you when you approach the cheapest station";
/* Stations empty state */
"No %@ stations within %lld %@." = "No %@ stations within %lld %@.";
"FuelBoard covers the whole UK — try Closest to browse every station." = "FuelBoard covers the whole UK — try Closest to browse every station.";
"Browse all UK stations" = "Browse all UK stations";
/* Settings → Data source footer */
"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.";
@@ -112,10 +125,7 @@
"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.";
"Notifications were denied — you can still use FuelBoard, just without alert banners." = "Notifications were denied — you can still use FuelBoard, just without alert banners.";
"Prices, ready when you are" = "Prices, ready when you are";
"FuelBoard downloads the latest prices from a relay on your local network — the full UK dataset, refreshed twice a day. Local network access is needed for that first download." = "FuelBoard downloads the latest prices daily";
"Waiting for network permission…" = "Waiting for network permission…";
"Connected" = "Connected";
"Local network access was denied — prices won't load until it's allowed, but you can keep using FuelBoard." = "Local network access was denied — prices won't load until it's allowed, but you can keep using FuelBoard.";
"FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed." = "Prices download from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.";
"You're all set" = "You're all set";
"Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby." = "Find the cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.";
"Continue" = "Continue";
@@ -123,7 +133,6 @@
"Allow Location Access" = "Allow Location Access";
"Continue without alerts" = "Continue without alerts";
"Allow Notifications" = "Allow Notifications";
"Waiting…" = "Waiting…";
"Start Using FuelBoard" = "Start Using FuelBoard";
/* Station map */
@@ -133,3 +142,29 @@
/* Station row */
"best" = "best";
/* Trends — price history chart */
"Trends" = "Trends";
"Range" = "Range";
"Mode" = "Mode";
"Price" = "Price";
"vs cheapest" = "vs cheapest";
"%lld days" = "%lld days";
"Fetching price history…" = "Fetching price history…";
"Couldn't load price history" = "Couldn't load price history";
"Retry" = "Retry";
"No price history yet" = "No price history yet";
"First snapshot %@ — a few days are needed to draw a trend." = "First snapshot %@ — a few days are needed to draw a trend.";
"Prices are recorded each day FuelBoard's relay runs — check back in a few days." = "Prices are recorded each day FuelBoard's relay runs — check back in a few days.";
"Average price over the days shown" = "Average price over the days shown";
"Average pence above the day's cheapest favourite" = "Average pence above the day's cheapest favourite";
/* Offline data banner */
"Offline data from %@" = "Offline data from %@";
"Offline data" = "Offline data";
"Pull to refresh on the Stations tab" = "Pull to refresh on the Stations tab";
"Offline data from %@. Pull to refresh on the Stations tab" = "Offline data from %@. Pull to refresh on the Stations tab";
"Offline data. Pull to refresh on the Stations tab" = "Offline data. Pull to refresh on the Stations tab";
"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";
@@ -0,0 +1 @@
../../../Shared/FuelHistory.swift
@@ -0,0 +1 @@
../../../Shared/MirrorFuelProvider.swift
@@ -233,11 +233,25 @@ final class BrandTests: XCTestCase {
XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet")
XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco")
XCTAssertEqual(station(brand: "APPLEGREEN").brandImageName, "brand_applegreen")
XCTAssertEqual(station(brand: "VALERO").brandImageName, "brand_valero")
XCTAssertEqual(station(brand: "WELCOME BREAK").brandImageName, "brand_welcome_break")
XCTAssertEqual(station(brand: "THE CO-OPERATIVE").brandImageName, "brand_the_co_operative")
XCTAssertEqual(station(brand: "CO-OP").brandImageName, "brand_the_co_operative")
XCTAssertEqual(station(brand: "MURCO").brandImageName, "brand_murco")
XCTAssertEqual(station(brand: "GLEANER").brandImageName, "brand_gleaner")
XCTAssertEqual(station(brand: "HIGHLAND FUELS").brandImageName, "brand_highland_fuels")
XCTAssertEqual(station(brand: "CIRCLE K").brandImageName, "brand_circle_k")
XCTAssertEqual(station(brand: "MAXOL").brandImageName, "brand_maxol")
XCTAssertEqual(station(brand: "SPAR").brandImageName, "brand_spar")
XCTAssertEqual(station(brand: "ESSAR").brandImageName, "brand_essar")
XCTAssertEqual(station(brand: "EG ON THE MOVE").brandImageName, "brand_esso")
XCTAssertEqual(station(brand: "GO FORECOURT").brandImageName, "brand_gulf")
}
func testUnknownBrand() {
XCTAssertNil(station(brand: "WELCOME BREAK").brandImageName)
XCTAssertNil(station(brand: "VALERO").brandImageName)
func testUnknownBrandUsesGenericFuelPumpFallbackInUI() {
XCTAssertNil(station(brand: "TOTAL HARVEST ENERGY").brandImageName)
XCTAssertNil(station(brand: "SOLO").brandImageName)
XCTAssertNil(station(brand: "PACE").brandImageName)
XCTAssertNil(station(brand: "").brandImageName)
}
}
@@ -561,3 +575,99 @@ final class SiriCheapestLookupTests: XCTestCase {
XCTAssertTrue(label.hasPrefix("as of "), "plain ISO 8601 still parses")
}
}
// MARK: - Price display style
final class PriceDisplayTests: XCTestCase {
func testStationSignStyle() {
// Bare pence figure, no £ exactly the roadside sign.
XCTAssertEqual(FuelStore.priceText(129.9, style: .stationSign), "129.9")
XCTAssertEqual(FuelStore.priceText(135.0, style: .stationSign), "135.0")
XCTAssertEqual(FuelStore.priceText(249.9, style: .stationSign), "249.9")
}
func testPoundsPenceStyle() {
// Forecourt superscript style: £ + 2dp + small superscript third digit + /L.
XCTAssertEqual(FuelStore.priceText(129.9, style: .poundsPence), "£1.29⁹/L")
XCTAssertEqual(FuelStore.priceText(135.0, style: .poundsPence), "£1.35⁰/L")
XCTAssertEqual(FuelStore.priceText(100.9, style: .poundsPence), "£1.00⁹/L")
XCTAssertEqual(FuelStore.priceText(199.9, style: .poundsPence), "£1.99⁹/L")
}
func testSpokenAlwaysPounds() {
// Siri would read "£129.9" as "one hundred and twenty-nine pounds"
// the spoken form must always be pounds regardless of display style.
XCTAssertEqual(FuelStore.priceTextSpoken(129.9), "£1.299")
XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009")
}
}
// MARK: - Offline data banner label
final class OfflineDataLabelTests: XCTestCase {
func testOfflineDataLabelFormatsStampWithFractionalSeconds() {
// The bundled dump's real envelope stamp.
XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33.000Z"), "15 Aug")
}
func testOfflineDataLabelToleratesPlainISODate() {
XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33Z"), "15 Aug")
}
func testOfflineDataLabelNilWhenMissingOrUnparseable() {
XCTAssertNil(FuelStore.offlineDataLabel(from: nil))
XCTAssertNil(FuelStore.offlineDataLabel(from: ""))
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
}
}
// MARK: - Road distance cache
final class RoadDistanceCacheTests: XCTestCase {
override func setUp() {
super.setUp()
// Keychain persists across invocations, so a cache left by an earlier
// 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: [:])
}
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
lat: lat, lng: lng, prices: [:], priceUpdated: nil)
}
func testDisplayDistanceFallsBackToStraightLineWhenNoCache() {
// London user, station ~ London -> no cache -> straight-line haversine.
let s = station("a", 51.5074, -0.1278)
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
}
func testRoadDistanceUsedWhenCachedNear() {
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, entries: ["a": 3200])
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, 3.2, accuracy: 0.0001)
}
func testRoadDistanceNilWhenOriginFar() {
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, entries: ["a": 3200])
let meters = FuelStore.roadDistanceMeters(for: "a", userLat: 53.4808, userLng: -2.2426)
XCTAssertNil(meters)
// And display falls back to straight-line.
let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426)
XCTAssertEqual(km, s.distanceKM(to: 53.4808, lng2: -2.2426), accuracy: 0.0001)
}
func testRoadDistanceUsedForOtherStationNotFound() {
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200])
// A station that isn't in the cache falls back to straight-line.
let s = station("z", 51.51, -0.13)
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
}
}
@@ -0,0 +1,205 @@
import XCTest
@testable import FuelBoardShared
// FuelHistory price-history store for the Favourites Trends graph.
// Covers day math, slim day parsing, series building, delta rebasing and
// cache pruning. The network layer is exercised separately (pure helpers are
// what the chart logic depends on).
final class FuelHistoryTests: XCTestCase {
// MARK: Day math
func testDayStringFormat() {
XCTAssertEqual(FuelHistoryStore.dayString(), FuelHistoryStore.dayString())
let d = FuelHistoryStore.date(fromDay: "2026-08-15")
XCTAssertNotNil(d)
XCTAssertEqual(FuelHistoryStore.dayString(d!), "2026-08-15")
}
func testNeededDaysCountAndOrder() {
let days = FuelHistoryStore.neededDays(back: 7, from: FuelHistoryStore.date(fromDay: "2026-08-15")!)
XCTAssertEqual(days.count, 7)
XCTAssertEqual(days.first, "2026-08-09")
XCTAssertEqual(days.last, "2026-08-15")
}
func testNeededDaysZero() {
XCTAssertTrue(FuelHistoryStore.neededDays(back: 0).isEmpty)
}
// MARK: Day parsing (slim decode + shared band guard)
private func dayJSON(stations: [[String: Any]]) -> Data {
let dict: [String: Any] = ["stations": stations]
return try! JSONSerialization.data(withJSONObject: dict)
}
func testParseDayStationsExtractsRequestedFuels() throws {
let data = dayJSON(stations: [
["id": "s1", "prices": ["E10": 129.9, "E5": 139.9, "B7": 134.9]],
["id": "s2", "prices": ["E10": 131.9]],
])
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["s1", "s2"])
XCTAssertEqual(parsed["s1"]?[.e10], 129.9)
XCTAssertEqual(parsed["s1"]?[.e5], 139.9)
XCTAssertEqual(parsed["s1"]?[.diesel], 134.9) // B7 diesel
XCTAssertEqual(parsed["s2"]?[.e10], 131.9)
XCTAssertNil(parsed["s2"]?[.e5])
}
func testParseDayStationsIgnoresUnrequestedStations() throws {
let data = dayJSON(stations: [
["id": "wanted", "prices": ["E10": 129.9]],
["id": "other", "prices": ["E10": 99.9]],
])
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["wanted"])
XCTAssertNotNil(parsed["wanted"])
XCTAssertNil(parsed["other"])
}
func testParseDayStationsBandGuardDropsGarbage() throws {
let data = dayJSON(stations: [
["id": "s1", "prices": ["E10": 129.9, "E5": 1.3, "B7": 1589.0]],
])
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["s1"])
XCTAssertEqual(parsed["s1"]?[.e10], 129.9)
XCTAssertNil(parsed["s1"]?[.e5])
XCTAssertNil(parsed["s1"]?[.diesel])
}
func testParseDayStationsMissingPricesIsEmpty() throws {
let data = dayJSON(stations: [["id": "s1"]])
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["s1"])
XCTAssertTrue(parsed.isEmpty)
}
// MARK: Series building
func testSeriesFromCacheBuildsOrderedPoints() {
let cache: [String: [String: [String: Double]]] = [
"2026-08-13": ["s1": ["e10": 130.0]],
"2026-08-14": ["s1": ["e10": 129.5]],
"2026-08-15": ["s1": ["e10": 128.9]],
]
let days = ["2026-08-13", "2026-08-14", "2026-08-15"]
let series = FuelHistoryStore.series(
fromCache: cache,
stations: [("s1", "Shell Test")],
fuel: .e10,
days: days
)
XCTAssertEqual(series.count, 1)
XCTAssertEqual(series[0].name, "Shell Test")
XCTAssertEqual(series[0].points.count, 3)
XCTAssertEqual(series[0].points.map(\.pence), [130.0, 129.5, 128.9])
XCTAssertEqual(series[0].points.map(\.date), days.compactMap { FuelHistoryStore.date(fromDay: $0) })
}
func testSeriesSkipsMissingDays() {
let cache: [String: [String: [String: Double]]] = [
"2026-08-13": ["s1": ["e10": 130.0]],
"2026-08-15": ["s1": ["e10": 128.9]], // gap on the 14th
]
let days = ["2026-08-13", "2026-08-14", "2026-08-15"]
let series = FuelHistoryStore.series(
fromCache: cache,
stations: [("s1", "Shell Test")],
fuel: .e10,
days: days
)
XCTAssertEqual(series[0].points.count, 2) // gap skipped, never fabricated
XCTAssertEqual(series[0].points.map(\.pence), [130.0, 128.9])
}
func testSeriesFiltersFuel() {
let cache: [String: [String: [String: Double]]] = [
"2026-08-15": ["s1": ["e10": 128.9, "diesel": 134.9]],
]
let days = ["2026-08-15"]
let diesel = FuelHistoryStore.series(fromCache: cache, stations: [("s1", "Shell")], fuel: .diesel, days: days)
XCTAssertEqual(diesel[0].points.map(\.pence), [134.9])
let e10 = FuelHistoryStore.series(fromCache: cache, stations: [("s1", "Shell")], fuel: .e10, days: days)
XCTAssertEqual(e10[0].points.map(\.pence), [128.9])
}
// MARK: Delta rebasing
func testDeltaSeriesRebasesToCheapestPerDay() {
let days = ["2026-08-13", "2026-08-14", "2026-08-15"]
let cache: [String: [String: [String: Double]]] = [
"2026-08-13": ["a": ["e10": 130.0], "b": ["e10": 132.0]],
"2026-08-14": ["a": ["e10": 131.0], "b": ["e10": 131.0]],
"2026-08-15": ["a": ["e10": 129.0], "b": ["e10": 130.5]],
]
let stations = [("a", "Asda A"), ("b", "Bp B")]
let raw = FuelHistoryStore.series(fromCache: cache, stations: stations, fuel: .e10, days: days)
let delta = FuelHistoryStore.deltaSeries(raw)
// Day 1: a=0, b=+2. Day 2: both 0. Day 3: a=0, b=+1.5
XCTAssertEqual(delta[0].points.map(\.pence), [0, 0, 0])
XCTAssertEqual(delta[1].points.map(\.pence), [2.0, 0, 1.5])
}
func testDeltaSeriesGapDayKeepsOnlyPresentStations() {
let cache: [String: [String: [String: Double]]] = [
"2026-08-15": ["a": ["e10": 129.0]], // b missing this day
]
let raw = FuelHistoryStore.series(fromCache: cache, stations: [("a", "A"), ("b", "B")], fuel: .e10, days: ["2026-08-15"])
let delta = FuelHistoryStore.deltaSeries(raw)
XCTAssertEqual(delta[0].points.map(\.pence), [0])
XCTAssertTrue(delta[1].points.isEmpty) // gap stays a gap
}
// MARK: Cache pruning
func testPruneKeepsRecentDaysOnly() {
let days = FuelHistoryStore.neededDays(back: FuelHistoryStore.maxCachedDays)
var cache: [String: [String: [String: Double]]] = [:]
for (i, d) in days.enumerated() {
cache[d] = ["s1": ["e10": Double(100 + i)]]
}
cache["2020-01-01"] = ["s1": ["e10": 1.0]] // stale must go
let pruned = FuelHistoryStore.prunedCache(cache)
XCTAssertNil(pruned["2020-01-01"])
XCTAssertEqual(pruned.count, days.count)
}
// MARK: Chart-key average
func testAveragePenceEmptyIsNil() {
XCTAssertNil(FuelHistoryStore.averagePence([]))
}
func testAveragePenceSinglePoint() {
let d = FuelHistoryStore.date(fromDay: "2026-08-15")!
XCTAssertEqual(FuelHistoryStore.averagePence([PricePoint(date: d, pence: 156.7)]), 156.7)
}
func testAveragePenceMultiplePoints() {
let d1 = FuelHistoryStore.date(fromDay: "2026-08-15")!
let d2 = FuelHistoryStore.date(fromDay: "2026-08-16")!
// (153.9 + 156.7) / 2 = 155.3 exact in binary? 153.9+156.7=310.6, /2=155.3
let avg = FuelHistoryStore.averagePence([
PricePoint(date: d1, pence: 153.9),
PricePoint(date: d2, pence: 156.7),
])
XCTAssertNotNil(avg)
XCTAssertEqual(avg!, 155.3, accuracy: 0.0001)
}
// MARK: Mirror URLs
func testHistoryFileURLKeepsBaseLastSegment() {
// Regression: URL(string:relativeTo:) drops the base's last segment
// ("main") without a trailing slash every fetch 404'd (2026-08-15).
let base = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")!
XCTAssertEqual(
FuelHistoryStore.historyFileURL(day: "2026-08-15", base: base).absoluteString,
"https://raw.githubusercontent.com/aptonline/fuelboard-data/main/history/2026-08-15.json"
)
XCTAssertEqual(
FuelHistoryStore.latestFileURL(base: base).absoluteString,
"https://raw.githubusercontent.com/aptonline/fuelboard-data/main/latest.json"
)
}
}
@@ -0,0 +1,186 @@
import XCTest
@testable import FuelBoardShared
/// P0 live chain: GitHub mirror FIRST everywhere [dev-only LAN relay]
/// (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`.
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 {
var result: [FuelStation]
var error: Error?
private(set) var callCount = 0
init(result: [FuelStation]? = nil, error: Error? = nil) {
self.result = result ?? []
self.error = error
}
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
callCount += 1
if let error { throw error }
return result
}
}
private func fixtureStation(_ id: String = "s1", lat: Double = 53.7, lng: Double = -1.8) -> FuelStation {
FuelStation(id: id, name: "Station \(id)", brand: "Test", address: "1 High St",
postcode: "SW1A 1AA", lat: lat, lng: lng,
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil)
}
// MARK: Full path (app refresh): GitHub first
func testFullPathPrefersMirror() async throws {
let mirror = StubProvider(result: [fixtureStation()])
let relay = StubProvider(error: FuelProviderError.relayUnavailable)
let chain = LiveChainProvider(mirror: mirror, relay: relay)
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(LiveChainProvider.lastSource, "github")
}
func testFullPathRelaySkippedWhenDisabled() async {
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
let relay = StubProvider(result: [fixtureStation("relay")])
let chain = LiveChainProvider(mirror: mirror, relay: relay)
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")
} catch {
XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay")
}
}
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)
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 {
let mirror = StubProvider(result: [fixtureStation("mirror")])
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, "mirror", "focused path must prefer the mirror — the relay is unreachable off-LAN")
XCTAssertEqual(relay.callCount, 0)
XCTAssertEqual(LiveChainProvider.lastSource, "github")
}
func testFocusedPathRelaySkippedWhenDisabled() async {
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
let relay = StubProvider(result: [fixtureStation("relay")])
let chain = LiveChainProvider(mirror: mirror, relay: relay)
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")
} catch {
XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay")
}
}
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() {
let here = fixtureStation("here", lat: 53.7, lng: -1.8) // e10+others, at origin
let far = fixtureStation("far", lat: 54.0, lng: -1.8) // ~33 km away, e10
let noFuel = FuelStation(id: "noFuel", name: "Diesel Only", brand: "Test",
address: "2 High St", postcode: "SW1A 1AA",
lat: 53.7, lng: -1.8, prices: [.diesel: 144.9], priceUpdated: nil)
let d1 = fixtureStation("d1", lat: 53.701, lng: -1.8)
let d2 = fixtureStation("d2", lat: 53.702, lng: -1.8)
let out = MirrorFuelProvider.focused([far, noFuel, d2, d1, here],
near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 10)
XCTAssertEqual(out.map(\.id), ["here", "d1", "d2"], "nearest-first, fuel-only, within radius")
XCTAssertFalse(out.contains { $0.id == "far" }, "outside radius must drop")
XCTAssertFalse(out.contains { $0.id == "noFuel" }, "missing fuel must drop")
}
func testMirrorFocusedPassesThroughWithoutRadius() {
let stations = [fixtureStation("a"), fixtureStation("b")]
XCTAssertEqual(MirrorFuelProvider.focused(stations, near: nil, lng: nil, fuel: .e10, radiusKM: nil).count, 2)
}
func testMirrorFocusedLimit() {
let stations = (0..<600).map { fixtureStation("s\($0)", lat: 53.7 + Double($0) * 0.0001, lng: -1.8) }
let out = MirrorFuelProvider.focused(stations, near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 100)
XCTAssertEqual(out.count, 500, "focused projection must stay bounded")
}
// MARK: Mirror day-cache decision
func testDumpCacheReuseDecision() {
XCTAssertTrue(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: "2026-08-15"),
"same day → reuse the cached dump, no ~2.8 MB re-download")
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-14", latestDay: "2026-08-15"))
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: nil, latestDay: "2026-08-15"))
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")
}
}
+133 -39
View File
@@ -3,21 +3,30 @@
// Lives in the widget extension (the standard host for ActivityConfiguration).
// Shows the cheapest station for the pinned fuel within the app's chosen
// radius. Tapping anywhere opens Apple Maps directions to that station.
//
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
// declared (see note on the config), so iPhone/iPad always render the full
// three-column card (`richBody`) with proper text sizes. `compactBody` is kept
// as a safety fallback should any surface ever hand this view a narrow width.
//
// IMPORTANT: `.supplementalActivityFamilies([.small])` is deliberately absent
// it made iOS render the squeezed `.small` card on the iPhone Lock Screen.
// CarPlay's small form comes from the Dynamic Island compact closures.
import ActivityKit
import SwiftUI
import WidgetKit
/// The Live Activity itself registered in the widget bundle alongside the
/// regular price widget. No CarPlay entitlement involved: this renders on the
/// Lock Screen, Dynamic Island, and the car display (CarPlay Ultra, iOS 26+).
/// `.supplementalActivityFamilies([.small])` makes it eligible for the car's
/// small Live Activity slot display-only there (FuelBoard is not a
/// CarPlay-enabled app, so car-side taps can't launch anything).
/// regular price widget. No CarPlay entitlement involved: renders on the
/// Lock Screen (full-width card on iPhone/iPad) and the Dynamic Island
/// (incl. the island's compact form used in the car, display-only FuelBoard
/// is not a CarPlay-enabled app, so car-side taps can't launch anything).
struct FuelBoardLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
// Lock Screen / banner presentation
// Lock Screen / banner / CarPlay small slot presentation
FuelBoardLiveActivityView(context: context)
} dynamicIsland: { context in
DynamicIsland {
@@ -32,7 +41,7 @@ struct FuelBoardLiveActivity: Widget {
}
} compactLeading: {
Image(systemName: "fuelpump.fill")
.foregroundStyle(.green)
.foregroundStyle(context.state.fuel.tintColor)
} compactTrailing: {
FuelBoardLiveActivityPriceView(context: context)
} minimal: {
@@ -41,45 +50,127 @@ struct FuelBoardLiveActivity: Widget {
}
}
.supplementalActivityFamilies([.small])
// Why `.small` is kept: it lets the SHARED body render a compact form
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
// instead of falling back to the Dynamic Island compact closure
// which could NOT show the station distance the user wants on CarPlay.
// The full-width iPhone/iPad card is protected by the `richMinWidth`
// gate on `richBody` + its flexible, truncating middle column, so
// iPhone/iPad still get the full card; only truly small space picks
// the compact strip below.
}
}
/// Lock Screen / banner body the main presentation.
/// Lock Screen / banner / CarPlay body adaptive.
private struct FuelBoardLiveActivityView: View {
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
/// Below this ACTUAL proposed width we show the compact strip (CarPlay
/// small / Watch smart stack); at/above it we show the full card. The
/// decision is made from the real width the system hands the body, read
/// via a background GeometryReader NOT ViewThatFits ideal-width
/// measurement (that's broken for truncating text: a long station name
/// inflated the "ideal" width past the iPhone Lock Screen and collapsed
/// the full card).
private let compactWidthThreshold: CGFloat = 280
/// Measured slot width (drives the rich-vs-compact branch). Measured in a
/// background GeometryReader so it does NOT act as the layout container:
/// a GeometryReader root pins content top-left, and forcing a
/// maxHeight:.infinity frame on it over-claims the whole proposed height,
/// centring the content below true vertical centre (bigger gap above) on
/// the Lock Screen. Measuring behind the scenes keeps the content
/// intrinsic-sized so the system vertically centres it itself.
@State private var slotWidth: CGFloat = 400
var body: some View {
Link(destination: context.state.mapsURL) {
HStack(spacing: 12) {
// LEFT station brand glyph
Image(systemName: "fuelpump.circle.fill")
.font(.system(size: 32))
.foregroundStyle(.green, .white)
.frame(width: 40, height: 40)
// MIDDLE fuel + station
VStack(alignment: .leading, spacing: 2) {
Text("Cheapest \(context.state.fuel.displayName)")
.font(.headline)
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
// RIGHT price
VStack(alignment: .trailing, spacing: 2) {
Text(context.state.priceText)
.font(.title2.bold().monospacedDigit())
Text("Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
Group {
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
// full Lock Screen width (>= threshold) rich card, no matter
// how long the station name is. Truly small slots (CarPlay /
// Watch) offer much less compact strip.
if slotWidth >= compactWidthThreshold {
richBody
} else {
compactBody
}
}
.padding()
// Fill the card width so the background measure reads the real
// slot, not the intrinsic content width.
.frame(maxWidth: .infinity)
// Side-channel width measurement never the layout container.
.background(
GeometryReader { geo in
Color.clear
.onAppear { slotWidth = geo.size.width }
.onChange(of: geo.size.width) { _, w in slotWidth = w }
}
)
}
}
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
private var richBody: some View {
HStack(spacing: 12) {
// LEFT station brand glyph: the fuel-tinted pump on its own. No
// background circle behind it (user request).
Image(systemName: "fuelpump.fill")
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(context.state.fuel.tintColor)
.frame(width: 40, height: 40)
// MIDDLE fuel + station
VStack(alignment: .leading, spacing: 2) {
Text("Cheapest \(context.state.fuel.displayName)")
.font(.headline)
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
// RIGHT price
VStack(alignment: .trailing, spacing: 2) {
FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle,
size: 22, weight: .bold)
Text("Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding()
}
/// Minimal strip for small space (CarPlay small / Watch smart stack):
/// fuel type + bold price on one line, station · distance below.
/// Deliberately no app name and no "Tap for directions" CarPlay is
/// display-only, and the user's asks here are just fuel + price + distance.
private var compactBody: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
Text(context.state.fuel.displayName)
.font(.caption.bold())
.lineLimit(1)
Spacer(minLength: 4)
FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle,
size: 15, weight: .bold)
.lineLimit(1)
}
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.system(size: 9))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.truncationMode(.tail)
}
.padding(8)
}
}
/// Dynamic Island expanded regions + compact trailing price only.
@@ -87,8 +178,9 @@ private struct FuelBoardLiveActivityPriceView: View {
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
var body: some View {
Text(context.state.priceText)
.font(.headline.bold().monospacedDigit())
FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle,
size: 17, weight: .bold)
}
}
@@ -127,13 +219,15 @@ extension FuelBoardLiveActivityAttributes.ContentState {
FuelStore.loadDistanceUnit().format(distanceKM)
}
/// £ price string from pence, e.g. 161.9 -> "£1.619".
/// Price string from pence, in the user's chosen display style
/// (station sign "129.9" or pounds "£1.29/L"). Uses the style captured
/// in the state at push time.
var priceText: String {
String(format: "£%.3f", pricePence / 100)
FuelStore.priceText(pricePence, style: priceDisplayStyle)
}
/// Apple Maps directions URL to the pinned station.
var mapsURL: URL {
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
}
}
}
+56 -175
View File
@@ -2,20 +2,9 @@ import WidgetKit
import SwiftUI
import AppIntents
// Fuel colour wheel mirrors the app's palette in StationsView.swift
// (FuelType.tintColor): green = unleaded (#30D158), yellow = premium
// (#FFD60A), cyan = diesel (#64D2FF). Kept here (widget target) because
// Shared/FuelStore.swift is Foundation-only by design; if the app's copy
// ever moves to Shared, DELETE this duplicate to avoid redeclaration.
private extension FuelType {
var fuelColor: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
}
// Fuel colour wheel lives in Shared/FuelPriceWidgetViews.swift (moved from
// here 2026-08-16 so the app's widget preview screen uses the same colours).
// Shared/FuelStore.swift stays Foundation-only by design.
// FuelBoard widget petrol stations near you. ONE widget kind, TWO sizes:
// .systemSmall: single station (the first of the configured result the
@@ -84,14 +73,8 @@ struct FuelPriceWidget: Widget {
}
struct FuelPriceEntry: TimelineEntry {
let date: Date
let stations: [FuelStation] // already filtered + sorted per config
let fuel: FuelType
let sort: SortMode // per-widget: cheapest | closest
let isFavourites: Bool // favourites mode: pinned stations, cheapest-first
let location: Coordinate?
let locationSource: String // "live" | "cached" | "none"
let unit: DistanceUnit // user's display unit for distances
let data: FuelPriceWidgetEntryData
var date: Date { data.date }
}
struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & WidgetConfigValues>: AppIntentTimelineProvider {
@@ -109,9 +92,11 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
.filter { $0.prices[.e10] != nil }
.sorted { $0.prices[.e10]! < $1.prices[.e10]! }
return FuelPriceEntry(
date: Date(), stations: Array(sample.prefix(8)),
fuel: .e10, sort: .cheapest, isFavourites: false,
location: nil, locationSource: "none", unit: unit
data: FuelPriceWidgetEntryData(
date: Date(), stations: Array(sample.prefix(8)),
fuel: .e10, sort: .cheapest, isFavourites: false,
location: nil, locationSource: "none", unit: unit
)
)
}
@@ -142,32 +127,36 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
/// extension and what it produced. Deliberately outside the timeline
/// result can never affect rendering.
private func writeDiagBeacon(entry: FuelPriceEntry) {
let d = entry.data
let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
let first = entry.stations.first
let first = d.stations.first
let json = """
{"intent":"\(intentType)","source":"\(entry.locationSource)",\
"n":\(entry.stations.count),"fuel":"\(entry.fuel.rawValue)",\
"sort":"\(entry.sort.rawValue)","fav":\(entry.isFavourites),\
"station":"\(first?.name ?? "")","price":\(first?.prices[entry.fuel] ?? -1)}
{"intent":"\(intentType)","source":"\(d.locationSource)",\
"n":\(d.stations.count),"fuel":"\(d.fuel.rawValue)",\
"sort":"\(d.sort.rawValue)","fav":\(d.isFavourites),\
"station":"\(first?.name ?? "")","price":\(first?.prices[d.fuel] ?? -1)}
"""
FuelStore.saveWidgetDiag(json, intentType: intentType)
// The relay GET is dev-only (same flag as the app beacon): consumers
// must never make a local-network attempt. The local app-group diag
// write above stays Settings Widget Diagnostics reads that.
guard FuelStore.loadRelayFallbackEnabled() else { return }
guard var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"),
resolvingAgainstBaseURL: false
) else { return }
components.queryItems = [
URLQueryItem(name: "intent", value: intentType),
URLQueryItem(name: "source", value: entry.locationSource),
URLQueryItem(name: "n", value: String(entry.stations.count)),
URLQueryItem(name: "fuel", value: entry.fuel.rawValue),
URLQueryItem(name: "sort", value: entry.sort.rawValue),
URLQueryItem(name: "fav", value: entry.isFavourites ? "1" : "0"),
URLQueryItem(name: "source", value: d.locationSource),
URLQueryItem(name: "n", value: String(d.stations.count)),
URLQueryItem(name: "fuel", value: d.fuel.rawValue),
URLQueryItem(name: "sort", value: d.sort.rawValue),
URLQueryItem(name: "fav", value: d.isFavourites ? "1" : "0"),
URLQueryItem(name: "station", value: first?.name ?? ""),
URLQueryItem(name: "price", value: String(first?.prices[entry.fuel] ?? -1)),
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
]
guard let url = components.url else { return }
var request = URLRequest(url: url)
request.timeoutInterval = 2
let request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
Task {
_ = try? await URLSession.shared.data(for: request)
}
@@ -291,17 +280,27 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
}
return FuelPriceEntry(
date: Date(), stations: Array(ordered.prefix(8)),
fuel: fuel, sort: sort, isFavourites: isFavourites,
location: location, locationSource: source,
unit: unit
data: FuelPriceWidgetEntryData(
date: Date(), stations: Array(ordered.prefix(8)),
fuel: fuel, sort: sort, isFavourites: isFavourites,
location: location, locationSource: source,
unit: unit
)
)
}
/// One focused fetch from the relay around the widget's location small
/// response (radius + limit 500), bounded so a dead relay can't stall the
/// timeline. Returns nil on any failure so callers fall back to cache.
/// One focused fetch around the widget's location. GitHub mirror FIRST
/// HTTPS, works anywhere, and the app-group day-cache usually makes it a
/// local decode rather than a 2.8 MB download. The LAN relay only joins
/// behind the dev flag (fuelboard.relayFallback); consumers must never
/// attempt local-network access. Returns nil on any failure so callers
/// fall back to cache/placeholder.
private static func fetchFocused(near location: Coordinate, fuel: FuelType, radiusKM: Double) async -> [FuelStation]? {
if let stations = try? await MirrorFuelProvider().fetchStations(
near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM) {
return stations
}
guard FuelStore.loadRelayFallbackEnabled() else { return nil }
var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
resolvingAgainstBaseURL: false
@@ -313,8 +312,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
URLQueryItem(name: "radius", value: String(radiusKM)),
URLQueryItem(name: "limit", value: "500"),
]
var request = URLRequest(url: components.url!)
request.timeoutInterval = 5
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
@@ -324,11 +322,16 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
}
}
/// Fuel-only UK-wide fetch (no lat/lng/radius) the relay returns the
/// cheapest-first dataset for the fuel, bounded by limit. Used when the
/// widget has no location fix so the face shows real stations instead of
/// sample data. Same timeout/bounded semantics as fetchFocused.
/// Fuel-only UK-wide fetch (no lat/lng/radius) used when the widget has
/// no location fix so the face shows real stations instead of sample
/// data. GitHub mirror first (full dump + local fuel filter), relay only
/// behind the dev flag. Same nil-on-failure semantics as fetchFocused.
private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [FuelStation]? {
if let stations = try? await MirrorFuelProvider().fetchStations(
near: nil, lng: nil, fuel: fuel, radiusKM: nil) {
return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit))
}
guard FuelStore.loadRelayFallbackEnabled() else { return nil }
var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
resolvingAgainstBaseURL: false
@@ -337,8 +340,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
URLQueryItem(name: "fuel", value: fuel.rawValue),
URLQueryItem(name: "limit", value: String(limit)),
]
var request = URLRequest(url: components.url!)
request.timeoutInterval = 5
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
@@ -354,127 +356,6 @@ struct FuelPriceWidgetView: View {
let entry: FuelPriceEntry
var body: some View {
Group {
if entry.stations.isEmpty {
VStack(spacing: 6) {
Image(systemName: entry.isFavourites ? "star" : "fuelpump")
.font(.title2)
.foregroundStyle(.secondary)
Text(entry.isFavourites
? "No favourite \(entry.fuel.displayName) stations"
: "No \(entry.fuel.displayName) stations")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else if family == .systemSmall {
singleStation
} else {
stationList
}
}
}
private var heading: String {
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
return entry.sort == .closest
? "Closest \(entry.fuel.displayName)"
: "Cheapest \(entry.fuel.displayName)"
}
private var singleStation: some View {
let station = entry.stations[0]
return VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(entry.fuel.fuelColor)
Text(heading)
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(station.name)
.font(.headline)
.lineLimit(1)
if let price = station.prices[entry.fuel] {
Text(String(format: "£%.3f", price / 100))
.font(.system(size: 26, weight: .bold).monospaced())
.foregroundStyle(.green)
}
if let location = entry.location {
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
Text("Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.widgetURL(station.widgetDirectionsURL)
}
/// How many station rows each family can fit: medium widgets are short
/// (158pt) so 3 rows is the safe cap; large/extraLarge have ~2× the height
/// and fit 5 compact rows. Small widgets use singleStation instead.
private var maxRows: Int {
switch family {
case .systemMedium: return 3
default: return 5
}
}
private var stationList: some View {
let cheapest = entry.stations.compactMap { $0.prices[entry.fuel] }.min()
return VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(entry.fuel.fuelColor)
Text(heading)
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
Text(entry.isFavourites ? "cheapest first"
: (entry.locationSource == "none" ? "by price" : "near you"))
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(entry.stations.prefix(maxRows)) { station in
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
HStack(spacing: 8) {
Text(station.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
if let location = entry.location {
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
if let price = station.prices[entry.fuel] {
HStack(spacing: 4) {
Circle()
.fill(ragColor(for: price, cheapest: cheapest))
.frame(width: 6, height: 6)
Text(String(format: "£%.3f", price / 100))
.font(.caption.weight(.bold).monospaced())
.foregroundStyle(.primary)
}
}
}
}
.buttonStyle(.plain)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func ragColor(for price: Double, cheapest: Double?) -> Color {
guard let cheapest else { return .gray }
switch RAGRating.rating(price: price, cheapest: cheapest) {
case .green: return .green
case .amber: return .orange
case .red: return .red
}
FuelPriceWidgetContent(entry: entry.data, family: family)
}
}
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+9
View File
@@ -0,0 +1,9 @@
# Source of truth
The source of truth for this repo is the clone on Proxmox CT 111
(`fuelboard-relay`, 192.168.1.113): `/root/workspace/fuelboard` — since 2026-08-16.
- Edit BACKLOG.md and any repo content on the CT: `ssh fuelboard-relay`
- Commit as FuelBoard Contributor, push origin (Gitea; PAT in /root/.netrc on the CT)
- This Mac clone (`~/workspace/fuelboard`) is a READ-ONLY mirror — refresh it
interactively with `git pull` (Mac keychain required; agent sessions cannot read it)
+4
View File
@@ -25,6 +25,10 @@ struct FuelBoardLiveActivityAttributes: ActivityAttributes {
var brand: String
/// Price in pence per litre (same unit as `FuelStation.prices`).
var pricePence: Double
/// Display style at push time (station sign vs pounds & pence) carried
/// in the state so the extension renders deterministically instead of
/// re-reading storage that may not be shared on sideloads.
var priceDisplayStyle: PriceDisplayStyle
var distanceKM: Double
var lat: Double
var lng: Double
+283
View File
@@ -0,0 +1,283 @@
// FuelHistory.swift price-history store for the Favourites Trends graph.
//
// Reads the GitHub price mirror (aptonline/fuelboard-data, pushed daily by the
// LAN relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps.
// The store keeps a favourites-only day cache in the app group so the chart
// works offline once a day has been seen, and only fetches the days it is
// missing. Foundation-only so the unit-test target can compile it on macOS.
//
// Payload shape (verified 2026-08-15): each day file is the raw relay
// /api/v1/stations response { fuel, count, stations_count, source,
// data_updated, stations: [{ id, name, brand, address, postcode, lat, lng,
// price, prices: {E5/E10/DIESEL: pence}, price_updated }] }.
import Foundation
// MARK: - History model
/// One price observation for a station on a calendar day (pence/litre).
struct PricePoint: Identifiable, Equatable, Codable {
let date: Date
let pence: Double
var id: String { "\(date.timeIntervalSince1970)" }
}
/// A favourite station's price series for one fuel, oldest-first.
struct StationHistory: Equatable, Identifiable {
let stationID: String
let name: String
let fuel: FuelType
let points: [PricePoint]
/// A series is unique per (station, fuel) the two keys the archive and
/// favourites are scoped by.
var id: String { "\(stationID)-\(fuel.rawValue)" }
}
/// The mirror pointer file (latest.json).
struct MirrorLatest: Codable, Equatable {
let date: String?
let stationCount: Int?
let dataUpdated: String?
let availableFrom: String?
let availableTo: String?
enum CodingKeys: String, CodingKey {
case date
case stationCount = "station_count"
case dataUpdated = "data_updated"
case availableFrom = "available_from"
case availableTo = "available_to"
}
}
enum FuelHistoryError: LocalizedError {
case unavailable
case invalidData
var errorDescription: String? {
switch self {
case .unavailable: return "Price history mirror unreachable."
case .invalidData: return "Price history data was invalid."
}
}
}
// MARK: - Store
enum FuelHistoryStore {
/// GitHub raw mirror base the app reads history directly from here;
/// the LAN relay remains the live-data fallback.
static let mirrorBase = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")!
static let historyCacheKey = "fuelboard.historyCache"
static let maxCachedDays = 90
static let rangeOptions = [7, 30, 90]
// MARK: Pure helpers (unit-tested)
/// Calendar day string (yyyy-MM-dd) for a date, anchored at UTC noon so
/// timezone shifts never move a snapshot to the wrong day.
static func dayString(_ date: Date = Date()) -> String {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
let noon = cal.date(bySettingHour: 12, minute: 0, second: 0, of: date) ?? date
let fmt = DateFormatter()
fmt.calendar = cal
fmt.timeZone = cal.timeZone
fmt.dateFormat = "yyyy-MM-dd"
return fmt.string(from: noon)
}
/// Parse a day-string back into a Date (UTC noon), for chart x-values.
static func date(fromDay day: String) -> Date? {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
let fmt = DateFormatter()
fmt.calendar = cal
fmt.timeZone = cal.timeZone
fmt.dateFormat = "yyyy-MM-dd"
return fmt.date(from: day)
}
/// The last `days` calendar-day strings, oldest-first, today included.
static func neededDays(back days: Int, from today: Date = Date()) -> [String] {
guard days > 0 else { return [] }
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
return (0..<days).reversed().compactMap { offset in
guard let d = cal.date(byAdding: .day, value: -offset, to: today) else { return nil }
return dayString(d)
}
}
/// Slim-decode one day file into [stationID: [FuelType: pence]] for the
/// requested station IDs only (a full decode of 8k stations per day would
/// be wasteful). Applies the same price band as the live decode.
static func parseDayStations(_ data: Data, stationIDs: Set<String>) throws -> [String: [FuelType: Double]] {
struct DayPayload: Codable {
struct S: Codable {
let id: String?
let prices: [String: Double]?
}
let stations: [S]
}
let payload = try JSONDecoder().decode(DayPayload.self, from: data)
var out: [String: [FuelType: Double]] = [:]
for s in payload.stations {
guard let id = s.id, stationIDs.contains(id), let prices = s.prices else { continue }
let mapped = FuelPriceProvider.mapGrades(prices)
if !mapped.isEmpty { out[id] = mapped }
}
return out
}
/// Builds per-station series from the day cache. Days without an entry for
/// a station are gaps (skipped) never fabricated.
static func series(fromCache cache: [String: [String: [String: Double]]],
stations: [(id: String, name: String)],
fuel: FuelType,
days: [String]) -> [StationHistory] {
stations.map { station in
let points: [PricePoint] = days.compactMap { day in
guard let pence = cache[day]?[station.id]?[fuel.rawValue] else { return nil }
guard let date = date(fromDay: day) else { return nil }
return PricePoint(date: date, pence: pence)
}
return StationHistory(stationID: station.id, name: station.name,
fuel: fuel, points: points)
}
}
/// Average pence over a series' points; nil when there are no points
/// (a station with no data shows no bracket figure in the chart key).
static func averagePence(_ points: [PricePoint]) -> Double? {
guard !points.isEmpty else { return nil }
return points.reduce(0) { $0 + $1.pence } / Double(points.count)
}
/// Rebase every station's series so each day's CHEAPEST favourite sits at
/// 0 and the others show signed pence above it (mirrors the list's
/// baseline delta pattern). Days where a station has no point are gaps.
static func deltaSeries(_ series: [StationHistory]) -> [StationHistory] {
var dayMin: [Date: Double] = [:]
for s in series {
for p in s.points {
dayMin[p.date] = min(dayMin[p.date] ?? .infinity, p.pence)
}
}
return series.map { s in
StationHistory(stationID: s.stationID, name: s.name, fuel: s.fuel,
points: s.points.compactMap { p in
guard let min = dayMin[p.date] else { return nil }
return PricePoint(date: p.date, pence: p.pence - min)
})
}
}
/// Keep only the most recent `maxCachedDays` days (bounded growth).
static func prunedCache(_ cache: [String: [String: [String: Double]]],
from today: Date = Date()) -> [String: [String: [String: Double]]] {
let keep = Set(neededDays(back: maxCachedDays, from: today))
return cache.filter { keep.contains($0.key) }
}
// MARK: Cache (app-group, favourites-only)
/// Cache shape: [day: [stationID: [fuelRawValue: pence]]].
static func loadCache() -> [String: [String: [String: Double]]] {
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
let data = defaults.data(forKey: historyCacheKey),
let cache = try? JSONDecoder().decode([String: [String: [String: Double]]].self, from: data) else {
return [:]
}
return cache
}
static func saveCache(_ cache: [String: [String: [String: Double]]]) {
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
let data = try? JSONEncoder().encode(prunedCache(cache)) else { return }
defaults.set(data, forKey: historyCacheKey)
}
// MARK: URLs
/// The mirror pointer URL. Built with appendingPathComponent NOT
/// URL(string:relativeTo:), which silently drops the base's last segment
/// ("main") when the base lacks a trailing slash (found 2026-08-15: every
/// history fetch 404'd because the path resolved without /main).
static func latestFileURL(base: URL = mirrorBase) -> URL {
base.appendingPathComponent("latest.json")
}
static func historyFileURL(day: String, base: URL = mirrorBase) -> URL {
base.appendingPathComponent("history/\(day).json")
}
// MARK: Network
/// The mirror pointer used for the empty-state hint ("first snapshot
/// landed "). Non-fatal: nil just means no hint. Deliberately bypasses
/// the HTTP cache (reloadIgnoringLocalCacheData) so an OFFLINE fetch
/// really fails instead of silently re-serving a cached pointer as a
/// success otherwise "no network" would look like fresh data and the
/// offline banner would never fire. A short timeout surfaces the failure
/// (and the banner) quickly on a connected-but-dead network.
static func fetchLatest(base: URL = mirrorBase, timeout: TimeInterval = 10,
session: URLSession = .shared) async -> MirrorLatest? {
var req = URLRequest(url: latestFileURL(base: base))
req.cachePolicy = .reloadIgnoringLocalCacheData
req.timeoutInterval = timeout
guard let (data, response) = try? await session.data(for: req),
(response as? HTTPURLResponse)?.statusCode == 200,
let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else {
return nil
}
return latest
}
/// Fetches price history for the given favourites (station id + name) and
/// fuel over the last `days` calendar days. Missing days (404, network
/// failure, station not present that day) become gaps, never errors. Uses
/// the local cache first and only fetches days it doesn't have complete.
static func fetchHistory(stations: [(id: String, name: String)],
fuel: FuelType,
days: Int,
base: URL = mirrorBase,
session: URLSession = .shared) async -> [StationHistory] {
guard !stations.isEmpty, days > 0 else { return [] }
let ids = Set(stations.map(\.id))
let dayList = neededDays(back: days)
var cache = loadCache()
// Fetch missing/incomplete days in parallel; per-day failures are
// silent gaps so one bad day never kills the whole chart.
var fetched: [(day: String, value: [String: [String: Double]])] = []
await withTaskGroup(of: (String, [String: [String: Double]]?).self) { group in
for day in dayList {
group.addTask {
if let cached = cache[day], ids.allSatisfy({ cached[$0] != nil }) {
return (day, nil) // already complete skip network
}
do {
let (data, response) = try await session.data(from: historyFileURL(day: day, base: base))
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return (day, nil) }
let parsed = try parseDayStations(data, stationIDs: ids)
let encoded: [String: [String: Double]] = parsed.mapValues { prices in
Dictionary(uniqueKeysWithValues: prices.map { ($0.key.rawValue, $0.value) })
}
return (day, encoded.isEmpty ? nil : encoded)
} catch {
return (day, nil)
}
}
}
for await result in group {
if let value = result.1 { fetched.append((result.0, value)) }
}
}
for (day, value) in fetched { cache[day] = value }
saveCache(cache)
return series(fromCache: cache, stations: stations, fuel: fuel, days: dayList)
}
}
+46 -18
View File
@@ -19,9 +19,9 @@ protocol FuelPriceProviding {
}
enum FuelPriceProvider {
/// Default: the keyless relay (full-UK Fuel Finder data). Falls back to
/// the England-wide sample set when the relay is unreachable.
static let active: FuelPriceProviding = RelayFuelProvider()
/// Default: the live chain GitHub mirror (primary, off-LAN) LAN
/// relay (fallback) bundled dump (last resort in the app target).
static let active: FuelPriceProviding = LiveChainProvider()
/// Decodes a relay payload into stations, applying the defensive price
/// band. Internal so the unit-test target can exercise the guard.
@@ -42,6 +42,28 @@ enum FuelPriceProvider {
}
}
/// Price sanity band (pence/litre) anything outside is relay regression
/// garbage and must never reach calculations (shared by the live decode
/// and the history mirror decoder).
static let priceBand: ClosedRange<Double> = 50...500
/// Maps relay grade keys (E5/E10/DIESEL) to FuelType with the defensive
/// band guard. Shared by the relay decode and the history mirror parser so
/// both surfaces apply identical sanitisation.
static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] {
var result: [FuelType: Double] = [:]
for (grade, value) in prices {
guard priceBand.contains(value) else { continue }
switch grade.uppercased() {
case "E10": result[.e10] = value
case "E5": result[.e5] = value
case "DIESEL", "B7", "B7S", "B7P", "B10": result[.diesel] = value
default: break
}
}
return result
}
/// Decodes the relay envelope metadata (source, dataset update time) the
/// About section shows these so the user can see which data source is
/// live and how fresh the GOV.UK data itself is. The fields are additive
@@ -71,6 +93,19 @@ struct RelayFuelProvider: FuelPriceProviding {
/// dataset update time). Written by fetchStations, read by the view.
static var latestMeta: RelayMeta?
/// Admin telemetry: the relay tags inbound connections by client kind
/// (app vs widget) so the developer can monitor the poll model. Siri
/// never fetches (cached-data-first). Purely observational the relay
/// ignores the header for responses.
static let clientTagHeader = "X-Client"
static func relayRequest(_ url: URL, client: String, timeout: TimeInterval = 30) -> URLRequest {
var request = URLRequest(url: url)
request.timeoutInterval = timeout
request.setValue(client, forHTTPHeaderField: clientTagHeader)
return request
}
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
var components = URLComponents(url: baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false)!
var query: [URLQueryItem] = [URLQueryItem(name: "fuel", value: fuel.rawValue)]
@@ -89,7 +124,8 @@ struct RelayFuelProvider: FuelPriceProviding {
}
components.queryItems = query
let (data, response) = try await URLSession.shared.data(from: components.url!)
let request = RelayFuelProvider.relayRequest(components.url!, client: "app")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw FuelProviderError.relayUnavailable
}
@@ -129,20 +165,9 @@ private struct RelayResponse: Codable {
/// regression (e.g. the band being removed server-side) can't re-poison
/// the nationwide cheapest reference with 1.3p / 1589p garbage.
var allPrices: [FuelType: Double] {
var result: [FuelType: Double] = [:]
if let prices {
for (grade, value) in prices {
guard (50...500).contains(value) else { continue }
switch grade.uppercased() {
case "E10": result[.e10] = value
case "E5": result[.e5] = value
case "DIESEL", "B7", "B7S", "B7P", "B10": result[.diesel] = value
default: break
}
}
}
var result = FuelPriceProvider.mapGrades(prices ?? [:])
// Backwards-compat: relay versions without `prices` still send `price`.
if result.isEmpty, let price, (50...500).contains(price) {
if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) {
result[.e10] = price
}
return result
@@ -164,7 +189,7 @@ struct RelayMeta: Codable, Equatable {
/// Representative stations spread across ENGLAND (one cluster per region) so
/// the fallback works anywhere in the country it is NOT tied to one town.
/// Real England-wide data comes from the relay (full-UK Fuel Finder CSV/API).
/// Real UK-wide data comes from the relay (full-UK Fuel Finder CSV/API).
/// Prices in pence/litre.
struct SampleFuelProvider: FuelPriceProviding {
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
@@ -271,12 +296,15 @@ struct FuelFinderProvider: FuelPriceProviding {
enum FuelProviderError: LocalizedError {
case notImplemented
case relayUnavailable
case mirrorUnavailable
var errorDescription: String? {
switch self {
case .notImplemented:
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
case .relayUnavailable:
return "FuelBoard Relay unreachable — showing cached/sample data."
case .mirrorUnavailable:
return "GitHub price mirror unreachable."
}
}
}
+187
View File
@@ -0,0 +1,187 @@
import SwiftUI
import WidgetKit
// Shared widget rendering compiled by BOTH the widget extension and the
// app target. The app uses it for the in-app widget preview screen (launch
// arg `-widgets`); the widget extension uses it for real timelines.
// WidgetKit is available to the app target, so WidgetFamily is fine here.
// NOTE: FuelBoardTests only symlinks specific Shared files, so this file is
// NOT compiled by the SPM test target (it needs WidgetKit).
// Fuel colour wheel mirrors the app's palette in StationsView.swift
// (FuelType.tintColor): green = unleaded (#30D158), yellow = premium
// (#FFD60A), cyan = diesel (#64D2FF). Lives here because both the widget and
// the app preview need it; Shared/FuelStore.swift stays Foundation-only.
// (Previously a private extension in the widget target deleted on the move.)
private extension FuelType {
var fuelColor: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
}
/// Plain data (no WidgetKit types) so both the widget and the app preview can
/// construct entries. The widget's `FuelPriceEntry: TimelineEntry` wraps this.
struct FuelPriceWidgetEntryData {
let date: Date
let stations: [FuelStation] // already filtered + sorted per config
let fuel: FuelType
let sort: SortMode // per-widget: cheapest | closest
let isFavourites: Bool // favourites mode: pinned stations, cheapest-first
let location: Coordinate?
let locationSource: String // "live" | "cached" | "none"
let unit: DistanceUnit // user's display unit for distances
}
/// The widget face, parameterised by family so the app can preview it.
/// `isWidget` controls WidgetKit-only chrome (widgetURL / Link rows) in-app
/// previews pass false.
struct FuelPriceWidgetContent: View {
let entry: FuelPriceWidgetEntryData
let family: WidgetFamily
var isWidget = true
var body: some View {
Group {
if entry.stations.isEmpty {
VStack(spacing: 6) {
Image(systemName: entry.isFavourites ? "star" : "fuelpump")
.font(.title2)
.foregroundStyle(.secondary)
Text(entry.isFavourites
? "No favourite \(entry.fuel.displayName) stations"
: "No \(entry.fuel.displayName) stations")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else if family == .systemSmall {
singleStation
} else {
stationList
}
}
}
private var heading: String {
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
return entry.sort == .closest
? "Closest \(entry.fuel.displayName)"
: "Cheapest \(entry.fuel.displayName)"
}
private var singleStation: some View {
let station = entry.stations[0]
return VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(entry.fuel.fuelColor)
Text(heading)
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(station.name)
.font(.headline)
.lineLimit(1)
if let price = station.prices[entry.fuel] {
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
}
if let location = entry.location {
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
Text("Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.modifier(WidgetURLModifier(url: isWidget ? station.widgetDirectionsURL : nil))
}
/// How many station rows each family can fit: medium widgets are short
/// (158pt) so 3 rows is the safe cap; large/extraLarge have ~2× the height
/// and fit 5 compact rows. Small widgets use singleStation instead.
private var maxRows: Int {
switch family {
case .systemMedium: return 3
default: return 5
}
}
private var stationList: some View {
let cheapest = entry.stations.compactMap { $0.prices[entry.fuel] }.min()
return VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(entry.fuel.fuelColor)
Text(heading)
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
Text(entry.isFavourites ? "cheapest first"
: (entry.locationSource == "none" ? "by price" : "near you"))
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(entry.stations.prefix(maxRows)) { station in
if isWidget {
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
row(station, cheapest: cheapest)
}
.buttonStyle(.plain)
} else {
row(station, cheapest: cheapest)
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func row(_ station: FuelStation, cheapest: Double?) -> some View {
HStack(spacing: 8) {
Text(station.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
if let location = entry.location {
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
if let price = station.prices[entry.fuel] {
HStack(spacing: 4) {
Circle()
.fill(ragColor(for: price, cheapest: cheapest))
.frame(width: 6, height: 6)
FuelStore.priceTextAttributed(price, size: 12, weight: .bold)
}
}
}
}
private func ragColor(for price: Double, cheapest: Double?) -> Color {
guard let cheapest else { return .gray }
switch RAGRating.rating(price: price, cheapest: cheapest) {
case .green: return .green
case .amber: return .orange
case .red: return .red
}
}
}
/// Applies widgetURL only when a URL is given (WidgetKit-only modifier).
private struct WidgetURLModifier: ViewModifier {
let url: URL?
func body(content: Content) -> some View {
if let url {
content.widgetURL(url)
} else {
content
}
}
}
+328 -1
View File
@@ -8,7 +8,12 @@
// keychain app-group defaults fallback.
import Foundation
import SwiftUI
import Security
#if canImport(AppIntents)
import AppIntents
#endif
import SwiftUI
// MARK: - Fuel types
@@ -27,6 +32,16 @@ struct FavouriteEntry: Identifiable, Codable, Equatable {
var id: String { "\(fuel.rawValue)|\(station.id)" }
}
/// Persisted baseline for the per-fuel cheapest-favourite price-drop alert.
/// Stored separately from the favourites list so refresh-time comparison can
/// survive app relaunches without immediately alerting on first fetch.
struct FavouriteAlertSnapshot: Codable, Equatable {
let fuel: FuelType
let stationID: String
let stationName: String
let price: Double
}
enum SortMode: String, Codable, CaseIterable, Identifiable {
case cheapest
case closest
@@ -58,6 +73,18 @@ enum RAGRating: Int, Codable {
}
enum FuelType: String, Codable, CaseIterable, Identifiable {
#if canImport(AppIntents)
typealias DisplayRepresentation = AppIntents.DisplayRepresentation
typealias TypeDisplayRepresentation = AppIntents.TypeDisplayRepresentation
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [
.e10: "Unleaded",
.e5: "Premium",
.diesel: "Diesel",
]
#endif
case e10 // Unleaded (E10)
case e5 // Premium (E5)
case diesel // B7 diesel
@@ -73,6 +100,24 @@ enum FuelType: String, Codable, CaseIterable, Identifiable {
}
}
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Lives here in Shared
/// so the app, widget, and Live Activity all tint the pump/fuel glyphs from one
/// definition.
extension FuelType {
var tintColor: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
}
#if canImport(AppIntents)
extension FuelType: AppEnum {}
#endif
/// Display unit for all distances in the app + widget. Internally distances
/// are always stored/computed in km; conversion happens at the display and
/// filter boundary so nothing else needs to know the unit.
@@ -143,6 +188,26 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
}
}
/// Display style for fuel prices. Internally prices are always stored and
/// computed in pence-per-litre (GOV.UK's unit, e.g. 129.9); the style only
/// affects RENDERING: the station-sign convention shows the bare pence
/// figure ("129.9", no £ exactly what a UK forecourt sign shows), pounds &
/// pence shows the converted value in the forecourt's superscript style
/// ("£1.29/L"). Calculations never see this.
enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
case stationSign // 129.9 bare pence figure, like the roadside sign
case poundsPence // £1.29/L small superscript third digit, small /L
var id: String { rawValue }
var displayName: String {
switch self {
case .stationSign: return "Station sign"
case .poundsPence: return "Pounds & pence"
}
}
}
// MARK: - Station model
struct FuelStation: Identifiable, Codable, Equatable {
@@ -192,21 +257,37 @@ struct FuelStation: Identifiable, Codable, Equatable {
var brandImageName: String? {
let raw = brand.uppercased()
let known: [(String, String)] = [
("CIRCLE K", "brand_circle_k"),
("MAXOL", "brand_maxol"),
("SPAR", "brand_spar"),
("SHELL", "brand_shell"),
("SAINSBURY", "brand_sainsburys"),
("MORRISONS", "brand_morrisons"),
("APPLEGREEN", "brand_applegreen"),
("TESCO", "brand_tesco"),
("TEXACO", "brand_texaco"),
("ESSAR", "brand_essar"),
("ESSO", "brand_esso"),
("ASDA", "brand_asda"),
("GULF", "brand_gulf"),
("VALERO", "brand_valero"),
("WELCOME BREAK", "brand_welcome_break"),
("THE CO-OPERATIVE", "brand_the_co_operative"),
("CO-OPERATIVE", "brand_the_co_operative"),
("CO OPERATIVE", "brand_the_co_operative"),
("CO-OP", "brand_the_co_operative"),
("CO OP", "brand_the_co_operative"),
("MURCO", "brand_murco"),
("GLEANER", "brand_gleaner"),
("HIGHLAND FUELS", "brand_highland_fuels"),
("BP", "brand_bp"),
("JET", "brand_jet"),
]
for (needle, asset) in known where raw.contains(needle) {
return asset
}
if raw.contains("EG ON THE MOVE") { return "brand_esso" }
if raw == "GO" || raw.contains("GO FORECOURT") || raw.contains("GO FUEL") { return "brand_gulf" }
return nil
}
}
@@ -278,8 +359,12 @@ struct FuelStore {
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool alerts mirror the Stations-tab distance
static let favouriteDropAlertsEnabledKey = "fuelboard.favouriteDropAlertsEnabled" // Bool
static let favouriteDropAlertsFuelKey = "fuelboard.favouriteDropAlertsFuel" // FuelType raw value
static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON
static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool Live Activity mirrors the Stations-tab distance
static let debugModeKey = "fuelboard.debugMode" // Bool hidden dev flag
static let relayFallbackKey = "fuelboard.relayFallback" // Bool dev-only relay fallback
static let liveActivityKey = "fuelboard.liveActivity" // Bool Live Activity toggle
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
@@ -435,6 +520,86 @@ struct FuelStore {
saveString(unit.rawValue, service: distanceUnitKey)
}
// MARK: Price display station sign (129.9) vs pounds & pence (£1.29/L).
// Prices are always stored/computed in pence-per-litre; this style only
// changes how they are RENDERED, so it can never affect calculations.
// Default station sign = the forecourt convention.
static let priceDisplayStyleKey = "fuelboard.priceDisplayStyle"
static func loadPriceDisplayStyle() -> PriceDisplayStyle {
if let raw = loadString(service: priceDisplayStyleKey), let style = PriceDisplayStyle(rawValue: raw) {
return style
}
return .stationSign
}
static func savePriceDisplayStyle(_ style: PriceDisplayStyle) {
saveString(style.rawValue, service: priceDisplayStyleKey)
}
/// Superscript digit glyphs for the pounds & pence format's small raised
/// third digit (the forecourt style: "£1.29").
private static let superscriptDigits: [Character] = ["", "¹", "²", "³", "", "", "", "", "", ""]
/// Render a pence-per-litre price per the saved style:
/// stationSign -> "129.9" (bare pence figure, no £ the roadside sign),
/// poundsPence -> "£1.29/L" (pounds with a superscript third digit and a
/// small /L unit, matching UK garage displays).
static func priceText(_ pence: Double, style: PriceDisplayStyle? = nil) -> String {
switch style ?? loadPriceDisplayStyle() {
case .stationSign:
return String(format: "%.1f", pence)
case .poundsPence:
let tenths = Int((pence * 10).rounded())
let whole = tenths / 1000
let major = (tenths % 1000) / 10
let minor = tenths % 10
return String(format: "£%d.%02d", whole, major)
+ String(superscriptDigits[minor])
+ "/L"
}
}
/// Rich, forecourt-styled price text for SwiftUI surfaces. Same formats as
/// `priceText`, but the pounds & pence mode renders the third digit small
/// and superscripted and /L small and muted proper text styling instead
/// of Unicode glyphs. Pass the surface's size/weight/color; the returned
/// Text carries its own fonts, so do NOT apply `.font(...)` on top.
static func priceTextAttributed(_ pence: Double, style: PriceDisplayStyle? = nil,
size: CGFloat = 17, weight: Font.Weight = .semibold,
color: Color = .primary) -> Text {
let s = style ?? loadPriceDisplayStyle()
let base = Font.system(size: size, weight: weight).monospaced()
switch s {
case .stationSign:
return Text(priceText(pence, style: s)).font(base).foregroundColor(color)
case .poundsPence:
let tenths = Int((pence * 10).rounded())
let whole = tenths / 1000
let major = (tenths % 1000) / 10
let minor = tenths % 10
let amount = Text(String(format: "£%d.%02d", whole, major))
.font(base)
.foregroundColor(color)
let sup = Text(String(superscriptDigits[minor]))
.font(.system(size: size * 0.6, weight: weight).monospaced())
.baselineOffset(size * 0.35)
.foregroundColor(color)
let perL = Text("/L")
.font(.system(size: size * 0.5, weight: .regular).monospaced())
.foregroundColor(color.opacity(0.55))
return Text("\(amount)\(sup)\(perL)")
}
}
/// Speech-safe pounds form for Siri dialogs Siri would read "£129.9"
/// aloud as "one hundred and twenty-nine pounds", so the SPOKEN answer
/// always uses pounds regardless of the display style.
static func priceTextSpoken(_ pence: Double) -> String {
String(format: "£%.3f", pence / 100)
}
// MARK: Debug mode
/// Hidden developer flag. NOT exposed in the UI: toggled by tapping the
@@ -451,6 +616,22 @@ struct FuelStore {
saveString(enabled ? "1" : "0", service: debugModeKey)
}
// MARK: Relay fallback (dev-only)
/// Dev-only switch: re-inserts the LAN relay into the live chain as a
/// fallback for home testing. OFF by default consumers must never make
/// a local-network attempt (the relay is unreachable off the developer's
/// LAN, and the attempt itself would fire the iOS Local Network prompt).
/// Toggled from Settings Debug; keychain-first so it survives the
/// delete reinstall test loop like every other small setting.
static func loadRelayFallbackEnabled() -> Bool {
loadString(service: relayFallbackKey) == "1"
}
static func saveRelayFallbackEnabled(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: relayFallbackKey)
}
// MARK: Favourites
/// A favourite pins a station FOR ONE fuel type. Starring a row while
@@ -490,6 +671,24 @@ struct FuelStore {
}
}
/// The cheapest favourite for one fuel, preserving the user's stored order
/// on price ties. Used by the favourite price-drop alert so a tie does not
/// flap between equally-priced favourites.
static func cheapestFavourite(in favourites: [FavouriteEntry], fuel: FuelType) -> FavouriteEntry? {
var best: FavouriteEntry?
for entry in favourites where entry.fuel == fuel {
guard let price = entry.station.prices[fuel] else { continue }
guard let current = best, let currentPrice = current.station.prices[fuel] else {
best = entry
continue
}
if price < currentPrice {
best = entry
}
}
return best
}
/// Reorders ONE fuel's favourites within the global array (drag-and-drop in
/// the Favourites tab). The moved fuel's block stays at its original
/// position in the array; other fuels keep their relative order. The array
@@ -573,6 +772,48 @@ struct FuelStore {
saveString(enabled ? "1" : "0", service: alertsFollowSearchKey)
}
// MARK: Favourite price-drop alerts
static func loadFavouriteDropAlertsEnabled() -> Bool {
loadString(service: favouriteDropAlertsEnabledKey) == "1"
}
static func saveFavouriteDropAlertsEnabled(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: favouriteDropAlertsEnabledKey)
}
static func loadFavouriteDropAlertsFuel() -> FuelType {
if let raw = loadString(service: favouriteDropAlertsFuelKey), let fuel = FuelType(rawValue: raw) {
return fuel
}
return .e10
}
static func saveFavouriteDropAlertsFuel(_ fuel: FuelType) {
saveString(fuel.rawValue, service: favouriteDropAlertsFuelKey)
}
static func loadFavouriteAlertSnapshots() -> [FuelType: FavouriteAlertSnapshot] {
if let data = keychainData(service: favouriteAlertSnapshotsKey),
let stored = try? JSONDecoder().decode([String: FavouriteAlertSnapshot].self, from: data) {
var result: [FuelType: FavouriteAlertSnapshot] = [:]
for (key, value) in stored {
if let fuel = FuelType(rawValue: key) {
result[fuel] = value
}
}
return result
}
return [:]
}
static func saveFavouriteAlertSnapshots(_ snapshots: [FuelType: FavouriteAlertSnapshot]) {
let keyed = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.key.rawValue, $0.value) })
if let data = try? JSONEncoder().encode(keyed) {
writeKeychain(data: data, service: favouriteAlertSnapshotsKey)
}
}
// MARK: Live Activity
static func loadLiveActivityEnabled() -> Bool {
@@ -664,6 +905,27 @@ struct FuelStore {
loadString(service: dataUpdatedKey)
}
/// A short label for the offline-data banner: "15 Aug" from a GOV.UK
/// `data_updated` ISO 8601 stamp (with or without fractional seconds).
/// Nil when the stamp is missing or unparseable callers then hide the
/// banner rather than label data with a wrong date.
static func offlineDataLabel(from stamp: String?) -> String? {
guard let stamp, !stamp.isEmpty else { return nil }
let withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = withFraction.date(from: stamp)
if date == nil {
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
date = plain.date(from: stamp)
}
guard let date else { return nil }
let formatter = DateFormatter()
formatter.dateFormat = "d MMM"
formatter.locale = Locale(identifier: "en_GB")
return formatter.string(from: date)
}
/// True when the cached data is fresh enough that a scheduled auto-refresh
/// should be skipped (twice-a-day policy).
static var isCacheFresh: Bool {
@@ -683,10 +945,66 @@ struct FuelStore {
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
}
// MARK: Road distances (Apple-Maps-matched, computed by the app)
/// Cached road/routed distances (metres) keyed by station ID, computed by
/// the app via MapKit `MKDirections`. Stored in KEYCHAIN (survives on free
/// SideStore accounts where the app-group container isn't provisioned) so
/// the widget extension can read it too. Widget + Live Activity prefer
/// these over straight-line haversine for the displayed distance.
static let roadDistancesKey = "fuelboard.roadDistances"
/// How far (metres) the cache's source location may be from the current
/// user position before a cached road distance is treated as stale.
static let roadDistanceOriginToleranceMeters: Double = 600
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: Double]) {
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
updatedAt: Date().timeIntervalSince1970, entries: entries)
if let data = try? JSONEncoder().encode(cache) {
saveString(data.base64EncodedString(), service: roadDistancesKey)
}
}
static func loadRoadDistances() -> RoadDistanceCache? {
guard let raw = loadString(service: roadDistancesKey),
let data = Data(base64Encoded: raw),
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
else { return nil }
return cache
}
/// Cached road distance (metres) to a station from the user's location, or
/// nil when not cached / the cache was built too far from where the user
/// is now.
static func roadDistanceMeters(for stationID: String, userLat: Double, userLng: Double) -> Double? {
guard let cache = loadRoadDistances(),
let meters = cache.entries[stationID] else { return nil }
// The cache is only valid near the location it was built from.
let dLat = (userLat - cache.sourceLat) * .pi / 180
let dLng = (userLng - cache.sourceLng) * .pi / 180
let r = 6371000.0
let a = sin(dLat / 2) * sin(dLat / 2) +
cos(cache.sourceLat * .pi / 180) * cos(userLat * .pi / 180) *
sin(dLng / 2) * sin(dLng / 2)
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
return meters
}
/// Distance (km) to display for a station: cached ROAD distance when
/// available (matches Apple Maps), else straight-line haversine.
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
if let meters = roadDistanceMeters(for: station.id, userLat: userLat, userLng: userLng) {
return meters / 1000.0
}
return station.distanceKM(to: userLat, lng2: userLng)
}
// MARK: Low-level keychain helpers
private static func keychainData(service: String) -> Data? {
var query: [String: Any] = [
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnData as String: true,
@@ -742,3 +1060,12 @@ struct FuelStore {
loadString(service: "widget.diag.\(intentType)")
}
}
/// Cached Apple-Maps road distances for nearby stations (see
/// `FuelStore.roadDistancesKey`). `entries` maps stationID road metres.
struct RoadDistanceCache: Codable {
let sourceLat: Double
let sourceLng: Double
let updatedAt: TimeInterval
let entries: [String: Double]
}
+196
View File
@@ -0,0 +1,196 @@
// MirrorFuelProvider.swift GitHub price-mirror provider (P0 live chain).
//
// The mirror (aptonline/fuelboard-data, pushed daily by the LAN relay) is the
// PRODUCTION data source: its day files are the raw relay /api/v1/stations
// envelope, so the shared decode works verbatim. Fetching from
// raw.githubusercontent.com makes live prices work fully off-LAN App
// Review cannot reach a LAN relay, and consumers never have one to reach
// and the bundled dump (app target) is the no-network last resort.
//
// Chain (LiveChainProvider) GitHub first, EVERYWHERE:
// GitHub mirror [dev-only LAN relay] (app target: bundled dump in the
// ContentView catch, widget: cache placeholder)
// The relay is NOT part of the consumer chain at all: it is unreachable from
// any phone outside the developer's LAN, and even attempting it from a
// consumer phone would fire the iOS Local Network permission prompt for an
// address that can never be reached. It remains reachable only behind the
// hidden dev flag `fuelboard.relayFallback` (Settings Debug) for home
// testing. The relay keeps its server-side job (GOV.UK ingest + daily mirror
// push) unchanged.
//
// Telemetry (FuelBeacon): raw GitHub fetches are invisible to repo analytics
// (page views/clones only), so every chain fetch opportunistically pings the
// relay's existing widget-diag route (zero relay changes) but ONLY when the
// same dev flag is on, so consumers never make a local-network attempt.
// /stats app-hit spikes (dev flag on) = GitHub path failing fallback active.
import Foundation
// MARK: - Mirror provider (GitHub raw)
/// Fetches CURRENT prices from the GitHub mirror. Same envelope as the
/// relay, so `FuelPriceProvider.decodeStations` applies unchanged.
struct MirrorFuelProvider: FuelPriceProviding {
var baseURL = FuelHistoryStore.mirrorBase
/// Envelope metadata from the most recent successful mirror fetch
/// surfaced via `LiveChainProvider.latestMeta` in Settings About.
static var latestMeta: RelayMeta?
/// App-group cache of the last fetched FULL dump, keyed by snapshot day.
/// The mirror pushes once/day, so between pushes the app reuses the
/// cached dump instead of re-downloading ~2.8 MB at every 12 h gate.
static let liveDumpCacheKey = "fuelboard.liveDumpCache"
struct DumpCache: Codable {
let day: String
let data: Data
}
static func loadDumpCache() -> DumpCache? {
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
let raw = defaults.data(forKey: liveDumpCacheKey),
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else {
return nil
}
return cache
}
static func saveDumpCache(day: String, data: Data) {
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return }
defaults.set(raw, forKey: liveDumpCacheKey)
}
/// True when the cached dump is already the freshest the mirror has
/// avoids the ~2.8 MB re-download when the mirror hasn't pushed a new day.
static func canReuseCache(cachedDay: String?, latestDay: String?) -> Bool {
guard let cachedDay, let latestDay else { return false }
return cachedDay == latestDay
}
/// Focused-path projection, replacing what the relay's radius endpoint
/// used to do server-side: stations WITH the fuel, within radius of the
/// point, nearest first, bounded. The alert path re-checks radius and
/// fuel itself afterwards, so this only narrows the payload.
static func focused(_ stations: [FuelStation], near lat: Double?, lng: Double?,
fuel: FuelType, radiusKM: Double?, limit: Int = 500) -> [FuelStation] {
guard let lat, let lng, let radiusKM else { return stations }
return Array(
stations
.filter { $0.prices[fuel] != nil && $0.distanceKM(to: lat, lng2: lng) <= radiusKM }
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
.prefix(limit)
)
}
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
// 1. Tiny pointer fetch the freshest snapshot day.
guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL),
let day = latest.availableTo ?? latest.date else {
throw FuelProviderError.mirrorUnavailable
}
// 2. Reuse the cached full dump when the mirror hasn't pushed a new day.
let cache = Self.loadDumpCache()
let data: Data
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
data = cached
} else {
// Bypass the HTTP cache + short timeout: an offline refresh must
// FAIL ( offline banner), never re-serve a stale cached dump as
// a "successful" live fetch.
var req = URLRequest(url: FuelHistoryStore.historyFileURL(day: day, base: baseURL))
req.cachePolicy = .reloadIgnoringLocalCacheData
req.timeoutInterval = 10
let (fetched, response) = try await URLSession.shared.data(for: req)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw FuelProviderError.mirrorUnavailable
}
Self.saveDumpCache(day: day, data: fetched)
data = fetched
}
let stations = try FuelPriceProvider.decodeStations(from: data)
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
}
}
// MARK: - Live chain provider
/// The live price chain GitHub first, everywhere. The LAN relay is NOT part
/// of the consumer chain: consumers can never reach it (and must never even
/// attempt it that would prompt for Local Network access on a stranger's
/// phone). It only joins behind the hidden dev flag `fuelboard.relayFallback`
/// (Settings Debug) for the developer's own home testing. Whichever leg
/// serves is recorded for the About section + telemetry.
struct LiveChainProvider: FuelPriceProviding {
var mirror: FuelPriceProviding
var relay: FuelPriceProviding
/// Which leg served the last fetch Settings About metadata + beacon.
static var latestMeta: RelayMeta?
static var lastSource: String?
init(mirror: FuelPriceProviding = MirrorFuelProvider(),
relay: FuelPriceProviding = RelayFuelProvider()) {
self.mirror = mirror
self.relay = relay
}
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
do {
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
Self.latestMeta = MirrorFuelProvider.latestMeta
Self.lastSource = "github"
FuelBeacon.fire(source: "github", n: stations.count)
return stations
} catch {
// Dev-only fallback: consumers never reach this branch the flag
// is off by default and the flag itself lives in the Debug section.
guard FuelStore.loadRelayFallbackEnabled() else { throw error }
let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
Self.latestMeta = RelayFuelProvider.latestMeta
Self.lastSource = "relay"
FuelBeacon.fire(source: "relay", n: stations.count)
return stations
}
}
}
// MARK: - Telemetry beacon
/// Fire-and-forget attribution for the LIVE chain (see header note). Never
/// awaited, never user-visible; a failed ping is silent. Fires ONLY when the
/// dev relay flag is on consumers must never make a local-network attempt,
/// and an off-LAN ping failing silently is itself the reachability datum.
/// Disabled in unit tests via `isEnabled`.
enum FuelBeacon {
static var isEnabled = true
static var shouldFire: Bool {
isEnabled && FuelStore.loadRelayFallbackEnabled()
}
static func fire(source: String, n: Int, timeout: TimeInterval = 2) {
guard shouldFire,
let url = beaconURL(source: source, n: n) else { return }
var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout)
request.cachePolicy = .reloadIgnoringLocalCacheData
Task { _ = try? await URLSession.shared.data(for: request) }
}
/// Testable URL construction hits the relay's existing widget-diag
/// beacon route with app attribution (zero relay changes).
static func beaconURL(source: String, n: Int, base: URL = RelayFuelProvider().baseURL) -> URL? {
guard var components = URLComponents(
url: base.appendingPathComponent("api/v1/widget-diag"),
resolvingAgainstBaseURL: false
) else { return nil }
components.queryItems = [
URLQueryItem(name: "intent", value: "app-live"),
URLQueryItem(name: "source", value: source),
URLQueryItem(name: "n", value: String(n)),
]
return components.url
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>logFormatVersion</key>
<integer>12</integer>
<key>logs</key>
<dict/>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>logFormatVersion</key>
<integer>12</integer>
<key>logs</key>
<dict/>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>logFormatVersion</key>
<integer>12</integer>
<key>logs</key>
<dict/>
</dict>
</plist>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>logFormatVersion</key>
<integer>12</integer>
<key>logs</key>
<dict/>
</dict>
</plist>

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