Author SHA1 Message Date
FuelBoard Contributor c9102217b9 Fix iOS deprecation warnings 2026-08-30 12:58:32 +01:00
FuelBoard Contributor 457816ec61 Add Smart data checking mode 2026-08-29 13:48:08 +01:00
FuelBoard Contributor 50036c8a67 Signing: set FuelBoard team for app and widget 2026-08-28 19:00:43 +01:00
FuelBoard Contributor a71b7ae20e Siri: remove Apple from App Intent descriptions 2026-08-28 17:48:51 +01:00
FuelBoard Contributor 98cf3a5736 Git: ignore build-watch output 2026-08-28 16:35:32 +01:00
FuelBoard Contributor ed41df46de siri: drop generic shortcut and broaden fuel-word phrases
Siri was hit-and-miss on 'unleaded near me' (\u2018can't do that, searching
in app\u2019 fallback). The generic parameterized AppShortcut ('cheapest
${fuel}') competed with the fixed-fuel literal-word shortcuts for the same
intent, degrading NLU ranking; it was already documented as flaky. Remove it
(now 9 shortcuts, under the 10 cap) and add natural phrase variants for
unleaded/petrol ('What's the cheapest unleaded near me', 'Cheapest unleaded
near me', 'Where's the cheapest petrol'). All phrases keep .applicationName
per the iOS 26 metadata requirement. Build-verified in extract.actionsdata:
generic gone, new phrases registered.
2026-08-20 19:27:34 +01:00
FuelBoard Contributor 9342ccc7c9 trends: redraw lines on 7/30/90 selection
Callers previously morphed the existing lines on a reload, so picking a new
range only glided in place. Now every range/fuel selection (the only reloads;
the sheet has no periodic refetch) replays the staggered per-series draw-in
via revealSeries(), so the lines visibly redraw left->right when the user
switches 7/30/90. Price<->vs-cheapest still morphs (pure view toggle, no
reload). Reduce Motion jump-cuts.
2026-08-20 18:49:54 +01:00
FuelBoard Contributor 508ccc3802 trends: staggered per-series line draw-in
The single-waterline trace-in was invisible with sparse data (2 points draw
in one step). Replace it with a per-series waterline: each station's line now
traces left->right on first appearance, series revealed 0.18s apart in a
cascade so the draw is clearly visible. Newly-added favourites get a full
reveal immediately on reload; Reduce Motion still jump-cuts. Range/mode
morphs and numeric headline transition unchanged.
2026-08-20 18:40:09 +01:00
FuelBoard Contributor 69c3c17606 trends: animate the price-history lines (subtle, Reduce-Motion aware)
- Reloads (range/fuel switch, Price<->vs-cheapest) now glide the existing
  lines + y-axis to the new data via withAnimation/.animation(value: mode)
  instead of snapping.
- One-time left->right trace-in on first data appearance by revealing an
  increasing prefix of each series' points (area + line stay in sync); later
  switches morph rather than re-trace.
- Headline average rolls its digits with .contentTransition(.numericText).
- All animation honours .accessibilityReduceMotion (jump-cut to final state).
Kept two-pass area/line ordering, explicit series:, bounded fill bands, and
tight chartYScale unchanged.
2026-08-20 17:54:55 +01:00
FuelBoard Contributor 0b27d3d4f6 road distance: pin-fingerprint guard so embedded/live/cached never cross-pollute
The road-distance cache was keyed purely by station ID with no coordinate, so
a value routed to one pin could be served for a same-ID station whose stored
coordinate came from a different source (live fetch vs bundled offline dump
vs a corrected pin), reproducing Maps mismatches.
- RoadDistanceCache now stores each routed pin (CachedRoadDistance{meters,
  lat, lng}) and roadDistanceMeters() only serves a value when the displayed
  station's coordinate matches the pinned one (within ~11 m).
- Kept the existing origin-distance staleness guard.
- Verified embedded sample data (ids y1/se1/...) never collides with real
  relay IDs, and the widget STRICT radius filter drops far-offline samples, so
  no actual leak existed in practice — this closes the theoretical stale-pin
  channel and future-proofs against coordinate fixes.
2026-08-20 17:26:33 +01:00
FuelBoard Contributor 01517ba687 onboarding: replay on a fresh/reinstalled install, not every launch
The keychain-first completed flag persists across reinstalls (same bundle id),
so a new install was treated as already-completed and onboarding silently
skipped while permissions still prompted. Add install identity:
- Store an install id in BOTH the app-own container defaults (wiped on
  reinstall, persists across launches) and keychain (survives reinstall).
- isFreshInstall() is true on first-ever launch or when the local id is
  missing/different from keychain (i.e. reinstalled); it seeds a fresh common
  id so the next launch of the same install is not 'fresh' again.
- shouldShowOnboarding() = not completed || fresh install -> onboarding now
  replays once per fresh/reinstall, never on ordinary launches.
Works on free SideStore (no app-group), using the app's own defaults domain
as the same-install signal.
2026-08-20 13:12:53 +01:00
FuelBoard Contributor d73a64023f road distance: trigger at launch + foreground, fall back to saved location
Road-distance routing previously only fired once a fresh GPS fix arrived via
the location hook/onChange. A returning user on a cold launch waited for that
fix, and if onboarding/flag issues kept tracking from starting, distances
stayed straight-line. Now:
- refreshRoadDistancesIfNeeded() uses the last saved location when the live
  fix isn't set yet, so it can run immediately.
- It's called explicitly on launch (already-onboarded branch), on foreground
  activation, and on the existing location paths.
2026-08-20 12:56:16 +01:00
FuelBoard Contributor dc6d1fd724 Merge branch 'fix/onboarding-persistence' into test/road-onboarding 2026-08-20 12:50:38 +01:00
FuelBoard Contributor 10e3b54d0c onboarding: persist completed flag keychain-first so it doesn't re-show
The completed flag lived only in app-group UserDefaults
(UserDefaults(suiteName: appGroupSuite)). On free SideStore accounts the
app-group container isn't provisioned, so the suite read as nil: save was a
silent no-op and load always returned false -> onboarding re-appeared on
every launch even after finishing and granting all permissions.

Switch to saveString/loadString (keychain-first, app-group mirror) under
onboardingCompletedKey, matching favourites/distance-unit persistence which
survives reinstall and works without a provisioning group. loadString falls
back to the app-group mirror, so anyone who previously completed on a
provisioned group keeps their flag; users on unprovisioned groups complete
once more and then it sticks.
2026-08-20 12:27:09 +01:00
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
67 changed files with 2503 additions and 306 deletions
+1
View File
@@ -1,6 +1,7 @@
build/ build/
build-release/ build-release/
build-sim/ build-sim/
build-watch/
.DS_Store .DS_Store
*.xcuserstate *.xcuserstate
xcuserdata/ xcuserdata/
+82 -15
View File
@@ -13,20 +13,30 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
## P1 — Soon ## P1 — Soon
- [ ] **Move relay + mirror off the Mac mini (plan for 2026-08-16)** — dedicated - [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` Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh`
(CT ~110, hostname `fuelboard-relay`, 1 core/1 GB/8 GB, static IP (CT **111**, hostname **`fuelboard-relay`**, 1 core/512 MB/2 GB, static IP
~192.168.1.150, plain LXC — NO Docker layer). Scope: relay AND mirror both **192.168.1.113**, plain LXC — NO Docker layer). Scope: relay AND mirror
move (full Mac independence; relay binds loopback only, GitHub is the only both moved (full Mac independence; relays bind **loopback only**, GitHub is
public surface). Steps: user creates CT on pve → install Python (trixie = the only public surface). What landed: Python 3.13.5 (trixie) boots both
3.13 vs Mac 3.14 — verify relay boots) + relay source + `.env` (GOV.UK relays (relay 8788 demo/CSV + relay-api 8789 `source: api`, 8,024 stations,
token) → copy deploy key `~/.ssh/fuelboard_deploy` + `github-fuelboard-data` ~2 min first sync, 0 failures); source at `/root/workspace/fuelboard-{relay,relay-api}`
ssh config → systemd `fuelboard-relay.service` (Restart=always, (venvs + pip, `.env` chmod 600); deploy key `fuelboard_deploy` +
127.0.0.1:8789/8788) + `fuelboard-mirror.timer` (10:00 + 16:00) + service → `github-fuelboard-data` ssh config + `fuelboard-data` clone at
relocate `mirror_push.py` (RELAY line → localhost on CT) + fuelboard-data `/root/workspace/fuelboard-data` (origin = ssh alias, green + synced);
clone → verify one live push → remove Mac launchd `mirror_push.py` → `/root/scripts/mirror_push.py` (RELAY line already
`com.apt.fuelboard-{relay,mirror}`. Open: confirm IP; logs move to `127.0.0.1:8789`); systemd `fuelboard-relay.service` +
journalctl. `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` - [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
(App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first (App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first
@@ -96,6 +106,26 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
scene (CPListTemplate) for navigation deep-links. scene (CPListTemplate) for navigation deep-links.
- [ ] **Alert region tuning** — make alert trigger radius + dedup window - [ ] **Alert region tuning** — make alert trigger radius + dedup window
user-configurable (currently fixed 300 m trigger / 1 per station per hour). 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).
- [ ] **Data checking mode: Standard vs Smart** — keep the current cache-first
/ 12 h policy as `Standard`, and add an optional `Smart` mode in Settings.
`Smart` should try lightweight background checks of the mirror pointer
(`latest.json` / `data_updated`) more often, and only download the full price
dataset when a newer snapshot is available. Design goal: better freshness
without pretending to do real-time refreshes or re-downloading the full dump
unnecessarily. User-facing copy must be explicit and honest:
- `Standard` — Saves battery. Refreshes price data up to twice daily.
- `Smart` — Checks for newer data more often in the background and refreshes
full prices only when an update is available.
Add a footnote/subcopy that background checks are best-effort and happen only
when iOS allows, so timing is not exact. Prefer this 2-mode setting over a
freeform frequency slider.
- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state - [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
(currently `refreshable` fires but no visible progress in the row list). (currently `refreshable` fires but no visible progress in the row list).
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`, - [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
@@ -121,13 +151,50 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
separate phase. Backlog ancestry: this was the original "relay could 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 rolling archive" idea — the archive is now P0 and the Trends graph (P1) is its
visible half. 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 - [ ] **Widget sparkline** — medium-widget mini sparkline of a favourite's 30-day
trend, after the Trends UI lands (one-kind rule preserved). trend, after the Trends UI lands (one-kind rule preserved).
- [ ] **Price alerts for a specific station** — notify when a starred station's - [ ] **Price alerts for a specific station** — notify when a starred station's
price drops below a user threshold. 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. - [ ] **Share sheet** — share "Cheapest Unleaded near me" as text/image card.
- [ ] **Watch app** — glanceable cheapest-price complication (WatchKit + shared - [ ] **Watch app v1 — favourites + Check now** — small Apple Watch companion,
app-group store; watch target would need to be added to the project). 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. - [ ] **App Clip** — 10 MB budget for "find cheapest fuel nearby" without install.
- [ ] **Widget picker: dual-unit distance labels** — the Edit-Widget Distance row - [ ] **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 label is cached by the system at sheet-render time and only re-resolves on
+5
View File
@@ -41,8 +41,13 @@
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string> <string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.apt.fuelboard.smart-refresh</string>
</array>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string>
<string>location</string> <string>location</string>
</array> </array>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
+4
View File
@@ -292,6 +292,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/App-Info.plist; INFOPLIST_FILE = Config/App-Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -314,6 +315,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/App-Info.plist; INFOPLIST_FILE = Config/App-Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -336,6 +338,7 @@
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/Widget-Info.plist; INFOPLIST_FILE = Config/Widget-Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -360,6 +363,7 @@
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/Widget-Info.plist; INFOPLIST_FILE = Config/Widget-Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
+21 -3
View File
@@ -12,6 +12,8 @@ struct AlertsView: View {
@Binding var enabled: Bool @Binding var enabled: Bool
@Binding var radius: Double // stored in km (monitor + storage) @Binding var radius: Double // stored in km (monitor + storage)
@Binding var fuel: FuelType // the fuel alerts monitor for @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). /// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
@Binding var followsSearch: Bool @Binding var followsSearch: Bool
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen /// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
@@ -30,6 +32,7 @@ struct AlertsView: View {
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
let monitoredCount: Int let monitoredCount: Int
let lastAlert: String? let lastAlert: String?
let onCheckFavouriteDropNow: () -> Void
/// The Stations-tab search distance shown in the user's unit, e.g. /// The Stations-tab search distance shown in the user's unit, e.g.
/// "5 miles" or "8 km" — for the "Follow search" rows. /// "5 miles" or "8 km" — for the "Follow search" rows.
@@ -131,7 +134,22 @@ struct AlertsView: View {
NavigationStack { NavigationStack {
List { List {
Section { 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) { Picker("Fuel", selection: $fuel) {
ForEach(FuelType.allCases) { fuel in ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel) Text(fuel.displayName).tag(fuel)
@@ -152,7 +170,7 @@ struct AlertsView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} header: { } header: {
Text("Cheapest-station alerts") Text("Proximity alerts")
} footer: { } 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. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.") 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.")
} }
@@ -165,7 +183,7 @@ struct AlertsView: View {
} }
Section { Section {
Toggle("Live Activity", isOn: $liveActivityEnabled) Toggle("Cheapest station", isOn: $liveActivityEnabled)
Picker("Fuel", selection: $liveActivityFuel) { Picker("Fuel", selection: $liveActivityFuel) {
ForEach(FuelType.allCases) { fuel in ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel) Text(fuel.displayName).tag(fuel)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 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.0 MiB

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 208 KiB

File diff suppressed because one or more lines are too long
@@ -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

+10
View File
@@ -15,4 +15,14 @@ enum BundledDumpProvider {
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil } guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
return try? FuelPriceProvider.decodeStations(from: asset.data) 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
}
} }
+441 -60
View File
@@ -2,11 +2,21 @@ import SwiftUI
import CoreLocation import CoreLocation
import WidgetKit 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 { struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var stations: [FuelStation] = FuelStore.loadStations() @State private var stations: [FuelStation] = FuelStore.loadStations()
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel() @State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
@State private var sortMode: SortMode = FuelStore.loadSortMode() @State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit() @State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() @State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@@ -15,6 +25,8 @@ struct ContentView: View {
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled() @State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel() @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 alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch()
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled() @State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel() @State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
@@ -30,13 +42,75 @@ struct ContentView: View {
private var effectiveAlertsRadiusKM: Double { private var effectiveAlertsRadiusKM: Double {
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius) 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. Falls back to the last saved
/// location so it can run before the first fresh GPS fix arrives.
private func refreshRoadDistancesIfNeeded() {
let origin = location ?? FuelStore.loadLocation()
guard let origin else { return }
Task {
await RoadDistanceService.refreshIfNeeded(
stations: stations,
lat: origin.lat,
lng: origin.lng
)
}
}
private func ensureBackgroundMotionTrackingIfNeeded() {
if alertsEnabled || liveActivityEnabled {
locationManager.startBackgroundTracking()
}
}
@State private var isLoading = false @State private var isLoading = false
@State private var statusMessage = "" @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 showOnboarding = false
@State private var showWidgetMock = false @State private var showWidgetMock = false
@State private var selectedTab = 0 @State private var selectedTab = 0
@State private var locationManager = LocationManager() @State private var locationManager = LocationManager()
@StateObject private var monitor = ProximityMonitor() @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 /// 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 /// bounds it ("best price within X miles"); in Closest mode the radius is
@@ -139,35 +213,16 @@ struct ContentView: View {
} }
var body: some View { var body: some View {
TabView(selection: $selectedTab) { configuredContent
stationsTab
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
.tag(0)
favouritesTab
.tabItem { Label("Favourites", systemImage: "star.fill") }
.tag(1)
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") }
.tag(2)
settingsTab
.tag(3)
} }
private var configuredContent: AnyView {
interactionObservedContent
}
private var presentedContent: AnyView {
AnyView(
mainContent
.fullScreenCover(isPresented: $showOnboarding) { .fullScreenCover(isPresented: $showOnboarding) {
OnboardingView { OnboardingView {
showOnboarding = false showOnboarding = false
@@ -179,21 +234,48 @@ struct ContentView: View {
.sheet(item: $monitor.pendingStationMap) { request in .sheet(item: $monitor.pendingStationMap) { request in
StationMapView(request: request) StationMapView(request: request)
} }
)
}
private var lifecycleObservedContent: AnyView {
AnyView(
presentedContent
.onAppear { .onAppear {
// Launch-arg hooks for the screenshot/UI-test harness (same // Launch-arg hooks for the screenshot/UI-test harness (same
// pattern as StationsView's `-showKeySheet`): `-tab <name>` // pattern as StationsView's `-showKeySheet`): `-tab <name>`
// opens the given tab; `-skipOnboarding` skips onboarding // opens the given tab; `-skipOnboarding` skips onboarding
// without touching the stored flag. // without touching the stored flag.
let args = ProcessInfo.processInfo.arguments let args = ProcessInfo.processInfo.arguments
#if DEBUG
showWidgetMock = args.contains("-widgets") showWidgetMock = args.contains("-widgets")
// `-seedFavourite <substring>` pins the first matching station // `-seedFavourite <substring>` pins the first matching station
// for Unleaded via the normal save path (keychain + app group) // for Unleaded via the normal save path (keychain + app group)
// so screenshot captures can show a populated Favourites tab. // so screenshot captures can show a populated Favourites tab.
if let i = args.firstIndex(of: "-seedFavourite"), i + 1 < args.count { // Repeatable: each occurrence adds another favourite (QA temp).
let query = args[i + 1] // Optional ":fuel" suffix (e10|e5|diesel) picks the
if let station = FuelStore.loadStations() // fuel, so the fuel-aware Trends open can be verified (QA temp).
.first(where: { $0.name.localizedCaseInsensitiveContains(query) }) { let seedQueries = args.enumerated()
FuelStore.saveFavourites([FavouriteEntry(station: station, fuel: .e10)]) .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)
} }
} }
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count { if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
@@ -204,6 +286,30 @@ struct ContentView: View {
default: selectedTab = 0 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 // Onboarding runs first on a fresh install — it owns the initial
// permission prompts (location, notifications, and the data/local // permission prompts (location, notifications, and the data/local
// network probe on the Data page). Location tracking and the first // network probe on the Data page). Location tracking and the first
@@ -211,29 +317,40 @@ struct ContentView: View {
// Launch-arg hook (UI-testing/screenshot harness, same pattern as // Launch-arg hook (UI-testing/screenshot harness, same pattern as
// StationsView's `-showKeySheet`): skip onboarding without // StationsView's `-showKeySheet`): skip onboarding without
// touching the stored flag. // touching the stored flag.
if FuelStore.loadHasCompletedOnboarding() #if DEBUG
|| args.contains("-skipOnboarding") { let shouldSkipOnboarding = args.contains("-skipOnboarding")
#else
let shouldSkipOnboarding = false
#endif
if !FuelStore.shouldShowOnboarding()
|| shouldSkipOnboarding {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
// Geofences must follow the user even in the background: // Geofences and the Live Activity must follow the user even in
// wire the delegate hook (fires on every fix incl. background // the background: wire the delegate hook (fires on every fix
// significant-change wake-ups) to re-register the region // incl. background significant-change wake-ups) so SwiftUI's
// window around the new position. SwiftUI onChange alone // foreground-only onChange isn't the only update path.
// never runs while suspended, so alerts would otherwise stay installLocationUpdateHook()
// frozen around the last foreground fix. ensureBackgroundMotionTrackingIfNeeded()
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()
}
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
monitor.setEnabled(alertsEnabled) monitor.setEnabled(alertsEnabled)
updateLiveActivity() updateLiveActivity()
// Compute road distances early (throttled; falls back to the
// last saved location) so distance surfaces are road-matched
// as soon as stations are available.
refreshRoadDistancesIfNeeded()
// Refresh only when the cache is stale (twice-a-day policy). // Refresh only when the cache is stale (twice-a-day policy).
// 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() } Task { await refresh() }
}
} else { } else {
showOnboarding = true showOnboarding = true
} }
@@ -248,6 +365,9 @@ struct ContentView: View {
// empty on a brand-new install. // empty on a brand-new install.
if !showing, FuelStore.loadHasCompletedOnboarding() { if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
installLocationUpdateHook()
SmartDataRefreshScheduler.scheduleNextIfNeeded()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity() updateLiveActivity()
@@ -262,9 +382,11 @@ struct ContentView: View {
if !showOnboarding, FuelStore.loadHasCompletedOnboarding() { if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
} }
SmartDataRefreshScheduler.scheduleNextIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity() updateLiveActivity()
refreshRoadDistancesIfNeeded()
// No network fetch on foreground — pull-to-refresh is the override. // No network fetch on foreground — pull-to-refresh is the override.
} else { } else {
locationManager.stopForegroundTracking() locationManager.stopForegroundTracking()
@@ -280,8 +402,15 @@ struct ContentView: View {
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity() updateLiveActivity()
refreshRoadDistancesIfNeeded()
} }
} }
)
}
private var interactionObservedContent: AnyView {
AnyView(
lifecycleObservedContent
.onChange(of: selectedFuel) { _, _ in .onChange(of: selectedFuel) { _, _ in
// No re-fetch needed — one response carries E5/E10/DIESEL prices. // No re-fetch needed — one response carries E5/E10/DIESEL prices.
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
@@ -305,9 +434,7 @@ struct ContentView: View {
monitor.setEnabled(newValue) monitor.setEnabled(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
if newValue { ensureBackgroundMotionTrackingIfNeeded()
locationManager.startBackgroundTracking()
}
} }
.onChange(of: alertsRadius) { _, newValue in .onChange(of: alertsRadius) { _, newValue in
FuelStore.saveAlertsRadius(newValue) FuelStore.saveAlertsRadius(newValue)
@@ -328,10 +455,17 @@ struct ContentView: View {
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) 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 .onChange(of: liveActivityEnabled) { _, newValue in
// Toggling the Live Activity on starts it with the current best // Toggling the Live Activity on starts it with the current best
// station; toggling off ends any running activity. // station; toggling off ends any running activity.
FuelStore.saveLiveActivityEnabled(newValue) FuelStore.saveLiveActivityEnabled(newValue)
ensureBackgroundMotionTrackingIfNeeded()
updateLiveActivity() updateLiveActivity()
} }
.onChange(of: liveActivityFuel) { _, newValue in .onChange(of: liveActivityFuel) { _, newValue in
@@ -367,6 +501,205 @@ struct ContentView: View {
// so reading storage here could push the OLD style. // so reading storage here could push the OLD style.
updateLiveActivity(priceDisplayStyleOverride: newValue) 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 (tip→network, 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. /// Pushes the current best-in-radius station into the Live Activity.
@@ -420,7 +753,33 @@ struct ContentView: View {
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle, priceDisplayStyle: priceDisplayStyle,
onToggleFavourite: toggleFavourite, onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites 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) }
}
) )
} }
@@ -428,8 +787,10 @@ struct ContentView: View {
/// within the compiler's type-check budget. /// within the compiler's type-check budget.
private var settingsTab: some View { private var settingsTab: some View {
SettingsView( SettingsView(
tipStore: tipStore,
distanceUnit: $distanceUnit, distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle, priceDisplayStyle: $priceDisplayStyle,
dataRefreshMode: $dataRefreshMode,
alertsFuel: alertsFuel, alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius, alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult, testAlertResult: monitor.lastTestResult,
@@ -483,6 +844,7 @@ struct ContentView: View {
isLoading = true isLoading = true
defer { isLoading = false } defer { isLoading = false }
do { do {
let previousFavouriteSnapshots = FuelStore.loadFavouriteAlertSnapshots()
let fetched = try await FuelPriceProvider.active.fetchStations( let fetched = try await FuelPriceProvider.active.fetchStations(
near: location?.lat, lng: location?.lng, near: location?.lat, lng: location?.lng,
fuel: selectedFuel, fuel: selectedFuel,
@@ -491,6 +853,7 @@ struct ContentView: View {
stations = fetched stations = fetched
FuelStore.saveStations(fetched) FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh() FuelStore.saveLastRefresh()
FuelStore.saveLastSmartProbe()
// Persist envelope metadata (source, station count, GOV.UK // Persist envelope metadata (source, station count, GOV.UK
// dataset update time) for the Settings → About section — the // dataset update time) for the Settings → About section — the
// live chain records whichever leg served the fetch. // live chain records whichever leg served the fetch.
@@ -502,16 +865,34 @@ struct ContentView: View {
// channel shared on SideStore free), so stale star-time snapshots // channel shared on SideStore free), so stale star-time snapshots
// would otherwise show old prices. // would otherwise show old prices.
FuelStore.saveFavourites(refreshedFavourites) FuelStore.saveFavourites(refreshedFavourites)
monitor.evaluateFavouritePriceDropAlert(
favourites: refreshedFavourites,
previousSnapshots: previousFavouriteSnapshots,
monitoredFuel: favouriteDropAlertsFuel,
enabled: favouriteDropAlertsEnabled
)
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))" statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
// Live data restored — any status banner no longer applies.
dataStatus = .live
} catch { } catch {
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data." statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
if FuelStore.loadStations().isEmpty { if FuelStore.loadStations().isEmpty {
// No cached prices — last resort is the bundled REAL dump // No cached prices — last resort is the bundled REAL dump
// (stale but genuine), then the demo sample set. // (stale but genuine), then the demo sample set. The banner
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations // 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 { } 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() stations = FuelStore.loadStations()
dataStatus = .connectionProblem
} }
} }
// Keep monitor geofences in sync with the freshest data. // Keep monitor geofences in sync with the freshest data.
@@ -564,14 +945,14 @@ struct StationRow: View {
.padding(5) .padding(5)
} else { } else {
Image(systemName: "fuelpump.fill") Image(systemName: "fuelpump.fill")
.font(.system(size: 22)) .font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(Color.black.opacity(0.82))
} }
} }
.frame(width: 46, height: 46) .frame(width: 46, height: 46)
.background(Circle().fill(.white)) .background(Circle().fill(Color.white))
.clipShape(Circle()) .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) .shadow(color: .black.opacity(0.08), radius: 2, y: 1)
// MIDDLE — name, full address, distance // MIDDLE — name, full address, distance
@@ -596,7 +977,7 @@ struct StationRow: View {
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
if let location { 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) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.monospacedDigit() .monospacedDigit()
+21 -2
View File
@@ -17,6 +17,10 @@ struct FavouritesView: View {
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop). /// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in } 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 /// Fuel types that currently have at least one favourite — these are the
/// only tabs shown (a fuel with no favourites gets no tab). /// only tabs shown (a fuel with no favourites gets no tab).
@@ -62,7 +66,9 @@ struct FavouritesView: View {
distanceUnit: DistanceUnit, distanceUnit: DistanceUnit,
priceDisplayStyle: PriceDisplayStyle, priceDisplayStyle: PriceDisplayStyle,
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }, 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.favourites = favourites
self.selectedFuel = selectedFuel self.selectedFuel = selectedFuel
self.location = location self.location = location
@@ -70,6 +76,8 @@ struct FavouritesView: View {
self.priceDisplayStyle = priceDisplayStyle self.priceDisplayStyle = priceDisplayStyle
self.onToggleFavourite = onToggleFavourite self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder self.onReorder = onReorder
self.onHistoryUnavailable = onHistoryUnavailable
self.onHistoryRecovered = onHistoryRecovered
_fuel = State(initialValue: selectedFuel) _fuel = State(initialValue: selectedFuel)
} }
@@ -158,9 +166,20 @@ struct FavouritesView: View {
TrendsView( TrendsView(
favourites: favourites, favourites: favourites,
selectedFuel: activeFuel, selectedFuel: activeFuel,
priceDisplayStyle: priceDisplayStyle 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
}
} }
} }
} }
+47
View File
@@ -1,13 +1,60 @@
import ActivityKit
import BackgroundTasks
import SwiftUI import SwiftUI
@main @main
struct FuelBoardApp: App { 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 { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.onOpenURL(perform: handleOpenURL) .onOpenURL(perform: handleOpenURL)
} }
.backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) {
await SmartDataRefreshCoordinator.runBackgroundProbe()
} }
}
#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 /// Handles deep links that end up in the app. Widget taps arrive here in
/// two cases: /// two cases:
+4 -2
View File
@@ -57,7 +57,7 @@ enum LiveActivityManager {
brand: best.brand, brand: best.brand,
pricePence: price, pricePence: price,
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(), priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
distanceKM: best.distanceKM(to: location.lat, lng2: location.lng), distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
lat: best.lat, lat: best.lat,
lng: best.lng, lng: best.lng,
updatedAt: Date() updatedAt: Date()
@@ -73,7 +73,9 @@ enum LiveActivityManager {
currentStartDate = nil currentStartDate = nil
start(attributes: attributes, state: state) start(attributes: attributes, state: state)
} else { } else {
Task { await current.update(using: state) } Task {
await current.update(.init(state: state, staleDate: nil))
}
} }
} else { } else {
start(attributes: attributes, state: state) start(attributes: attributes, state: state)
+118 -17
View File
@@ -85,7 +85,7 @@ struct OnboardingView: View {
private var welcomePage: some View { private var welcomePage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .accentColor) {
// The app icon itself, with the standard iOS rounded-corner // The app icon itself, with the standard iOS rounded-corner
// treatment (~22% of the size), so onboarding matches the // treatment (~22% of the size), so onboarding matches the
// Home Screen. // Home Screen.
@@ -122,8 +122,7 @@ struct OnboardingView: View {
private var locationPage: some View { private var locationPage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
Image(systemName: "location.fill") Image(systemName: "location.fill")
.font(.system(size: 40, weight: .semibold)) .font(.system(size: 40, weight: .semibold))
.foregroundStyle(.blue) .foregroundStyle(.blue)
@@ -154,8 +153,7 @@ struct OnboardingView: View {
private var notificationsPage: some View { private var notificationsPage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
Image(systemName: "bell.badge.fill") Image(systemName: "bell.badge.fill")
.font(.system(size: 40, weight: .semibold)) .font(.system(size: 40, weight: .semibold))
.foregroundStyle(.orange) .foregroundStyle(.orange)
@@ -186,8 +184,7 @@ struct OnboardingView: View {
private var dataPage: some View { private var dataPage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
Image(systemName: "arrow.down.circle.fill") Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 40, weight: .semibold)) .font(.system(size: 40, weight: .semibold))
.foregroundStyle(.teal) .foregroundStyle(.teal)
@@ -248,19 +245,27 @@ struct OnboardingView: View {
) { ) {
if prompter.locationDenied { if prompter.locationDenied {
openSettings() openSettings()
} else if prompter.locationGranted {
page = 2 // already decided — just move on
} else { } else {
// The prompt fires on LEAVING this page (onChange(of: // Fire the system prompt NOW, while this page is still on
// page), forward advance only) — the description is on // screen (after it's been read) and BEFORE advancing — so
// screen until the user moves on. // the prompt never covers the next page's animation. Auto-
page = 2 // advance on grant moves us on once the user responds;
// denying leaves the page's "Open Settings" path.
prompter.requestLocation()
} }
} }
case 2: case 2:
primaryButton( primaryButton(
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications") prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
) { ) {
// Prompt fires on leaving this page (same rule as Location). if prompter.notificationsDenied || prompter.notificationsGranted {
page = 3 page = 3 // decided either way — move on
} else {
// Same rule as Location: prompt now, before advancing.
prompter.requestNotifications()
}
} }
case 3: case 3:
// No permission on this page — prices need none. Straight on. // No permission on this page — prices need none. Straight on.
@@ -326,10 +331,103 @@ 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 /// Owns the system permission requests during onboarding and publishes
/// their outcomes so the pages can reflect them live. /// their outcomes so the pages can reflect them live.
@MainActor @MainActor
final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate { final class OnboardingPermissionPrompter: NSObject, ObservableObject, CLLocationManagerDelegate {
@Published private(set) var locationGranted = false @Published private(set) var locationGranted = false
@Published private(set) var locationDenied = false @Published private(set) var locationDenied = false
@Published private(set) var notificationsGranted = false @Published private(set) var notificationsGranted = false
@@ -358,11 +456,14 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
Task { @MainActor in Task { @MainActor in
switch settings.authorizationStatus { switch settings.authorizationStatus {
case .notDetermined: case .notDetermined:
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in do {
Task { @MainActor in let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge])
self.notificationsGranted = granted self.notificationsGranted = granted
self.notificationsDenied = !granted self.notificationsDenied = !granted
} } catch {
self.notificationsGranted = false
self.notificationsDenied = true
} }
case .authorized: case .authorized:
self.notificationsGranted = true self.notificationsGranted = true
+66
View File
@@ -79,6 +79,7 @@ struct TieChoice {
/// entering the winner's circle — not at the forecourt. /// entering the winner's circle — not at the forecourt.
@MainActor @MainActor
final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate { final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate {
private static let favouriteDropThresholdPence: Double = 1.0
@Published var monitoredStationIDs: [String] = [] @Published var monitoredStationIDs: [String] = []
@Published var lastAlert: String? @Published var lastAlert: String?
@Published private(set) var lastTestResult: String? @Published private(set) var lastTestResult: String?
@@ -218,6 +219,46 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
var isEnabled: Bool { enabled } 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. /// Records a stage in the live alert trace, newest first, capped at 8.
private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) { private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) {
alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0) alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0)
@@ -452,6 +493,31 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away" 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 /// Adds a notification whose tap opens directions to `station`. The
/// station's coordinates ride in `userInfo` so the tap handler can route /// 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 /// to Apple Maps (or an in-app fallback), and a map snapshot with a pin is
+102
View File
@@ -0,0 +1,102 @@
// 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: CachedRoadDistance] = [:]
for station in nearest {
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
if let meters = await roadMeters(from: origin, to: dest) {
// Store the exact pin that was routed so the display layer can
// refuse to serve this value if the station later appears with
// a different coordinate (corrected pin / other data source).
entries[station.id] = CachedRoadDistance(meters: meters,
lat: station.lat,
lng: station.lng)
}
}
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(location: CLLocation(latitude: from.latitude,
longitude: from.longitude),
address: nil)
request.destination = MKMapItem(location: CLLocation(latitude: to.latitude,
longitude: to.longitude),
address: nil)
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))
}
}
+128 -27
View File
@@ -13,8 +13,12 @@ import WidgetKit
/// freshness) is debug-only too — moved here so release builds don't expose /// freshness) is debug-only too — moved here so release builds don't expose
/// relay plumbing. /// relay plumbing.
struct SettingsView: View { 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 distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle @Binding var priceDisplayStyle: PriceDisplayStyle
@Binding var dataRefreshMode: DataRefreshMode
/// The fuel + radius currently configured for alerts (mirrors the Alerts /// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say. /// tab) so the test notification matches what real alerts will say.
var alertsFuel: FuelType = .e10 var alertsFuel: FuelType = .e10
@@ -53,9 +57,6 @@ struct SettingsView: View {
/// Clears the armed fence (monitor.clearDebugFence). /// Clears the armed fence (monitor.clearDebugFence).
var onClearDebugFence: () -> Void = {} var onClearDebugFence: () -> Void = {}
@StateObject private var tipStore = TipStore()
@State private var showTipAlert = false
@State private var tipAlertMessage = ""
@State private var testAlertMessage: String? @State private var testAlertMessage: String?
/// Hidden developer flag — the Debug section only appears when on. Toggled /// Hidden developer flag — the Debug section only appears when on. Toggled
/// by tapping the About → Version row five times (NOT a user-facing switch). /// by tapping the About → Version row five times (NOT a user-facing switch).
@@ -120,6 +121,29 @@ struct SettingsView: View {
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).") 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 {
Picker("Data checking", selection: $dataRefreshMode) {
ForEach(DataRefreshMode.allCases) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.onChange(of: dataRefreshMode) { _, newValue in
FuelStore.saveDataRefreshMode(newValue)
SmartDataRefreshScheduler.scheduleNextIfNeeded()
}
VStack(alignment: .leading, spacing: 8) {
Text(dataRefreshMode.summary)
Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.")
.foregroundStyle(.secondary)
}
.font(.footnote)
} header: {
Text("Data checking")
} footer: {
Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.")
}
Section { Section {
Button { Button {
onShowOnboarding() onShowOnboarding()
@@ -298,25 +322,47 @@ struct SettingsView: View {
} }
Section { Section {
VStack(spacing: 10) {
ForEach(TipStore.tiers) { tier in ForEach(TipStore.tiers) { tier in
Button { Button {
Task { await tipStore.purchase(tier) } Task { await tipStore.purchase(tier) }
} label: { } label: {
HStack { HStack(spacing: 12) {
Image(systemName: "fuelpump.fill")
.font(.title3)
.foregroundStyle(tier.accent)
.frame(width: 34)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(tier.name) Text(tier.name)
.font(.headline)
Text(tier.blurb) Text(tier.blurb)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer(minLength: 8)
Text(tipStore.displayPrice(for: tier)) Text(tipStore.displayPrice(for: tier))
.foregroundStyle(.secondary) .font(.headline.weight(.bold))
.foregroundStyle(tier.accent)
.monospacedDigit() .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: { } header: {
Text("Support FuelBoard") Text("Support FuelBoard")
} footer: { } footer: {
@@ -337,13 +383,6 @@ struct SettingsView: View {
Text("About") Text("About")
} }
if let message = tipStore.message {
Section {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.onAppear { .onAppear {
@@ -386,7 +425,7 @@ struct SettingsView: View {
Label("In range", systemImage: "location.circle.fill") Label("In range", systemImage: "location.circle.fill")
Spacer() Spacer()
HStack(spacing: 6) { HStack(spacing: 6) {
if let coord = status.coordinate { if status.coordinate != nil {
Circle() Circle()
.fill(inRangeColor(status.inRangeCount)) .fill(inRangeColor(status.inRangeCount))
.frame(width: 10, height: 10) .frame(width: 10, height: 10)
@@ -545,13 +584,17 @@ struct SettingsView: View {
testAlertMessage = NSLocalizedString("Notifications are turned off for FuelBoard. Enable them in Settings → Notifications → FuelBoard, then try again.", comment: "") testAlertMessage = NSLocalizedString("Notifications are turned off for FuelBoard. Enable them in Settings → Notifications → FuelBoard, then try again.", comment: "")
default: default:
// First time — ask, then fire if granted. // 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 { if granted {
onTestAlert() onTestAlert()
} else { } else {
testAlertMessage = NSLocalizedString("Notifications weren't allowed, so no test alert was sent.", comment: "") 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: "")
} }
} }
} }
@@ -569,20 +612,34 @@ final class TipStore: ObservableObject {
let name: String let name: String
let blurb: String let blurb: String
let fallbackPrice: String let fallbackPrice: String
let accent: Color // card/price accent (matches the fuel palette)
} }
static let tiers: [TipTier] = [ static let tiers: [TipTier] = [
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash", TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99"), 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", TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
blurb: "A generous top-up for development.", fallbackPrice: "£2.99"), 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", TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99"), 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 products: [String: Product] = [:]
@Published private(set) var purchaseInProgress = false @Published private(set) var purchaseInProgress = false
@Published private(set) var message: String? @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() /// Listens for transactions that complete OUTSIDE the direct purchase()
/// call — Ask to Buy approvals, payments finished on another device /// call — Ask to Buy approvals, payments finished on another device
@@ -600,6 +657,7 @@ final class TipStore: ObservableObject {
deinit { deinit {
updatesTask?.cancel() updatesTask?.cancel()
dismissTask?.cancel()
} }
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async { private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
@@ -608,7 +666,30 @@ final class TipStore: ObservableObject {
// Only acknowledge our own consumables (future products get their own). // Only acknowledge our own consumables (future products get their own).
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return } guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
await transaction.finish() await transaction.finish()
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "") 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 { func displayPrice(for tier: TipTier) -> String {
@@ -637,7 +718,11 @@ final class TipStore: ObservableObject {
// Connect yet), still allow the attempt so the user sees a clear // Connect yet), still allow the attempt so the user sees a clear
// outcome rather than a dead button. // outcome rather than a dead button.
guard let product = products[tier.id] else { guard let product = products[tier.id] else {
message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "") 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 return
} }
@@ -650,19 +735,35 @@ final class TipStore: ObservableObject {
// Consume the consumable — otherwise StoreKit re-delivers // Consume the consumable — otherwise StoreKit re-delivers
// it through Transaction.updates on every launch. // it through Transaction.updates on every launch.
await transaction.finish() await transaction.finish()
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "") 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: 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: case .userCancelled:
message = nil // silent — the user just closed the sheet dismissOutcome() // silent — the user just closed the sheet
case .pending: 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: @unknown default:
message = nil dismissOutcome()
} }
} catch { } 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
)
} }
} }
} }
+15 -28
View File
@@ -22,18 +22,6 @@ import SwiftUI
/// "super unleaded") so Siri matches how people actually ask. The app UI's /// "super unleaded") so Siri matches how people actually ask. The app UI's
/// FuelType.displayName is untouched. /// 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 // MARK: - Intent
struct CheapestFuelIntent: AppIntent { struct CheapestFuelIntent: AppIntent {
@@ -137,7 +125,7 @@ struct CheapestFuelIntent: AppIntent {
struct DirectionsToCheapestFuelIntent: AppIntent { struct DirectionsToCheapestFuelIntent: AppIntent {
static var title: LocalizedStringResource = "Directions to Cheapest Fuel Near Me" static var title: LocalizedStringResource = "Directions to Cheapest Fuel Near Me"
static var description = IntentDescription( static var description = IntentDescription(
"Opens Apple Maps directions to the cheapest station selling a fuel near you, using the latest cached prices." "Opens directions to the cheapest station selling a fuel near you, using the latest cached prices."
) )
@Parameter(title: "Fuel") @Parameter(title: "Fuel")
@@ -297,7 +285,7 @@ struct FavouriteFuelPriceIntent: AppIntent {
struct DirectionsToFavouriteFuelIntent: AppIntent { struct DirectionsToFavouriteFuelIntent: AppIntent {
static var title: LocalizedStringResource = "Directions to Favourite Fuel Station" static var title: LocalizedStringResource = "Directions to Favourite Fuel Station"
static var description = IntentDescription( static var description = IntentDescription(
"Opens Apple Maps directions to your top favourite station for a fuel." "Opens directions to your top favourite station for a fuel."
) )
@Parameter(title: "Fuel") @Parameter(title: "Fuel")
@@ -416,7 +404,7 @@ struct FuelMessage: View {
/// Shortcut slot and adds no phrase. /// Shortcut slot and adds no phrase.
struct OpenDirectionsIntent: AppIntent { struct OpenDirectionsIntent: AppIntent {
static var title: LocalizedStringResource = "Directions" static var title: LocalizedStringResource = "Directions"
static var description = IntentDescription("Opens Apple Maps directions to the station.") static var description = IntentDescription("Opens directions to the station.")
static var isDiscoverable: Bool = false static var isDiscoverable: Bool = false
@Parameter var stationName: String @Parameter var stationName: String
@@ -501,19 +489,14 @@ struct FuelMessageSnippetIntent: SnippetIntent {
struct FuelBoardShortcuts: AppShortcutsProvider { struct FuelBoardShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] { static var appShortcuts: [AppShortcut] {
// Generic parameterized shortcut — matches whatever fuel word Siri // NOTE (2026-08-20): the generic parameterized shortcut ("cheapest
// resolves. Parameter resolution is flaky on-device ("cheapest diesel" // ${fuel}") was REMOVED. It competed with the fixed-fuel literal-word
// matched, "cheapest unleaded" didn't), which is why the fixed-fuel // shortcuts for the same intent (parameter resolution was already flaky
// entries below carry the fuel word as LITERAL phrase text. // on-device), which degraded Siri's NLU ranking and caused the
AppShortcut( // hit-and-miss "can't do that, searching in app" fallback. Now Siri
intent: CheapestFuelIntent(), // routes on literal fuel words only, and the freed slot keeps us under
phrases: [ // the 10-shortcut cap. Every phrase carries .applicationName (iOS 26
"Ask \(.applicationName) what's the cheapest \(\.$fuel) near me", // metadata-processor requirement) and mirrors how people actually ask.
],
shortTitle: "Cheapest Fuel",
systemImageName: "fuelpump"
)
AppShortcut( AppShortcut(
intent: CheapestFuelIntent(fuel: .e10), intent: CheapestFuelIntent(fuel: .e10),
phrases: [ phrases: [
@@ -521,6 +504,10 @@ struct FuelBoardShortcuts: AppShortcutsProvider {
"Ask \(.applicationName) for the cheapest petrol near me", "Ask \(.applicationName) for the cheapest petrol near me",
"Find the cheapest petrol near me \(.applicationName)", "Find the cheapest petrol near me \(.applicationName)",
"Find the cheapest unleaded near me \(.applicationName)", "Find the cheapest unleaded near me \(.applicationName)",
"What's the cheapest unleaded near me \(.applicationName)",
"What's the cheapest petrol near me \(.applicationName)",
"Cheapest unleaded near me \(.applicationName)",
"Where's the cheapest petrol \(.applicationName)",
], ],
shortTitle: "Cheapest Unleaded", shortTitle: "Cheapest Unleaded",
systemImageName: "fuelpump" systemImageName: "fuelpump"
+88
View File
@@ -0,0 +1,88 @@
import BackgroundTasks
import Foundation
import WidgetKit
enum SmartDataRefreshScheduler {
static let taskIdentifier = "com.apt.fuelboard.smart-refresh"
static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
guard let task = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false)
return
}
handle(task)
}
}
static func scheduleNextIfNeeded() {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier)
guard FuelStore.loadDataRefreshMode() == .smart else { return }
let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: FuelStore.smartProbeInterval)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
#if DEBUG
print("SMART-REFRESH schedule failed: \(error)")
#endif
}
}
private static func handle(_ task: BGAppRefreshTask) {
scheduleNextIfNeeded()
let refreshTask = Task {
let success = await SmartDataRefreshCoordinator.runBackgroundProbe()
task.setTaskCompleted(success: success)
}
task.expirationHandler = {
refreshTask.cancel()
}
}
}
enum SmartDataRefreshCoordinator {
@discardableResult
static func runBackgroundProbe(now: Date = Date()) async -> Bool {
guard FuelStore.loadDataRefreshMode() == .smart else { return true }
guard FuelStore.isSmartProbeDue(now: now) || FuelStore.loadStations().isEmpty else {
return true
}
FuelStore.saveLastSmartProbe(now)
guard !Task.isCancelled else { return false }
guard let latest = await FuelHistoryStore.fetchLatest() else {
return false
}
let latestDay = latest.availableTo ?? latest.date
let latestUpdated = latest.dataUpdated
let cachedDay = MirrorFuelProvider.loadDumpCache()?.day
let savedUpdated = FuelStore.loadDataUpdated()
let shouldFetchFullDump = FuelStore.loadStations().isEmpty
|| !FuelStore.isCacheFresh(now: now)
|| (latestDay != nil && latestDay != cachedDay)
|| (latestUpdated != nil && latestUpdated != savedUpdated)
guard shouldFetchFullDump, !Task.isCancelled else { return true }
do {
let fetched = try await FuelPriceProvider.active.fetchStations(
near: nil,
lng: nil,
fuel: FuelStore.loadSelectedFuel(),
radiusKM: nil
)
guard !Task.isCancelled else { return false }
FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh(now)
if let meta = LiveChainProvider.latestMeta {
FuelStore.saveRelayMeta(meta)
}
WidgetCenter.shared.reloadAllTimelines()
return true
} catch {
return false
}
}
}
+111 -38
View File
@@ -44,7 +44,7 @@ struct StationsView: View {
} else { } else {
ratio = "\(totalCount)" ratio = "\(totalCount)"
} }
if let location { if location != nil {
if sortMode == .closest { if sortMode == .closest {
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated" return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
} }
@@ -174,11 +174,13 @@ struct StationsView: View {
.navigationTitle("FuelBoard") .navigationTitle("FuelBoard")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.onAppear { .onAppear {
#if DEBUG
// QA hook: launch with `-showKeySheet` to verify the legend // QA hook: launch with `-showKeySheet` to verify the legend
// sheet without driving a tap (simctl has no tap command). // sheet without driving a tap (simctl has no tap command).
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") { if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
showKey = true showKey = true
} }
#endif
} }
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
@@ -193,39 +195,58 @@ struct StationsView: View {
.sheet(isPresented: $showKey) { .sheet(isPresented: $showKey) {
NavigationStack { NavigationStack {
List { List {
Section("Key") { Section {
HStack(spacing: 8) { VStack(spacing: 10) {
Circle().fill(.green).frame(width: 12, height: 12) keyCard(
Text("Best value — within 1.5p of the cheapest") icon: "checkmark.circle.fill", color: .green,
.font(.caption) title: "Best value", blurb: "Within 1.5p of the cheapest",
} threshold: "≤1.5p")
HStack(spacing: 8) { keyCard(
Circle().fill(.orange).frame(width: 12, height: 12) icon: "equal.circle.fill", color: .orange,
Text("Okay — within 4p of the cheapest") title: "Okay", blurb: "Within 4p of the cheapest",
.font(.caption) threshold: "≤4p")
} keyCard(
HStack(spacing: 8) { icon: "exclamationmark.circle.fill", color: .red,
Circle().fill(.red).frame(width: 12, height: 12) title: "Pricey", blurb: "More than 4p over the cheapest",
Text("Pricey — more than 4p over the cheapest") threshold: ">4p")
.font(.caption) Rectangle()
} .fill(Color(.separator))
HStack(spacing: 8) { .frame(height: 0.5)
.padding(.vertical, 2)
HStack(spacing: 12) {
Text("TOP") Text("TOP")
.font(.caption2.bold()) .font(.caption2.bold())
.padding(.horizontal, 5) .padding(.horizontal, 7)
.padding(.vertical, 1) .padding(.vertical, 3)
.background(Capsule().fill(.blue.opacity(0.15))) .background(Capsule().fill(.blue.opacity(0.16)))
.foregroundStyle(.blue) .foregroundStyle(.blue)
Text("Top result for the current sort") Text("Top result for the current sort")
.font(.caption) .font(.subheadline.weight(.semibold))
Spacer(minLength: 0)
} }
HStack(spacing: 8) { .padding(.horizontal, 14)
.padding(.vertical, 12)
.keyCardFill(color: .blue)
HStack(spacing: 12) {
Image(systemName: "star.fill") Image(systemName: "star.fill")
.font(.caption2) .font(.title3)
.foregroundStyle(.yellow) .foregroundStyle(.yellow)
.frame(width: 34)
Text("Star a station to add it to Favourites") Text("Star a station to add it to Favourites")
.font(.caption) .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") .navigationTitle("Key")
@@ -236,10 +257,52 @@ struct StationsView: View {
} }
} }
} }
.presentationDetents([.medium]) .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) // MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
@@ -254,17 +317,6 @@ extension FuelType {
case .diesel: return "Diesel" 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 /// Fuel-type selector styled like a segmented control, with a coloured pump
@@ -279,20 +331,26 @@ struct FuelTypeSegmentedPicker: View {
var fuels: [FuelType] = FuelType.allCases var fuels: [FuelType] = FuelType.allCases
var onSelect: (FuelType) -> Void = { _ in } var onSelect: (FuelType) -> Void = { _ in }
@Namespace private var selectionSlider
var body: some View { var body: some View {
HStack(spacing: 3) { HStack(spacing: 3) {
ForEach(fuels) { fuel in ForEach(fuels) { fuel in
let isSelected = fuel == selection let isSelected = fuel == selection
Button { Button {
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
selection = fuel selection = fuel
}
onSelect(fuel) onSelect(fuel)
} label: { } label: {
HStack(spacing: 5) { HStack(spacing: 5) {
Image(systemName: "fuelpump.fill") Image(systemName: "fuelpump.fill")
.font(.caption2) .font(.caption2)
.foregroundStyle(fuel.tintColor) .foregroundStyle(fuel.tintColor)
.scaleEffect(isSelected ? 1.08 : 0.94)
Text(fuel.shortName) Text(fuel.shortName)
.font(.subheadline.weight(isSelected ? .semibold : .regular)) .font(.subheadline.weight(isSelected ? .semibold : .regular))
.contentTransition(.opacity)
} }
.foregroundStyle(isSelected ? Color.primary : Color.secondary) .foregroundStyle(isSelected ? Color.primary : Color.secondary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -301,14 +359,29 @@ struct FuelTypeSegmentedPicker: View {
if isSelected { if isSelected {
Capsule() Capsule()
.fill(Color(UIColor.systemBackground)) .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) .buttonStyle(.plain)
} }
} }
.padding(3) .padding(3)
.background(Capsule().fill(Color(.secondarySystemBackground))) .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))
}
+330 -14
View File
@@ -13,10 +13,19 @@ struct TrendsView: View {
/// All favourites (fuel-scoped entries) — the sheet derives the active /// All favourites (fuel-scoped entries) — the sheet derives the active
/// fuel's stations and which fuels have favourites. /// fuel's stations and which fuels have favourites.
let favourites: [FavouriteEntry] 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 selectedFuel: FuelType
let priceDisplayStyle: PriceDisplayStyle 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 @Environment(\.dismiss) private var dismiss
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var fuel: FuelType = .e10 @State private var fuel: FuelType = .e10
@State private var rangeDays: Int = 30 @State private var rangeDays: Int = 30
@@ -26,6 +35,28 @@ struct TrendsView: View {
@State private var loadFailed = false @State private var loadFailed = false
@State private var firstSnapshot: String? @State private var firstSnapshot: String?
/// Per-series draw-in waterline: maps stationID → how many leading points
/// are revealed, so each line traces left→right on first appearance.
/// Series reveal in a short staggered cascade so the draw is clearly
/// visible even with only 2 points per line. Stays full after the first
/// reveal so range/mode switches morph instead of re-tracing.
@State private var revealed: [String: Int] = [:]
/// 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 { enum TrendsMode: String, CaseIterable, Identifiable {
case price case price
case vsCheapest case vsCheapest
@@ -79,7 +110,44 @@ struct TrendsView: View {
} }
} }
/// 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 0→200 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 { 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 isLoading = true
loadFailed = false loadFailed = false
defer { isLoading = false } defer { isLoading = false }
@@ -97,7 +165,47 @@ struct TrendsView: View {
// pointer probe already done above. // pointer probe already done above.
loadFailed = firstSnapshot == nil loadFailed = firstSnapshot == nil
} }
// Always redraw the lines on a selection change. `.task(id:)` fires on
// the initial appear and on every fuel/range switch (there's no
// periodic refetch in this sheet), so a staggered per-series draw-in
// replays exactly when the user picks 7/30/90 (or switches fuel) while
// a width-stable morph keeps the x-axis/y-range from jumping abruptly.
series = fetched series = fetched
revealSeries(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?()
}
}
/// Replays the staggered per-series draw-in for the given histories:
/// resets the waterline, then cascades each station's line left→right,
/// 0.18s apart, on every range/fuel selection. Reduce Motion jump-cuts
/// straight to the full state.
private func revealSeries(_ histories: [StationHistory]) {
let hasPoints = histories.contains { !$0.points.isEmpty }
guard hasPoints else { return }
if reduceMotion {
revealed = histories.reduce(into: [:]) { $0[$1.stationID] = $1.points.count }
return
}
revealed = [:]
let cascadeNS = UInt64(0.18 * 1_000_000_000)
for (i, h) in histories.enumerated() {
let sid = h.stationID
let total = h.points.count
Task {
try? await Task.sleep(nanoseconds: UInt64(i) * cascadeNS)
guard !Task.isCancelled else { return }
withAnimation(.easeOut(duration: 0.5)) {
revealed[sid] = total
}
}
}
} }
private func yLabel(_ pence: Double) -> String { private func yLabel(_ pence: Double) -> String {
@@ -109,6 +217,38 @@ struct TrendsView: View {
} }
} }
// 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 { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 14) { VStack(spacing: 14) {
@@ -172,10 +312,7 @@ struct TrendsView: View {
} else if !hasEnoughData { } else if !hasEnoughData {
emptyState // single point — nothing to draw yet emptyState // single point — nothing to draw yet
} else { } else {
VStack(spacing: 12) { revenueCatCard
chart
legend
}
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -204,14 +341,107 @@ struct TrendsView: View {
.padding(.vertical, 24) .padding(.vertical, 24)
} }
private var chart: some View { /// RevenueCat-style card: rounded surface with a headline metric (period
Chart(displaySeries) { history in /// average + change), the multi-series area chart, then the key. Mirrors
ForEach(history.points) { point in /// the mockup approved after the dashboard reference screenshot.
LineMark( private var revenueCatCard: some View {
x: .value("Date", point.date), VStack(alignment: .leading, spacing: 10) {
y: .value("Price", point.pence) metricHeader
metricFooter
chart
legend
legendFooter
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(.secondarySystemGroupedBackground))
) )
.foregroundStyle(seriesColor(index(of: history.stationID))) }
/// 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)
// Roll the digits to the new figure on range/mode change
// (fires inside the animated transaction above).
.contentTransition(.numericText(value: headAvg))
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.prefix(revealed[history.stationID] ?? 0)) { point in
areaMark(point, series: history.name,
color: seriesColor(index(of: history.stationID)))
}
}
ForEach(displaySeries) { history in
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
lineMark(point, series: history.name,
color: seriesColor(index(of: history.stationID)))
}
} }
} }
.chartXAxis { .chartXAxis {
@@ -219,39 +449,125 @@ struct TrendsView: View {
AxisGridLine() AxisGridLine()
AxisTick() AxisTick()
AxisValueLabel(format: .dateTime.month().day()) AxisValueLabel(format: .dateTime.month().day())
.font(.caption2)
.foregroundStyle(.secondary)
} }
} }
.chartYScale(domain: yDomain)
.chartYAxis { .chartYAxis {
AxisMarks { value in AxisMarks { value in
AxisGridLine() AxisGridLine()
AxisValueLabel { AxisValueLabel {
if let pence = value.as(Double.self) { if let pence = value.as(Double.self) {
Text(yLabel(pence)) Text(yLabel(pence))
.font(.caption2)
.foregroundStyle(.secondary)
} }
} }
} }
} }
.frame(height: 260) .frame(height: 190)
// Price ↔ vs-cheapest is a pure view toggle (no network): animate the
// lines + axis gliding to the rebased series.
.animation(.easeInOut(duration: 0.35), value: mode)
}
/// 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 { private func index(of stationID: String) -> Int {
orderedStations.firstIndex(where: { $0.id == stationID }) ?? 0 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 { private var legend: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
ForEach(Array(displaySeries.enumerated()), id: \.element.stationID) { index, history in ForEach(legendSeries, id: \.stationID) { history in
HStack(spacing: 8) { HStack(spacing: 8) {
Circle() Circle()
.fill(seriesColor(index)) .fill(seriesColor(index(of: history.stationID)))
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
Text(history.name) Text(history.name)
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
if let avg = FuelHistoryStore.averagePence(history.points) {
Text(legendFigure(avg))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Spacer() Spacer()
} }
} }
} }
.padding(.horizontal, 4) .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)
}
} }
+19 -3
View File
@@ -23,11 +23,15 @@
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)"; "Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
"All %lld stations shown" = "All %lld stations shown"; "All %lld stations shown" = "All %lld stations shown";
"Key" = "Key"; "Key" = "Key";
"Best value — within 1.5p of the cheapest" = "Best value — within 1.5p of the cheapest"; "Best value" = "Best value";
"Okay — within 4p of the cheapest" = "Okay — within 4p of the cheapest"; "Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
"Pricey — more than 4p over the cheapest" = "Pricey — more than 4p over 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"; "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"; "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 */ /* Favourites tab */
"No favourites yet" = "No favourites yet"; "No favourites yet" = "No favourites yet";
@@ -152,3 +156,15 @@
"No price history yet" = "No price history yet"; "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."; "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."; "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,24 @@
import Foundation
enum DataRefreshMode: String, CaseIterable, Identifiable {
case standard
case smart
var id: String { rawValue }
var displayName: String {
switch self {
case .standard: return "Standard"
case .smart: return "Smart"
}
}
var summary: String {
switch self {
case .standard:
return "Saves battery. Refreshes price data up to twice daily."
case .smart:
return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
}
}
}
@@ -233,11 +233,25 @@ final class BrandTests: XCTestCase {
XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet") XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet")
XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco") XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco")
XCTAssertEqual(station(brand: "APPLEGREEN").brandImageName, "brand_applegreen") 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() { func testUnknownBrandUsesGenericFuelPumpFallbackInUI() {
XCTAssertNil(station(brand: "WELCOME BREAK").brandImageName) XCTAssertNil(station(brand: "TOTAL HARVEST ENERGY").brandImageName)
XCTAssertNil(station(brand: "VALERO").brandImageName) XCTAssertNil(station(brand: "SOLO").brandImageName)
XCTAssertNil(station(brand: "PACE").brandImageName)
XCTAssertNil(station(brand: "").brandImageName) XCTAssertNil(station(brand: "").brandImageName)
} }
} }
@@ -587,3 +601,101 @@ final class PriceDisplayTests: XCTestCase {
XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009") 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": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
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": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
let meters = FuelStore.roadDistanceMeters(for: s, 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": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
// 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)
}
func testRoadDistanceNotServedWhenStationPinDiffers() {
// Route a road distance to station "a" at pin P1.
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
// The SAME station id appears with a moved pin (corrected coordinate /
// different embedded vs live source): the cached route to P1 must NOT
// be served — it belongs to a different location.
let moved = station("a", 51.5400, -0.1600)
let km = FuelStore.displayDistanceKM(station: moved, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
"road value routed to the old pin leaked onto a different coordinate")
}
}
// MARK: - Install identity
final class InstallIdentityTests: XCTestCase {
func testFreshInstallIsStableAfterFirstCall() {
// The first call seeds the common install id (local == keychain), so
// any subsequent call in the same process must report NOT-fresh. This
// holds regardless of persisted keychain/defaults state from prior runs.
_ = FuelStore.isFreshInstall()
XCTAssertFalse(FuelStore.isFreshInstall())
}
}
@@ -164,6 +164,29 @@ final class FuelHistoryTests: XCTestCase {
XCTAssertEqual(pruned.count, days.count) 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 // MARK: Mirror URLs
func testHistoryFileURLKeepsBaseLastSegment() { func testHistoryFileURLKeepsBaseLastSegment() {
+102 -12
View File
@@ -3,21 +3,30 @@
// Lives in the widget extension (the standard host for ActivityConfiguration). // Lives in the widget extension (the standard host for ActivityConfiguration).
// Shows the cheapest station for the pinned fuel within the app's chosen // Shows the cheapest station for the pinned fuel within the app's chosen
// radius. Tapping anywhere opens Apple Maps directions to that station. // 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 ActivityKit
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
/// The Live Activity itself — registered in the widget bundle alongside the /// The Live Activity itself — registered in the widget bundle alongside the
/// regular price widget. No CarPlay entitlement involved: this renders on the /// regular price widget. No CarPlay entitlement involved: renders on the
/// Lock Screen, Dynamic Island, and the car display (CarPlay Ultra, iOS 26+). /// Lock Screen (full-width card on iPhone/iPad) and the Dynamic Island
/// `.supplementalActivityFamilies([.small])` makes it eligible for the car's /// (incl. the island's compact form used in the car, display-only — FuelBoard
/// small Live Activity slot — display-only there (FuelBoard is not a /// is not a CarPlay-enabled app, so car-side taps can't launch anything).
/// CarPlay-enabled app, so car-side taps can't launch anything).
struct FuelBoardLiveActivity: Widget { struct FuelBoardLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
// Lock Screen / banner presentation // Lock Screen / banner / CarPlay small slot presentation
FuelBoardLiveActivityView(context: context) FuelBoardLiveActivityView(context: context)
} dynamicIsland: { context in } dynamicIsland: { context in
DynamicIsland { DynamicIsland {
@@ -32,7 +41,7 @@ struct FuelBoardLiveActivity: Widget {
} }
} compactLeading: { } compactLeading: {
Image(systemName: "fuelpump.fill") Image(systemName: "fuelpump.fill")
.foregroundStyle(.green) .foregroundStyle(context.state.fuel.tintColor)
} compactTrailing: { } compactTrailing: {
FuelBoardLiveActivityPriceView(context: context) FuelBoardLiveActivityPriceView(context: context)
} minimal: { } minimal: {
@@ -41,20 +50,74 @@ struct FuelBoardLiveActivity: Widget {
} }
} }
.supplementalActivityFamilies([.small]) .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 { private struct FuelBoardLiveActivityView: View {
let context: ActivityViewContext<FuelBoardLiveActivityAttributes> 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 { var body: some View {
Link(destination: context.state.mapsURL) { Link(destination: context.state.mapsURL) {
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
}
}
// 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) { HStack(spacing: 12) {
// LEFT — station brand glyph // LEFT — station brand glyph: the fuel-tinted pump on its own. No
Image(systemName: "fuelpump.circle.fill") // background circle behind it (user request).
.font(.system(size: 32)) Image(systemName: "fuelpump.fill")
.foregroundStyle(.green, .white) .font(.system(size: 28, weight: .semibold))
.foregroundStyle(context.state.fuel.tintColor)
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
// MIDDLE — fuel + station // MIDDLE — fuel + station
@@ -65,6 +128,8 @@ private struct FuelBoardLiveActivityView: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.75)
.truncationMode(.tail)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -80,6 +145,31 @@ private struct FuelBoardLiveActivityView: View {
} }
.padding() .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)
} }
} }
+3 -3
View File
@@ -156,7 +156,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)), URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
] ]
guard let url = components.url else { return } guard let url = components.url else { return }
var request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2) let request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
Task { Task {
_ = try? await URLSession.shared.data(for: request) _ = try? await URLSession.shared.data(for: request)
} }
@@ -312,7 +312,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
URLQueryItem(name: "radius", value: String(radiusKM)), URLQueryItem(name: "radius", value: String(radiusKM)),
URLQueryItem(name: "limit", value: "500"), URLQueryItem(name: "limit", value: "500"),
] ]
var request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5) let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
do { do {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
@@ -340,7 +340,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
URLQueryItem(name: "fuel", value: fuel.rawValue), URLQueryItem(name: "fuel", value: fuel.rawValue),
URLQueryItem(name: "limit", value: String(limit)), URLQueryItem(name: "limit", value: String(limit)),
] ]
var request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5) let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
do { do {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
+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)
+24
View File
@@ -0,0 +1,24 @@
import Foundation
enum DataRefreshMode: String, CaseIterable, Identifiable {
case standard
case smart
var id: String { rawValue }
var displayName: String {
switch self {
case .standard: return "Standard"
case .smart: return "Smart"
}
}
var summary: String {
switch self {
case .standard:
return "Saves battery. Refreshes price data up to twice daily."
case .smart:
return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
}
}
}
+18 -3
View File
@@ -148,6 +148,13 @@ enum FuelHistoryStore {
} }
} }
/// 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 /// 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 /// 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. /// baseline delta pattern). Days where a station has no point are gaps.
@@ -209,10 +216,18 @@ enum FuelHistoryStore {
// MARK: Network // MARK: Network
/// The mirror pointer — used for the empty-state hint ("first snapshot /// The mirror pointer — used for the empty-state hint ("first snapshot
/// landed …"). Non-fatal: nil just means no hint. /// landed …"). Non-fatal: nil just means no hint. Deliberately bypasses
static func fetchLatest(base: URL = mirrorBase, /// 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? { session: URLSession = .shared) async -> MirrorLatest? {
guard let (data, response) = try? await session.data(from: latestFileURL(base: base)), 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, (response as? HTTPURLResponse)?.statusCode == 200,
let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else { let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else {
return nil return nil
+2 -2
View File
@@ -89,7 +89,7 @@ struct FuelPriceWidgetContent: View {
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green) FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
} }
if let location = entry.location { if let location = entry.location {
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away") Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
@@ -148,7 +148,7 @@ struct FuelPriceWidgetContent: View {
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.lineLimit(1) .lineLimit(1)
if let location = entry.location { if let location = entry.location {
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng))) Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
+322 -10
View File
@@ -8,7 +8,11 @@
// keychain → app-group defaults → fallback. // keychain → app-group defaults → fallback.
import Foundation import Foundation
import SwiftUI
import Security import Security
#if canImport(AppIntents)
import AppIntents
#endif
import SwiftUI import SwiftUI
// MARK: - Fuel types // MARK: - Fuel types
@@ -28,6 +32,16 @@ struct FavouriteEntry: Identifiable, Codable, Equatable {
var id: String { "\(fuel.rawValue)|\(station.id)" } 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 { enum SortMode: String, Codable, CaseIterable, Identifiable {
case cheapest case cheapest
case closest case closest
@@ -59,6 +73,18 @@ enum RAGRating: Int, Codable {
} }
enum FuelType: String, Codable, CaseIterable, Identifiable { 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 e10 // Unleaded (E10)
case e5 // Premium (E5) case e5 // Premium (E5)
case diesel // B7 diesel case diesel // B7 diesel
@@ -74,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 /// Display unit for all distances in the app + widget. Internally distances
/// are always stored/computed in km; conversion happens at the display and /// are always stored/computed in km; conversion happens at the display and
/// filter boundary so nothing else needs to know the unit. /// filter boundary so nothing else needs to know the unit.
@@ -213,21 +257,37 @@ struct FuelStation: Identifiable, Codable, Equatable {
var brandImageName: String? { var brandImageName: String? {
let raw = brand.uppercased() let raw = brand.uppercased()
let known: [(String, String)] = [ let known: [(String, String)] = [
("CIRCLE K", "brand_circle_k"),
("MAXOL", "brand_maxol"),
("SPAR", "brand_spar"),
("SHELL", "brand_shell"), ("SHELL", "brand_shell"),
("SAINSBURY", "brand_sainsburys"), ("SAINSBURY", "brand_sainsburys"),
("MORRISONS", "brand_morrisons"), ("MORRISONS", "brand_morrisons"),
("APPLEGREEN", "brand_applegreen"), ("APPLEGREEN", "brand_applegreen"),
("TESCO", "brand_tesco"), ("TESCO", "brand_tesco"),
("TEXACO", "brand_texaco"), ("TEXACO", "brand_texaco"),
("ESSAR", "brand_essar"),
("ESSO", "brand_esso"), ("ESSO", "brand_esso"),
("ASDA", "brand_asda"), ("ASDA", "brand_asda"),
("GULF", "brand_gulf"), ("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"), ("BP", "brand_bp"),
("JET", "brand_jet"), ("JET", "brand_jet"),
] ]
for (needle, asset) in known where raw.contains(needle) { for (needle, asset) in known where raw.contains(needle) {
return asset 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 return nil
} }
} }
@@ -299,6 +359,9 @@ struct FuelStore {
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance 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 liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
static let relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback static let relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback
@@ -307,6 +370,8 @@ struct FuelStore {
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15) static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
static let dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value
static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // TimeInterval (seconds since 1970)
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv" static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
static let stationCountKey = "fuelboard.stationCount" // String — station count static let stationCountKey = "fuelboard.stationCount" // String — station count
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
@@ -516,7 +581,7 @@ struct FuelStore {
let whole = tenths / 1000 let whole = tenths / 1000
let major = (tenths % 1000) / 10 let major = (tenths % 1000) / 10
let minor = tenths % 10 let minor = tenths % 10
let main = Text(String(format: "£%d.%02d", whole, major)) let amount = Text(String(format: "£%d.%02d", whole, major))
.font(base) .font(base)
.foregroundColor(color) .foregroundColor(color)
let sup = Text(String(superscriptDigits[minor])) let sup = Text(String(superscriptDigits[minor]))
@@ -526,7 +591,7 @@ struct FuelStore {
let perL = Text("/L") let perL = Text("/L")
.font(.system(size: size * 0.5, weight: .regular).monospaced()) .font(.system(size: size * 0.5, weight: .regular).monospaced())
.foregroundColor(color.opacity(0.55)) .foregroundColor(color.opacity(0.55))
return main + sup + perL return Text("\(amount)\(sup)\(perL)")
} }
} }
@@ -608,6 +673,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 /// 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 /// the Favourites tab). The moved fuel's block stays at its original
/// position in the array; other fuels keep their relative order. The array /// position in the array; other fuels keep their relative order. The array
@@ -691,6 +774,48 @@ struct FuelStore {
saveString(enabled ? "1" : "0", service: alertsFollowSearchKey) 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 // MARK: Live Activity
static func loadLiveActivityEnabled() -> Bool { static func loadLiveActivityEnabled() -> Bool {
@@ -738,10 +863,24 @@ struct FuelStore {
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey) saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
} }
// MARK: Refresh policy — data is cached; the app only auto-refreshes // MARK: Refresh policy — Standard is cache-first/twice-daily; Smart keeps
// twice a day (pull-to-refresh is the manual override). // the same full-dump freshness cap but may probe the tiny mirror pointer
// more often in the background and only refresh the full dump if it changed.
static let refreshInterval: TimeInterval = 12 * 60 * 60 static let refreshInterval: TimeInterval = 12 * 60 * 60
static let smartProbeInterval: TimeInterval = 4 * 60 * 60
static func loadDataRefreshMode() -> DataRefreshMode {
if let raw = loadString(service: dataRefreshModeKey),
let mode = DataRefreshMode(rawValue: raw) {
return mode
}
return .standard
}
static func saveDataRefreshMode(_ mode: DataRefreshMode) {
saveString(mode.rawValue, service: dataRefreshModeKey)
}
static func loadLastRefresh() -> Date? { static func loadLastRefresh() -> Date? {
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) { if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
@@ -754,6 +893,17 @@ struct FuelStore {
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey) saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
} }
static func loadLastSmartProbe() -> Date? {
if let raw = loadString(service: lastSmartProbeKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static func saveLastSmartProbe(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey)
}
// MARK: Relay metadata — shown in Settings → About. Written after each // MARK: Relay metadata — shown in Settings → About. Written after each
// successful full fetch so the About section reflects the live source. // successful full fetch so the About section reflects the live source.
@@ -782,29 +932,171 @@ struct FuelStore {
loadString(service: dataUpdatedKey) 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 /// True when the cached data is fresh enough that a scheduled auto-refresh
/// should be skipped (twice-a-day policy). /// should be skipped (twice-a-day policy).
static var isCacheFresh: Bool { static var isCacheFresh: Bool {
isCacheFresh(now: Date())
}
static func isCacheFresh(now: Date) -> Bool {
guard let last = loadLastRefresh() else { return false } guard let last = loadLastRefresh() else { return false }
return Date().timeIntervalSince(last) < refreshInterval return now.timeIntervalSince(last) < refreshInterval
}
static func isSmartProbeDue(now: Date = Date()) -> Bool {
guard let last = loadLastSmartProbe() else { return true }
return now.timeIntervalSince(last) >= smartProbeInterval
} }
// MARK: Onboarding — the app shows the intro screen on first launch only // MARK: Onboarding — the app shows the intro screen on first launch only
// (a test button in the Alerts tab re-opens it). Stored in the app group // (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST
// so the widget can see it too if ever needed. // (with an app-group mirror) for the same reason as favourites/distance
// unit: free SideStore accounts don't provision the app-group container,
// so an app-group-only flag silently fails to save AND reloads as false,
// making onboarding re-appear on every launch. Keychain survives reinstall
// and is shared with the extension.
static func loadHasCompletedOnboarding() -> Bool { static func loadHasCompletedOnboarding() -> Bool {
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false (loadString(service: onboardingCompletedKey) ?? "0") == "1"
} }
static func saveHasCompletedOnboarding(_ completed: Bool) { static func saveHasCompletedOnboarding(_ completed: Bool) {
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey) saveString(completed ? "1" : "0", service: onboardingCompletedKey)
}
// MARK: Install identity — distinguish a genuinely fresh install (where
// onboarding should replay) from later launches of the same install.
/// Keychain copy of the install id (survives reinstall).
private static let installIDKey = "fuelboard.installID"
/// Local (app-own container) copy — wiped on reinstall, persists across
/// normal launches. Free SideStore accounts have no app-group container,
/// so this app-own defaults domain is the reliable "same install" signal.
private static let localInstallIDKey = "fuelboard.installID.local"
/// True when this is a first-ever install OR the app was just reinstalled
/// (local install id missing/different from the keychain id). Seeds a fresh
/// id into both stores so the next launch within the same install is not a
/// "fresh install" any more.
static func isFreshInstall() -> Bool {
let local = UserDefaults.standard.string(forKey: localInstallIDKey)
let remote = loadString(service: installIDKey)
if let local, let remote, local == remote { return false }
// Fresh / mismatched install (or first launch). Pattern a common id so
// subsequent launches of this install are recognised as the same one.
let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: localInstallIDKey)
saveString(id, service: installIDKey)
return true
}
/// Onboarding should present when it hasn't been completed in this install
/// OR this is a freshly-installed app (so the walkthrough replays on a new
/// install, e.g. after a SideStore reinstall, without re-showing on every
/// ordinary launch). The install check always runs (it seeds the id) rather
/// than short-circuiting, so a brand-new install is recorded before the
/// user ever fills in onboarding.
static func shouldShowOnboarding() -> Bool {
let fresh = isFreshInstall()
return !loadHasCompletedOnboarding() || fresh
}
// 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
/// How far (degrees) a station's stored coordinate may drift from the pin
/// a road distance was actually routed to before that cached value is
/// treated as belonging to a different station. Guards against one data
/// source (live fetch, bundled offline dump, or a corrected pin) serving a
/// road distance that was computed for a different coordinate under the
/// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but
/// catches any real pin change.
static let roadDistancePinToleranceDegrees: Double = 1e-4
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) {
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 / the station's own pin doesn't match the coordinate that was
/// routed.
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
guard let cache = loadRoadDistances(),
let entry = cache.entries[station.id] 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 }
// Pin fingerprint: never serve a routed value for a coordinate we
// didn't actually route to. This is the guard that keeps embedded /
// live / cached station sets from injecting each other's road metres.
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
return entry.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, userLat: userLat, userLng: userLng) {
return meters / 1000.0
}
return station.distanceKM(to: userLat, lng2: userLng)
} }
// MARK: Low-level keychain helpers // MARK: Low-level keychain helpers
private static func keychainData(service: String) -> Data? { private static func keychainData(service: String) -> Data? {
var query: [String: Any] = [ let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service, kSecAttrService as String: service,
kSecReturnData as String: true, kSecReturnData as String: true,
@@ -860,3 +1152,23 @@ struct FuelStore {
loadString(service: "widget.diag.\(intentType)") loadString(service: "widget.diag.\(intentType)")
} }
} }
/// Cached Apple-Maps road distances for nearby stations (see
/// A single cached road distance plus the station pin it was routed to. Keeping
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
/// *different* coordinate under the same ID — the guard that stops embedded /
/// live / cached station sets cross-contaminating the distance display.
struct CachedRoadDistance: Codable {
let meters: Double
let lat: Double
let lng: Double
}
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
/// the routed pin.
struct RoadDistanceCache: Codable {
let sourceLat: Double
let sourceLng: Double
let updatedAt: TimeInterval
let entries: [String: CachedRoadDistance]
}
+7 -3
View File
@@ -96,9 +96,13 @@ struct MirrorFuelProvider: FuelPriceProviding {
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data { if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
data = cached data = cached
} else { } else {
let (fetched, response) = try await URLSession.shared.data( // Bypass the HTTP cache + short timeout: an offline refresh must
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL) // 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 { guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw FuelProviderError.mirrorUnavailable throw FuelProviderError.mirrorUnavailable
} }