Compare commits
@@ -1,86 +1,129 @@
|
|||||||
# FuelBoard App Store Approval Review
|
# FuelBoard App Store Submission Plan
|
||||||
|
|
||||||
Review date: 2026-08-11 · Target: App Store submission readiness.
|
Updated: 2026-08-16 · Target: safe App Store submission.
|
||||||
This is the companion to SECURITY.md — focuses on App Store *review* gate
|
Companion to SECURITY.md (code security) — this file is the *review-gate*
|
||||||
(guideline compliance), not code security.
|
checklist: what must happen, who owns it, and the timeline.
|
||||||
|
|
||||||
## Status: NOT SUBMISSION-READY
|
## Status: SUBMITTABLE after the checklist below
|
||||||
|
|
||||||
Two blockers remain before a real submission (see below). Everything else
|
The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||||
checks out for review.
|
- ~~LAN relay data source~~ → **app fetches from public HTTPS
|
||||||
|
(raw.githubusercontent.com mirror) with a bundled real 8,022-station dump
|
||||||
|
as the no-network fallback.** No private IP in the consumer path. Reviewers
|
||||||
|
on Apple's network see real data.
|
||||||
|
- ~~Always-location risk~~ → **Always is requested only when the user enables
|
||||||
|
Alerts** (ProximityMonitor escalates WhenInUse → Always on alert opt-in).
|
||||||
|
Onboarding requests WhenInUse only.
|
||||||
|
|
||||||
## Blockers
|
## Hard blockers (must happen before ANY submission)
|
||||||
|
|
||||||
### 1. Data source — LAN relay (BLOCKER)
|
1. **[USER] Apple Developer Program membership** — the app has never been
|
||||||
- The app fetches from `http://192.168.1.131:8788` — a private LAN host. Off
|
signed (all builds are `CODE_SIGNING_ALLOWED=NO` + ad-hoc re-sign for
|
||||||
the user's network it fails and falls back to sample data. Reviewers on
|
SideStore). Requires a paid membership ($99/yr, up to 48 h to approve) and
|
||||||
Apple's network will see empty/sample results → 2.1 (performance) rejection,
|
a developer account.
|
||||||
and hardcoded IPs look like test builds.
|
2. **[USER] Register App ID `com.apt.fuelboard`** in the developer portal,
|
||||||
- **Fix**: the govuk API switch (BACKLOG P0). App must talk to a public HTTPS
|
with the **App Group `group.com.apt.fuelboard`** capability AND the
|
||||||
endpoint before submission. The relay (HTTPS domain) or direct gov.uk OAuth
|
**In-App Purchase** capability enabled (the widget + app-group cache
|
||||||
both work; the code path is already stubbed.
|
depend on the group; the tip jar needs IAP).
|
||||||
|
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE 2026-08-16** (app + widget
|
||||||
|
manifests added; UserDefaults `1C8F.1`, Location + Diagnostics declared).
|
||||||
|
4. **[ME] Real Distribution signing** — Distribution certificate + App Store
|
||||||
|
provisioning profile (or Xcode automatic signing once the account is in
|
||||||
|
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
|
||||||
|
5. **[USER] Privacy policy URL** in App Store Connect — mandatory; the app
|
||||||
|
collects location. A one-page GitHub Pages privacy policy is fine.
|
||||||
|
|
||||||
### 2. Always-location justification (BLOCKER without review notes)
|
## Required tasks — owner [ME] (technical, do after account exists)
|
||||||
- `NSLocationAlwaysAndWhenInUseUsageDescription` + `UIBackgroundModes: location`
|
|
||||||
present, but guideline 5.1.1 requires the feature to be obvious AND the app
|
|
||||||
must visibly use background location. The geofenced "cheapest station" alerts
|
|
||||||
satisfy this, but the reviewer must see it:
|
|
||||||
- Provide detailed App Review notes explaining the alert flow (enable Alerts →
|
|
||||||
geofence trigger → notification when approaching the cheapest station).
|
|
||||||
- Ensure the Alerts tab is discoverable and functional in review.
|
|
||||||
- Also: "Always" prompt should only appear after the user opts into alerts
|
|
||||||
(in-progress UX; currently the permission prompt may fire too early).
|
|
||||||
|
|
||||||
## Review-relevant configuration — PASS
|
- [x] **PrivacyInfo.xcprivacy** — app + widget manifests added (2026-08-16):
|
||||||
|
UserDefaults reason `1C8F.1`; app also declares Location (linked, not
|
||||||
|
tracking) + Diagnostics (not linked, not tracking — the dev beacon)
|
||||||
|
- [ ] Distribution archive + TestFlight upload (needs Apple account)
|
||||||
|
- [x] **Version bump** → `1.0.1` (build 2), app + widget plists
|
||||||
|
- [x] **OGL attribution** — Settings → Data source footer now reads "Prices
|
||||||
|
use the UK Fuel Finder dataset (Open Government Licence) and refresh
|
||||||
|
up to twice a day." (2.3.1 hygiene)
|
||||||
|
- [x] **Out-of-UK empty state** — "No X stations within N miles" + UK-only
|
||||||
|
explainer + "Browse all UK stations" button (switches to Closest)
|
||||||
|
- [ ] ~~Gate dev surface behind #if DEBUG~~ **DECLINED — kept in Release on
|
||||||
|
purpose**: the user's home-testing installs the Release IPA and toggles
|
||||||
|
`fuelboard.relayFallback` via Settings → Debug (revealed only by tapping
|
||||||
|
About → Version ×5 — invisible to reviewers; no credentials involved).
|
||||||
|
Gating it out of Release would break that workflow for zero review gain.
|
||||||
|
- [ ] Final pass: `swift test` green (95 tests), archive smoke test on device
|
||||||
|
- [ ] ~~Capture screenshots from simulator~~ **RAW CAPTURES DONE (2026-08-16)** —
|
||||||
|
native 6.9″ 1320×2868 PNGs of all four tabs (Stations, Favourites,
|
||||||
|
Alerts, Settings) at `/tmp/fuelboard-shots/asc/`; assembled into ASC
|
||||||
|
frames by the user in the yuzu-hub App Store Screenshot Generator
|
||||||
|
(https://yuzu-hub.github.io/appscreen/). Screenshot harness: launch
|
||||||
|
args `-skipOnboarding` / `-tab stations|favourites|alerts|settings`
|
||||||
|
(ContentView, same pattern as `-showKeySheet`).
|
||||||
|
|
||||||
- [x] **App icon**: was MISSING entirely (invalid-binary rejection). Fixed —
|
## Required tasks — owner [USER] (App Store Connect, cannot be done by me)
|
||||||
1024×1024 `AppIcon.appiconset` + `ASSETCATALOG_COMPILER_APPICON_NAME`.
|
|
||||||
- [x] **Launch screen**: `UILaunchScreen` empty dict present (iOS 14+ style).
|
|
||||||
- [x] **Device family**: iPhone-only (`TARGETED_DEVICE_FAMILY = 1`) — no iPad
|
|
||||||
multitasking requirements.
|
|
||||||
- [x] **Versioning**: `CFBundleShortVersionString 1.0`, `CFBundleVersion 1`
|
|
||||||
(bump per release).
|
|
||||||
- [x] **Permissions**: only location (WhenInUse + Always), with clear usage
|
|
||||||
strings. No contacts/photos/mic/camera.
|
|
||||||
- [x] **No third-party SDKs** → no SDK privacy manifests or third-party
|
|
||||||
attribution required.
|
|
||||||
- [x] **No account creation / login** → no account-deletion requirement (5.1.1(v)).
|
|
||||||
- [x] **ATS**: no `NSAllowsArbitraryLoads` (raw-IP cleartext is ATS-exempt; once
|
|
||||||
the relay is HTTPS this is moot).
|
|
||||||
- [x] **Export compliance**: no crypto beyond keychain/SecItem → no
|
|
||||||
documentation needed.
|
|
||||||
|
|
||||||
## Content / IP review
|
- [ ] Apple Developer Program membership + App ID (blockers #1–#2)
|
||||||
|
- [ ] **Tip jar: create the three consumable IAPs** in App Store Connect →
|
||||||
|
In-App Purchases (all type **Consumable**, exact product IDs, UK price
|
||||||
|
tier; tip jars are an accepted App Review pattern):
|
||||||
|
- `com.apt.fuelboard.tip099` — **£0.99** — "Splash & Dash" (Just enough to keep things moving.)
|
||||||
|
- `com.apt.fuelboard.tip299` — **£2.99** — "Half a Tank" (A generous top-up for development.)
|
||||||
|
- `com.apt.fuelboard.tip499` — **£4.99** — "Fill 'Er Up" (Keeping the app on the road.)
|
||||||
|
App-side is ready (`41e4c91`): Settings → Support FuelBoard shows all
|
||||||
|
three tiers with per-tier prices; sideloaded builds gracefully show
|
||||||
|
"not available in this build yet" until the products exist.
|
||||||
|
- [ ] App Store Connect app record: name, bundle ID `com.apt.fuelboard`,
|
||||||
|
category (Utilities), pricing (Free), availability (UK first?)
|
||||||
|
- [ ] Description, keywords, support URL, marketing URL, privacy policy URL
|
||||||
|
- [ ] Privacy nutrition labels questionnaire (location: yes, linked to user
|
||||||
|
for alerts/nearby; diagnostics: yes — the widget-diag beacon, not
|
||||||
|
linked, not used for tracking; no ATT prompt needed)
|
||||||
|
- [ ] Age rating questionnaire (4+; no objectionable content)
|
||||||
|
- [ ] Export compliance: HTTPS-only + keychain → standard encryption
|
||||||
|
exemption (5A992a), no documentation needed
|
||||||
|
- [ ] Approve the TestFlight build and do a real-device pass before submit
|
||||||
|
- [ ] Final "Submit for Review" click + stand by to answer review questions
|
||||||
|
|
||||||
- **Brand logos (Shell, BP, Tesco…)**: bundled from Simple Icons (CC0) +
|
## Review notes (copy-paste into App Store Connect)
|
||||||
Wikimedia Commons. Nominative use (identifying stations) is standard practice
|
|
||||||
(cf. GasBuddy). Low risk, but keep the sources documented.
|
|
||||||
- **Price data**: gov.uk Fuel Finder data is OGL-licensed — include attribution
|
|
||||||
in the app (About/credits) once live data ships. Sample fallback data must be
|
|
||||||
clearly demo-labelled (or removed for review) so reviewers don't think the
|
|
||||||
prices are fake — 2.3.1 accuracy risk.
|
|
||||||
- **"Cheapest" claims**: data is 12h-old cache. The UI says "cheapest" — fine,
|
|
||||||
but a small "prices from the UK Fuel Finder dataset, updated twice daily"
|
|
||||||
footnote is good hygiene for 2.3.1.
|
|
||||||
|
|
||||||
## Suggested review notes (copy-paste for App Store Connect)
|
|
||||||
|
|
||||||
> FuelBoard shows the cheapest petrol, diesel, and premium fuel near the user
|
> FuelBoard shows the cheapest petrol, diesel, and premium fuel near the user
|
||||||
> using the UK government Fuel Finder dataset (OGL). Prices are fetched up to
|
> using the UK government Fuel Finder dataset (Open Government Licence).
|
||||||
> twice a day; the app filters 5/10/15-mile radii on-device. Location is used
|
> Prices are downloaded from the internet up to twice a day; the app filters
|
||||||
> to (a) rank stations by distance and (b) power the optional Alerts feature:
|
> 5/10/15-mile radii on-device and works offline from a bundled snapshot.
|
||||||
> when enabled, the app geofences the cheapest station in the user's chosen
|
> Location is used to (a) rank stations by distance and (b) power the
|
||||||
> radius and sends a notification when they approach it. Always-location is
|
> optional Alerts feature: when the user enables alerts, the app geofences
|
||||||
> only requested when the user enables alerts. The app also provides a
|
> the cheapest station in their radius and sends a notification when they
|
||||||
> home-screen widget showing the current cheapest station nearby.
|
> approach it. Always-location is requested only when the user enables
|
||||||
|
> alerts. The app also provides a home-screen widget showing the current
|
||||||
|
> cheapest station nearby, and a price-history Trends chart for starred
|
||||||
|
> stations. All data is UK-wide (Fuel Finder covers England, Scotland, Wales
|
||||||
|
> and Northern Ireland); users outside the UK see an empty state explaining
|
||||||
|
> this.
|
||||||
|
|
||||||
## Release checklist (before every submission)
|
## Review risks (know before submitting)
|
||||||
|
|
||||||
1. [ ] Relay/govuk data source live on a public HTTPS endpoint
|
- **Always-location (5.1.1)** — the #1 question. The feature is visible
|
||||||
2. [ ] Always-permission flow tied to explicit user opt-in
|
(Alerts tab) and opt-in; the review notes above say so explicitly.
|
||||||
3. [ ] Bump `CFBundleShortVersionString` / `CFBundleVersion`
|
- **Reviewer outside the UK** — empty radius; mitigated by the empty-state
|
||||||
4. [ ] Sample-data fallback labelled or removed in the release build
|
task above + review notes.
|
||||||
5. [ ] OGL attribution present in-app
|
- **"Cheapest" claims (2.3.1)** — mitigated by the OGL/footnote task.
|
||||||
6. [ ] `swift test` green (FuelBoardTests, 28 tests)
|
- **Live Activities** — reviewers may ask why; answer: price alerts at a
|
||||||
7. [ ] Security audit pass (see SECURITY.md recurring checks)
|
glance. It is declared in the plist already.
|
||||||
8. [ ] Icon + screenshots for all required sizes in App Store Connect
|
- **Brand logos (Shell, BP, Tesco…)** — Simple Icons (CC0) + Wikimedia,
|
||||||
|
nominative use (cf. GasBuddy); low risk, keep sources documented.
|
||||||
|
- **GitHub raw as data source** — public data, no credentials; bundled dump
|
||||||
|
covers any regional GitHub unreachability.
|
||||||
|
|
||||||
|
## Timeline (best case)
|
||||||
|
|
||||||
|
| Day | Owner | Milestone |
|
||||||
|
|-----|-------|-----------|
|
||||||
|
| 0–2 | USER | Developer Program enrollment + App ID + app group capability |
|
||||||
|
| 2–3 | ME | Signing (automatic), PrivacyInfo.xcprivacy, attribution, empty state, version bump |
|
||||||
|
| 3–4 | ME | Archive → TestFlight; screenshots captured |
|
||||||
|
| 4–5 | USER | TestFlight install + real-device pass (onboarding, alerts, widget, offline) |
|
||||||
|
| 5–6 | USER | App Store Connect metadata + labels + age rating + privacy URL |
|
||||||
|
| 6–7 | USER/ME | Submit for review |
|
||||||
|
| 7–14 | Apple | Review (typically 1–3 days, can be a week); answer questions same-day |
|
||||||
|
|
||||||
|
Rollback note: nothing about submission changes the repo's rollback posture
|
||||||
|
(`main` history is intact; release builds are tagged per submission).
|
||||||
|
|||||||
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 841 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 815 KiB |
@@ -1,58 +1,49 @@
|
|||||||
# FuelBoard Backlog
|
# FuelBoard Backlog
|
||||||
|
|
||||||
FuelBoard — England-wide petrol-station price widget + app.
|
FuelBoard — UK-wide petrol-station price widget + app.
|
||||||
Relay mirrors the UK gov data (hourly CSV); app fetches the full-UK dump
|
GitHub mirror (`aptonline/fuelboard-data`, 2×/day 10:00+16:00) holds the
|
||||||
(~8,038 stations, all prices) once per 12 h and filters/sorts on-device.
|
full-UK dump (~8,022 stations, all prices); app fetches it once per 12 h and
|
||||||
|
filters/sorts on-device, with a bundled snapshot as the no-network last resort.
|
||||||
|
LAN relay is dev-only (hidden `fuelboard.relayFallback` flag).
|
||||||
|
|
||||||
Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea.
|
Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea.
|
||||||
Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## P0 — Next build
|
|
||||||
|
|
||||||
- [ ] **GitHub price-history mirror (warm the data for Trends)** — the relay does
|
|
||||||
the GOV.UK heavy lifting (OAuth token stays in relay `.env`) and pushes a daily
|
|
||||||
snapshot to a PUBLIC repo; the app reads from GitHub, so this doubles as the
|
|
||||||
production data source (App Review can't reach the LAN relay) AND the history
|
|
||||||
archive the Trends graph (P1) reads. Shape: relay writes `latest.json` (tiny
|
|
||||||
pointer: date, station_count, data_updated, available range) +
|
|
||||||
`history/YYYY-MM-DD.json` (FULL dump — 2.58 MB/day, decision Option 2) and
|
|
||||||
pushes once/day; the app fetches
|
|
||||||
`raw.githubusercontent.com/aptonline/fuelboard-data/main/latest.json`, then only
|
|
||||||
the missing day files for starred (station, fuel) pairs (deterministic URLs —
|
|
||||||
404 = no data that day, leave a graph gap, never error). LAN relay stays the
|
|
||||||
fallback source. Telemetry implication: raw GitHub fetches are invisible to repo
|
|
||||||
analytics (page views/clones only) — keep the widget-diag beacon firing
|
|
||||||
regardless of data source (build/stale-build checks survive) and add an
|
|
||||||
opportunistic fire-and-forget X-Client app beacon on fetch (LAN = attribution +
|
|
||||||
cadence; off-LAN skip is itself a reachability datum). `/stats` keeps the
|
|
||||||
intrusion canary and gains a new signal: app-hit spikes = GitHub path failing →
|
|
||||||
fallback active. App Review: public repo required (reviewer fetches the data
|
|
||||||
themselves); HTTPS/ATS fine; plain JSON is not "remote code" (2.5.2-safe); bundle
|
|
||||||
a small fallback dump so the app never demos empty. Retention: 180 days (~470 MB,
|
|
||||||
pruning enforced by the push script — full dump, NOT the earlier ~200 KB
|
|
||||||
price-only estimate). *PUSH SIDE DONE 2026-08-15 — repo live
|
|
||||||
(https://github.com/aptonline/fuelboard-data); auth = SSH deploy key
|
|
||||||
~/.ssh/fuelboard_deploy via alias github-fuelboard-data (no token anywhere);
|
|
||||||
clone ~/workspace/fuelboard-data; launchd com.apt.fuelboard-mirror daily 10:00
|
|
||||||
(script ~/.hermes/scripts/mirror_push.py, log
|
|
||||||
~/Library/Logs/fuelboard-mirror.log); manual trigger `launchctl start
|
|
||||||
com.apt.fuelboard-mirror`; first snapshot 2026-08-15 landed + verified raw 200.
|
|
||||||
REMAINING: live provider chain (GitHub → relay → bundled dump for CURRENT
|
|
||||||
prices) + the telemetry beacon — NOTE: the HISTORY read path shipped with P1
|
|
||||||
(FuelHistoryStore: latest.json + day files for starred stations, favourites-only
|
|
||||||
app-group cache, 404 = gap never error, 90-day prune).*
|
|
||||||
|
|
||||||
## P1 — Soon
|
## P1 — Soon
|
||||||
|
|
||||||
|
- [x] **Move relay + mirror off the Mac mini (DONE 2026-08-16)** — dedicated
|
||||||
|
Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh`
|
||||||
|
(CT **111**, hostname **`fuelboard-relay`**, 1 core/512 MB/2 GB, static IP
|
||||||
|
**192.168.1.113**, plain LXC — NO Docker layer). Scope: relay AND mirror
|
||||||
|
both moved (full Mac independence; relays bind **loopback only**, GitHub is
|
||||||
|
the only public surface). What landed: Python 3.13.5 (trixie) boots both
|
||||||
|
relays (relay 8788 demo/CSV + relay-api 8789 `source: api`, 8,024 stations,
|
||||||
|
~2 min first sync, 0 failures); source at `/root/workspace/fuelboard-{relay,relay-api}`
|
||||||
|
(venvs + pip, `.env` chmod 600); deploy key `fuelboard_deploy` +
|
||||||
|
`github-fuelboard-data` ssh config + `fuelboard-data` clone at
|
||||||
|
`/root/workspace/fuelboard-data` (origin = ssh alias, green + synced);
|
||||||
|
`mirror_push.py` → `/root/scripts/mirror_push.py` (RELAY line already
|
||||||
|
`127.0.0.1:8789`); systemd `fuelboard-relay.service` +
|
||||||
|
`fuelboard-relay-api.service` (Restart=always, `--host 127.0.0.1`) +
|
||||||
|
`fuelboard-mirror.service` (oneshot) + `.timer` (10:00 + 16:00, Persistent,
|
||||||
|
armed — next Mon 10:00); first live push VERIFIED from outside
|
||||||
|
(commit `735a292`, latest.json `data_updated 16:50:53Z`); Mac launchd
|
||||||
|
`com.apt.fuelboard-{relay,relay-api,mirror}` unloaded + plists parked in
|
||||||
|
`~/Library/LaunchAgents/fuelboard-migrated-to-ct/`; editor 8790 + IPA 8765
|
||||||
|
stay on the Mac; price watchdog `relay_price_watchdog.py` now fetches via
|
||||||
|
SSH (`~/.ssh/fuelboard_ct` → `/root/scripts/watchdog_fetch.py`). Logs via
|
||||||
|
`journalctl -u fuelboard-*` on the CT; SSH into CT: `ssh -i ~/.ssh/fuelboard_ct root@192.168.1.113`.
|
||||||
|
**Open: rootfs is 2 GB (1.1 GB free) — mirror retention ~940 MB will get
|
||||||
|
tight; rootfs grown to 8 GB on pve 2026-08-16 (5.9 GB free).**
|
||||||
|
|
||||||
- [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
|
- [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
|
||||||
with freshness label ("as of 6am"), last-known-location fallback. Constraint:
|
with freshness label ("as of 6am"), last-known-location fallback. Constraint:
|
||||||
fresh-data path needs a reachable source off-LAN — cached answers work anywhere
|
fresh-data path needs a reachable source off-LAN — cached answers work anywhere
|
||||||
the app has previously fetched (see P3 data mirror). Interim: the relay on
|
the app has previously fetched (the GitHub mirror serves this now). *DONE —
|
||||||
Tailscale (100.120.217.98:8789) is reachable off-LAN for testing; relay will
|
radius-scoped
|
||||||
move to a permanent box or the GitHub mirror long-term. *DONE — radius-scoped
|
|
||||||
lookup `037c241` (real-data regression: GULF HISTON 100.9p 132 mi away),
|
lookup `037c241` (real-data regression: GULF HISTON 100.9p 132 mi away),
|
||||||
per-fuel literal-word shortcuts `e55078c`, names = app words `a3b6f37`, plus
|
per-fuel literal-word shortcuts `e55078c`, names = app words `a3b6f37`, plus
|
||||||
Directions-to-cheapest `caadf18` + spoken hand-off `aed99e3`.*
|
Directions-to-cheapest `caadf18` + spoken hand-off `aed99e3`.*
|
||||||
@@ -66,7 +57,7 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
capsule fuel picker; range control 7/30/90 days; respects the price-display
|
capsule fuel picker; range control 7/30/90 days; respects the price-display
|
||||||
toggle; honest empty-state copy in Localizable.strings ("Prices are recorded from
|
toggle; honest empty-state copy in Localizable.strings ("Prices are recorded from
|
||||||
each refresh — check back in a few days"). No widget in v1 (one-kind rule).
|
each refresh — check back in a few days"). No widget in v1 (one-kind rule).
|
||||||
Depends on the P0 archive. *DONE 2026-08-15 on feature/trends-history (unmerged):
|
Depends on the P0 archive. *DONE 2026-08-15, merged to main 2026-08-16:
|
||||||
`Shared/FuelHistory.swift` (FuelHistoryStore: day math, slim day decode w/ shared
|
`Shared/FuelHistory.swift` (FuelHistoryStore: day math, slim day decode w/ shared
|
||||||
band, series + deltaSeries, app-group favourites-only cache, parallel per-day
|
band, series + deltaSeries, app-group favourites-only cache, parallel per-day
|
||||||
fetches) + `FuelBoard/TrendsView.swift` (chart, fuel capsule, 7/30/90, Price/vs
|
fetches) + `FuelBoard/TrendsView.swift` (chart, fuel capsule, 7/30/90, Price/vs
|
||||||
@@ -74,8 +65,8 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
FavouritesView + strings. 83 tests incl. URL regression (appendingPathComponent
|
FavouritesView + strings. 83 tests incl. URL regression (appendingPathComponent
|
||||||
— URL(string:relativeTo:) dropped /main, all fetches 404'd). Sim-verified:
|
— URL(string:relativeTo:) dropped /main, all fetches 404'd). Sim-verified:
|
||||||
favourites rows + TOP/deltas; sheet controls + honest empty state w/ live first-
|
favourites rows + TOP/deltas; sheet controls + honest empty state w/ live first-
|
||||||
snapshot date. Pending: merge to main after device sideload test; lines render
|
snapshot date. Pending: lines render once ≥2 snapshots (archive warming daily,
|
||||||
once ≥2 snapshots (archive warming daily).*
|
~Aug 17).*
|
||||||
|
|
||||||
## P2 — Later
|
## P2 — Later
|
||||||
|
|
||||||
@@ -115,10 +106,24 @@ 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).
|
||||||
|
- [ ] **Price-drop alerts: optional frequent-check mode** — if the app ever
|
||||||
|
relaxes the current "twice daily max" data policy, expose this as a clearly
|
||||||
|
separate mode rather than a freeform frequency slider. Example framing:
|
||||||
|
`Standard` (current behaviour) vs `Frequent` (best-effort extra checks),
|
||||||
|
with conservative caps (e.g. every 4–6 h, not hourly) and copy that does not
|
||||||
|
promise exact timing under iOS background scheduling.
|
||||||
- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
|
- [ ] **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).
|
||||||
- [ ] **Offline first-run** — first launch without network currently shows empty
|
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
|
||||||
states; bundle a sample dump so the app demos offline.
|
refreshed by `scripts/refresh_bundled_dump.sh`) is the no-network last resort
|
||||||
|
in the live chain (P0, 2026-08-16); first launch demos the full dataset offline.
|
||||||
- [ ] **Localization** — UK English only today; audit string catalog for l10n.
|
- [ ] **Localization** — UK English only today; audit string catalog for l10n.
|
||||||
- [ ] **Dark mode polish** — verify brand logos (white-circle clips) and RAG dot
|
- [ ] **Dark mode polish** — verify brand logos (white-circle clips) and RAG dot
|
||||||
contrast under dark appearance on-device.
|
contrast under dark appearance on-device.
|
||||||
@@ -139,13 +144,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
|
||||||
@@ -164,6 +206,20 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
|
|
||||||
## Done (recent)
|
## Done (recent)
|
||||||
|
|
||||||
|
- [x] **GitHub price-history mirror + LIVE provider chain (P0, DONE 2026-08-16)** —
|
||||||
|
push side (relay → public `aptonline/fuelboard-data`, daily 10:00 launchd, SSH
|
||||||
|
deploy key, 180-day retention) + history read side (Trends, P1) + **live chain
|
||||||
|
(`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from
|
||||||
|
raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) →
|
||||||
|
**GitHub FIRST everywhere** (alert checks + widget too — the relay is
|
||||||
|
unreachable from any consumer phone and attempting it would fire the Local
|
||||||
|
Network prompt); LAN relay joins ONLY behind the hidden dev flag
|
||||||
|
`fuelboard.relayFallback` (Settings → Debug, off by default); bundled REAL
|
||||||
|
dump (`FuelBoardDump` dataset, refreshed by `scripts/refresh_bundled_dump.sh`)
|
||||||
|
is the app's no-network last resort. `FuelBeacon` fire-and-forget X-Client app
|
||||||
|
ping fires only with the dev flag on. **Local Network permission removed from
|
||||||
|
onboarding entirely** — data page is informational, replay on cellular can no
|
||||||
|
longer hang. 95 tests.
|
||||||
- [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via
|
- [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via
|
||||||
relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials
|
relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials
|
||||||
live in relay `.env` only, never in the IPA
|
live in relay `.env` only, never in the IPA
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0.1</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -27,18 +29,16 @@
|
|||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>1.0</string>
|
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>2</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
|
<string>FuelBoard can optionally connect to the FuelBoard Relay on your local network for faster alerts during development.</string>
|
||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
|
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>FuelBoard connects to the FuelBoard Relay on your local network to download the latest fuel prices.</string>
|
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
@@ -17,15 +17,15 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>1.0.1</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>2</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
|
||||||
<string>FuelBoard uses your location to show the cheapest nearby petrol stations.</string>
|
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
<string>com.apple.widgetkit-extension</string>
|
<string>com.apple.widgetkit-extension</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>FuelBoard uses your location to show the cheapest nearby petrol stations.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 747 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 747 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIconPreview.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 208 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIconPreviewDark.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"data" : [
|
||||||
|
{
|
||||||
|
"filename" : "fuelboard_dump.json",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "PreviewWallpaper.jpg",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 177 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "brand_circle_k_2x.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "brand_circle_k_3x.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 636 B |
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 6.9 KiB |
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
// BundledDumpProvider.swift — no-network last resort (P0 live chain).
|
||||||
|
//
|
||||||
|
// A REAL snapshot of the mirror dump is bundled into the app (FuelBoardDump
|
||||||
|
// dataset, refreshed before builds by scripts/refresh_bundled_dump.sh) so the
|
||||||
|
// app never demos empty and App Review always sees real data even fully
|
||||||
|
// offline. Same relay envelope, so the shared decode applies unchanged.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum BundledDumpProvider {
|
||||||
|
/// The bundled real snapshot, decoded with the shared relay decode —
|
||||||
|
/// nil only if the asset is missing or corrupt (callers then fall back
|
||||||
|
/// to the England-wide sample set).
|
||||||
|
static var stations: [FuelStation]? {
|
||||||
|
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
|
||||||
|
return try? FuelPriceProvider.decodeStations(from: asset.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The GOV.UK `data_updated` stamp from the bundled envelope (ISO 8601).
|
||||||
|
/// Labels the offline-data banner honestly ("Offline data from 15 Aug")
|
||||||
|
/// instead of presenting the build's snapshot as live.
|
||||||
|
static var dataUpdatedStamp: String? {
|
||||||
|
guard let asset = NSDataAsset(name: "FuelBoardDump"),
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: asset.data)
|
||||||
|
as? [String: Any] else { return nil }
|
||||||
|
return obj["data_updated"] as? String
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,15 @@ 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: Equatable {
|
||||||
|
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
|
||||||
|
|
||||||
@@ -15,6 +24,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,11 +41,48 @@ 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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()
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -137,66 +185,140 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
configuredContent
|
||||||
stationsTab
|
}
|
||||||
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
|
||||||
|
|
||||||
favouritesTab
|
private var configuredContent: AnyView {
|
||||||
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
interactionObservedContent
|
||||||
|
}
|
||||||
|
|
||||||
AlertsView(
|
private var presentedContent: AnyView {
|
||||||
enabled: $alertsEnabled,
|
AnyView(
|
||||||
radius: $alertsRadius,
|
mainContent
|
||||||
fuel: $alertsFuel,
|
.fullScreenCover(isPresented: $showOnboarding) {
|
||||||
followsSearch: $alertsFollowsSearch,
|
OnboardingView {
|
||||||
liveActivityEnabled: $liveActivityEnabled,
|
showOnboarding = false
|
||||||
liveActivityFuel: $liveActivityFuel,
|
}
|
||||||
liveActivityRadiusMiles: $liveActivityRadiusMiles,
|
}
|
||||||
liveActivityFollowsSearch: $liveActivityFollowsSearch,
|
.fullScreenCover(isPresented: $showWidgetMock) {
|
||||||
stationLimit: stationLimit,
|
WidgetMockScreen()
|
||||||
distanceUnit: distanceUnit,
|
}
|
||||||
monitoredCount: monitor.monitoredStationIDs.count,
|
.sheet(item: $monitor.pendingStationMap) { request in
|
||||||
lastAlert: monitor.lastAlert
|
StationMapView(request: request)
|
||||||
)
|
}
|
||||||
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
)
|
||||||
|
}
|
||||||
|
|
||||||
settingsTab
|
private var lifecycleObservedContent: AnyView {
|
||||||
}
|
AnyView(
|
||||||
.fullScreenCover(isPresented: $showOnboarding) {
|
presentedContent
|
||||||
OnboardingView {
|
.onAppear {
|
||||||
showOnboarding = false
|
// Launch-arg hooks for the screenshot/UI-test harness (same
|
||||||
|
// pattern as StationsView's `-showKeySheet`): `-tab <name>`
|
||||||
|
// opens the given tab; `-skipOnboarding` skips onboarding
|
||||||
|
// without touching the stored flag.
|
||||||
|
let args = ProcessInfo.processInfo.arguments
|
||||||
|
#if DEBUG
|
||||||
|
showWidgetMock = args.contains("-widgets")
|
||||||
|
// `-seedFavourite <substring>` pins the first matching station
|
||||||
|
// for Unleaded via the normal save path (keychain + app group)
|
||||||
|
// so screenshot captures can show a populated Favourites tab.
|
||||||
|
// Repeatable: each occurrence adds another favourite (QA temp).
|
||||||
|
// Optional ":fuel" suffix (e10|e5|diesel) picks the
|
||||||
|
// fuel, so the fuel-aware Trends open can be verified (QA temp).
|
||||||
|
let seedQueries = args.enumerated()
|
||||||
|
.filter { $0.element == "-seedFavourite" }
|
||||||
|
.compactMap { i, _ in
|
||||||
|
i + 1 < args.count ? args[i + 1] : nil
|
||||||
|
}
|
||||||
|
if !seedQueries.isEmpty {
|
||||||
|
let stations = FuelStore.loadStations()
|
||||||
|
var favs = seedQueries.compactMap { query in
|
||||||
|
let parts = query.split(separator: ":", maxSplits: 1)
|
||||||
|
let name = String(parts[0])
|
||||||
|
let fuel = parts.count > 1
|
||||||
|
? FuelType(rawValue: String(parts[1])) ?? .e10
|
||||||
|
: FuelType.e10
|
||||||
|
return stations.first { $0.name.localizedCaseInsensitiveContains(name) }
|
||||||
|
.map { FavouriteEntry(station: $0, fuel: fuel) }
|
||||||
|
}
|
||||||
|
if favs.isEmpty, let q = seedQueries.first,
|
||||||
|
let s = stations.first(where: { $0.name.localizedCaseInsensitiveContains(q.split(separator: ":").first.map(String.init) ?? q) }) {
|
||||||
|
favs = [FavouriteEntry(station: s, fuel: .e10)]
|
||||||
|
}
|
||||||
|
if !favs.isEmpty {
|
||||||
|
FuelStore.saveFavourites(favs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
||||||
.sheet(item: $monitor.pendingStationMap) { request in
|
switch args[i + 1] {
|
||||||
StationMapView(request: request)
|
case "favourites": selectedTab = 1
|
||||||
}
|
case "alerts": selectedTab = 2
|
||||||
.onAppear {
|
case "settings": selectedTab = 3
|
||||||
|
default: selectedTab = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// QA temp: `-fuel <e10|e5|diesel>` sets the app-wide selected
|
||||||
|
// fuel so the fuel-aware Trends open can be verified (simulates
|
||||||
|
// the user having switched to the diesel tab).
|
||||||
|
if let i = args.firstIndex(of: "-fuel"), i + 1 < args.count,
|
||||||
|
let f = FuelType(rawValue: args[i + 1]) {
|
||||||
|
selectedFuel = f
|
||||||
|
FuelStore.saveSelectedFuel(f)
|
||||||
|
}
|
||||||
|
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
|
||||||
|
// failure legs for the screenshot harness. The auto-refresh below
|
||||||
|
// is skipped so the banner stays up (a live fetch would clear it).
|
||||||
|
if args.contains("-forceOfflineDump") {
|
||||||
|
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
|
||||||
|
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
|
||||||
|
}
|
||||||
|
if args.contains("-forceConnectionProblem") {
|
||||||
|
stations = FuelStore.loadStations().isEmpty
|
||||||
|
? (BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations)
|
||||||
|
: FuelStore.loadStations()
|
||||||
|
dataStatus = .connectionProblem
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
showWidgetMock = false
|
||||||
|
#endif
|
||||||
// Onboarding runs first on a fresh install — it owns the initial
|
// 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
|
||||||
// network fetch start once it's done.
|
// network fetch start once it's done.
|
||||||
if FuelStore.loadHasCompletedOnboarding() {
|
// Launch-arg hook (UI-testing/screenshot harness, same pattern as
|
||||||
|
// StationsView's `-showKeySheet`): skip onboarding without
|
||||||
|
// touching the stored flag.
|
||||||
|
#if DEBUG
|
||||||
|
let shouldSkipOnboarding = args.contains("-skipOnboarding")
|
||||||
|
#else
|
||||||
|
let shouldSkipOnboarding = false
|
||||||
|
#endif
|
||||||
|
if FuelStore.loadHasCompletedOnboarding()
|
||||||
|
|| shouldSkipOnboarding {
|
||||||
locationManager.startForegroundTracking()
|
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()
|
||||||
// Refresh only when the cache is stale (twice-a-day policy).
|
// Refresh only when the cache is stale (twice-a-day policy).
|
||||||
Task { await refresh() }
|
// Skipped under the force-* hooks so the banner stays up.
|
||||||
|
#if DEBUG
|
||||||
|
let shouldSkipInitialRefresh = args.contains("-forceOfflineDump")
|
||||||
|
|| args.contains("-forceConnectionProblem")
|
||||||
|
|| args.contains("-forceHistoryFailure")
|
||||||
|
#else
|
||||||
|
let shouldSkipInitialRefresh = false
|
||||||
|
#endif
|
||||||
|
if !shouldSkipInitialRefresh {
|
||||||
|
Task { await refresh() }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showOnboarding = true
|
showOnboarding = true
|
||||||
}
|
}
|
||||||
@@ -211,6 +333,8 @@ 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()
|
||||||
|
ensureBackgroundMotionTrackingIfNeeded()
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
@@ -245,7 +369,13 @@ struct ContentView: View {
|
|||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedFuel) { _, _ in
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var interactionObservedContent: AnyView {
|
||||||
|
AnyView(
|
||||||
|
lifecycleObservedContent
|
||||||
|
.onChange(of: selectedFuel) { _, _ in
|
||||||
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
|
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
@@ -268,9 +398,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)
|
||||||
@@ -291,10 +419,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
|
||||||
@@ -330,6 +465,152 @@ 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 = activeBanner {
|
||||||
|
// 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.
|
||||||
|
floatingBanner(banner)
|
||||||
|
.padding(.top, geo.safeAreaInsets.top + 10)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .move(edge: .top).combined(with: .opacity),
|
||||||
|
// Slide up + fade on dismiss/timeout (one style for all).
|
||||||
|
removal: .move(edge: .top).combined(with: .opacity)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: activeBanner)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -383,7 +664,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) }
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +698,7 @@ 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,
|
||||||
alertsFuel: alertsFuel,
|
alertsFuel: alertsFuel,
|
||||||
@@ -446,6 +754,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,
|
||||||
@@ -454,9 +763,10 @@ struct ContentView: View {
|
|||||||
stations = fetched
|
stations = fetched
|
||||||
FuelStore.saveStations(fetched)
|
FuelStore.saveStations(fetched)
|
||||||
FuelStore.saveLastRefresh()
|
FuelStore.saveLastRefresh()
|
||||||
// Persist relay envelope metadata (source, station count, GOV.UK
|
// Persist envelope metadata (source, station count, GOV.UK
|
||||||
// dataset update time) for the Settings → About section.
|
// dataset update time) for the Settings → About section — the
|
||||||
if let meta = RelayFuelProvider.latestMeta {
|
// live chain records whichever leg served the fetch.
|
||||||
|
if let meta = LiveChainProvider.latestMeta {
|
||||||
FuelStore.saveRelayMeta(meta)
|
FuelStore.saveRelayMeta(meta)
|
||||||
}
|
}
|
||||||
// Keep the keychain favourites fresh with the new prices — the
|
// Keep the keychain favourites fresh with the new prices — the
|
||||||
@@ -464,11 +774,35 @@ 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/sample data."
|
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
||||||
stations = FuelStore.loadStations().isEmpty ? SampleFuelProvider.sampleStations : FuelStore.loadStations()
|
if FuelStore.loadStations().isEmpty {
|
||||||
|
// No cached prices — last resort is the bundled REAL dump
|
||||||
|
// (stale but genuine), then the demo sample set. The banner
|
||||||
|
// labels the bundled snapshot honestly with its own date.
|
||||||
|
if let dump = BundledDumpProvider.stations {
|
||||||
|
stations = dump
|
||||||
|
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
|
||||||
|
} else {
|
||||||
|
stations = SampleFuelProvider.sampleStations
|
||||||
|
dataStatus = .live
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Saved prices are still on screen — but the fetch failed, so
|
||||||
|
// say so: a stale cache must not look like a live app.
|
||||||
|
stations = FuelStore.loadStations()
|
||||||
|
dataStatus = .connectionProblem
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Keep monitor geofences in sync with the freshest data.
|
// Keep monitor geofences in sync with the freshest data.
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
@@ -520,14 +854,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
|
||||||
|
|||||||
@@ -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,40 +47,30 @@ struct OnboardingView: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
}
|
}
|
||||||
// Auto-advance once the user grants a permission — the prompt itself
|
// Auto-advance once the user grants a permission.
|
||||||
// only fires when the Continue button is tapped (after reading the
|
|
||||||
// page's description), never when the page merely appears.
|
|
||||||
.onChange(of: prompter.locationGranted) { _, granted in
|
.onChange(of: prompter.locationGranted) { _, granted in
|
||||||
if granted, page == 1 { page = 2 }
|
if granted, page == 1 { page = 2 }
|
||||||
}
|
}
|
||||||
.onChange(of: prompter.notificationsGranted) { _, granted in
|
.onChange(of: prompter.notificationsGranted) { _, granted in
|
||||||
if granted, page == 2 { page = 3 }
|
if granted, page == 2 { page = 3 }
|
||||||
}
|
}
|
||||||
.onChange(of: prompter.dataGranted) { _, granted in
|
// Fire each permission when the user LEAVES its page by advancing
|
||||||
if granted, page == 3 {
|
// forward (swipe or Continue) — by then the screen has been read.
|
||||||
// The probe result lands while the Local Network prompt is
|
// Never on arrival: a fast swipe can't stack prompts before a page
|
||||||
// still dismissing, so without a beat the green "Connected"
|
// is seen. Forward-only: swiping back never fires. Idempotent —
|
||||||
// tick is skipped and the page jumps straight to Done —
|
// an already-determined permission just refreshes, so the
|
||||||
// unlike the Location/Notifications pages where the granted
|
// auto-advance path and replays stay clean (no duplicate alerts).
|
||||||
// tick is visible. Hold the tick on screen, then advance.
|
.onChange(of: page) { oldPage, newPage in
|
||||||
Task { @MainActor in
|
guard newPage > oldPage else { return }
|
||||||
try? await Task.sleep(nanoseconds: 800_000_000)
|
if oldPage == 1 {
|
||||||
if page == 3 { page = 4 }
|
prompter.requestLocation()
|
||||||
}
|
} else if oldPage == 2 {
|
||||||
}
|
prompter.requestNotifications()
|
||||||
}
|
|
||||||
// Replay path (test button / returning user): the Local Network
|
|
||||||
// permission is already decided, so probe silently up front. The
|
|
||||||
// probe fires no prompt once the permission is granted or denied —
|
|
||||||
// only the FIRST attempt (undetermined) shows the system alert, and
|
|
||||||
// that only happens on first run. By the time the data page appears
|
|
||||||
// its status is known, so it shows the green tick (or denied text)
|
|
||||||
// immediately, matching the Location and Notifications pages.
|
|
||||||
.onAppear {
|
|
||||||
if FuelStore.loadHasCompletedOnboarding() {
|
|
||||||
prompter.preflightDataAccess()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// No data-permission step: prices download from the internet (GitHub
|
||||||
|
// mirror) with no permission at all — Local Network was removed from
|
||||||
|
// the app when the relay left the consumer chain.
|
||||||
.background(
|
.background(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
|
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
|
||||||
@@ -95,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.
|
||||||
@@ -118,7 +108,7 @@ struct OnboardingView: View {
|
|||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
featureRow(icon: "globe.europe.africa.fill", text: "England-wide prices — 8,000+ stations, updated twice a day")
|
featureRow(icon: "globe.europe.africa.fill", text: "UK-wide prices — 8,000+ stations, updated twice a day")
|
||||||
featureRow(icon: "scope", text: "Cheapest within your chosen radius, or closest station first")
|
featureRow(icon: "scope", text: "Cheapest within your chosen radius, or closest station first")
|
||||||
featureRow(icon: "star.fill", text: "Favourites with instant price comparison")
|
featureRow(icon: "star.fill", text: "Favourites with instant price comparison")
|
||||||
featureRow(icon: "chart.bar.fill", text: "Green/amber/red rating vs the best nearby price")
|
featureRow(icon: "chart.bar.fill", text: "Green/amber/red rating vs the best nearby price")
|
||||||
@@ -132,8 +122,7 @@ struct OnboardingView: View {
|
|||||||
private var locationPage: some View {
|
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)
|
||||||
@@ -164,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)
|
||||||
@@ -196,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)
|
||||||
@@ -207,47 +194,17 @@ struct OnboardingView: View {
|
|||||||
Text("Prices, ready when you are")
|
Text("Prices, ready when you are")
|
||||||
.font(.largeTitle.bold())
|
.font(.largeTitle.bold())
|
||||||
|
|
||||||
Text("FuelBoard downloads the latest prices from a relay on your local network — the full UK dataset, refreshed twice a day. Local network access is needed for that first download.")
|
Text("FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
dataStatusLabel
|
|
||||||
.padding(.top, 24)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var dataStatusLabel: some View {
|
|
||||||
if prompter.dataLoading {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
ProgressView()
|
|
||||||
Text("Waiting for network permission…")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} else if prompter.dataGranted {
|
|
||||||
// Same confirmation styling as the Location/Notifications pages.
|
|
||||||
Label("Connected", systemImage: "checkmark.circle.fill")
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
.font(.subheadline)
|
|
||||||
} else if prompter.dataDenied {
|
|
||||||
Text("Local network access was denied — prices won't load until it's allowed, but you can keep using FuelBoard.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
} else {
|
|
||||||
Text("The system prompt will appear next.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var donePage: some View {
|
private var donePage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -288,39 +245,31 @@ struct OnboardingView: View {
|
|||||||
) {
|
) {
|
||||||
if prompter.locationDenied {
|
if prompter.locationDenied {
|
||||||
openSettings()
|
openSettings()
|
||||||
} else if !prompter.locationGranted {
|
} else if prompter.locationGranted {
|
||||||
// The system prompt fires HERE — after the user has read
|
page = 2 // already decided — just move on
|
||||||
// the description and tapped Continue — not on page appear.
|
|
||||||
prompter.requestLocation()
|
|
||||||
} else {
|
} else {
|
||||||
page = 2
|
// Fire the system prompt NOW, while this page is still on
|
||||||
|
// screen (after it's been read) and BEFORE advancing — so
|
||||||
|
// the prompt never covers the next page's animation. Auto-
|
||||||
|
// advance on grant moves us on once the user responds;
|
||||||
|
// denying leaves the page's "Open Settings" path.
|
||||||
|
prompter.requestLocation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
primaryButton(
|
primaryButton(
|
||||||
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
||||||
) {
|
) {
|
||||||
if prompter.notificationsDenied {
|
if prompter.notificationsDenied || prompter.notificationsGranted {
|
||||||
page = 3
|
page = 3 // decided either way — move on
|
||||||
} else if !prompter.notificationsGranted {
|
|
||||||
prompter.requestNotifications()
|
|
||||||
} else {
|
} else {
|
||||||
page = 3
|
// Same rule as Location: prompt now, before advancing.
|
||||||
|
prompter.requestNotifications()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 3:
|
case 3:
|
||||||
primaryButton(prompter.dataLoading ? "Waiting…" : "Continue") {
|
// No permission on this page — prices need none. Straight on.
|
||||||
if prompter.dataGranted || prompter.dataDenied {
|
primaryButton("Continue") { page = 4 }
|
||||||
// Prompt answered (allowed or denied) — move on to the
|
|
||||||
// final slide. Never dead-end on an Open Settings button.
|
|
||||||
page = 4
|
|
||||||
} else if !prompter.dataLoading {
|
|
||||||
// Fires the Local Network prompt + relay probe here,
|
|
||||||
// after the user has read the page and tapped Continue.
|
|
||||||
prompter.requestDataAccess()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(prompter.dataLoading)
|
|
||||||
default:
|
default:
|
||||||
primaryButton("Start Using FuelBoard") { finish() }
|
primaryButton("Start Using FuelBoard") { finish() }
|
||||||
}
|
}
|
||||||
@@ -382,21 +331,107 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct AnimatedOnboardingIcon<Content: View>: View {
|
||||||
|
enum Motion {
|
||||||
|
case gentle
|
||||||
|
case locationArrow
|
||||||
|
case alarmBell
|
||||||
|
case downloadArrow
|
||||||
|
}
|
||||||
|
|
||||||
|
let tint: Color
|
||||||
|
var motion: Motion = .gentle
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
|
||||||
|
@State private var settled = false
|
||||||
|
@State private var iconRotation: Double = 0
|
||||||
|
@State private var iconOffsetY: CGFloat = 0
|
||||||
|
@State private var iconScale: CGFloat = 0.92
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(tint.opacity(0.10))
|
||||||
|
.frame(width: 108, height: 108)
|
||||||
|
.scaleEffect(settled ? 1.0 : 0.94)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.stroke(tint.opacity(0.18), lineWidth: 1)
|
||||||
|
.frame(width: 118, height: 118)
|
||||||
|
.scaleEffect(settled ? 1.03 : 0.97)
|
||||||
|
.opacity(settled ? 1.0 : 0.55)
|
||||||
|
|
||||||
|
content
|
||||||
|
.scaleEffect(iconScale)
|
||||||
|
.rotationEffect(.degrees(iconRotation))
|
||||||
|
.offset(y: iconOffsetY)
|
||||||
|
}
|
||||||
|
.shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2)
|
||||||
|
.animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled)
|
||||||
|
.onAppear {
|
||||||
|
playAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playAnimation() {
|
||||||
|
settled = false
|
||||||
|
iconRotation = 0
|
||||||
|
iconOffsetY = 4
|
||||||
|
iconScale = 0.92
|
||||||
|
|
||||||
|
switch motion {
|
||||||
|
case .gentle:
|
||||||
|
withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
|
||||||
|
settled = true
|
||||||
|
iconOffsetY = 0
|
||||||
|
iconScale = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
case .locationArrow:
|
||||||
|
iconRotation = -120
|
||||||
|
withAnimation(.linear(duration: 0.45)) {
|
||||||
|
settled = true
|
||||||
|
iconRotation = 24
|
||||||
|
iconOffsetY = 0
|
||||||
|
iconScale = 1.02
|
||||||
|
}
|
||||||
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) {
|
||||||
|
iconRotation = 0
|
||||||
|
iconScale = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
case .alarmBell:
|
||||||
|
settled = true
|
||||||
|
iconOffsetY = 0
|
||||||
|
iconScale = 1.0
|
||||||
|
withAnimation(.linear(duration: 0.10)) { iconRotation = -14 }
|
||||||
|
withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 }
|
||||||
|
withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 }
|
||||||
|
withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 }
|
||||||
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 }
|
||||||
|
|
||||||
|
case .downloadArrow:
|
||||||
|
iconOffsetY = -22
|
||||||
|
withAnimation(.easeIn(duration: 0.22)) {
|
||||||
|
settled = true
|
||||||
|
iconScale = 1.0
|
||||||
|
iconOffsetY = 8
|
||||||
|
}
|
||||||
|
withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) {
|
||||||
|
iconOffsetY = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Owns the system permission requests during onboarding and publishes
|
/// 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
|
||||||
@Published private(set) var notificationsDenied = false
|
@Published private(set) var notificationsDenied = false
|
||||||
@Published private(set) var dataGranted = false
|
|
||||||
@Published private(set) var dataDenied = false
|
|
||||||
@Published private(set) var dataLoading = false
|
|
||||||
|
|
||||||
/// Held while the Local Network permission prompt is pending — keeps the
|
|
||||||
/// bare TCP connect alive until the user answers (nil after resolve).
|
|
||||||
private var dataConnection: NWConnection?
|
|
||||||
|
|
||||||
private let manager = CLLocationManager()
|
private let manager = CLLocationManager()
|
||||||
|
|
||||||
@@ -421,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()
|
||||||
self.notificationsGranted = granted
|
.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
self.notificationsDenied = !granted
|
self.notificationsGranted = granted
|
||||||
}
|
self.notificationsDenied = !granted
|
||||||
|
} catch {
|
||||||
|
self.notificationsGranted = false
|
||||||
|
self.notificationsDenied = true
|
||||||
}
|
}
|
||||||
case .authorized:
|
case .authorized:
|
||||||
self.notificationsGranted = true
|
self.notificationsGranted = true
|
||||||
@@ -438,125 +476,9 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Triggers the Local Network permission prompt WITHOUT loading any data.
|
/// Replay pre-flight: NO data permission exists anymore — prices download
|
||||||
/// iOS shows the prompt on the first attempt to reach a local-network
|
/// from the internet with no prompt, so there is nothing to preflight.
|
||||||
/// address, so a bare TCP connect to the relay host:port is enough — no
|
/// (The LAN relay left the consumer chain; Local Network is gone.)
|
||||||
/// HTTP request, no payload, nothing to wait on. Once the user answers
|
|
||||||
/// the prompt the connect either succeeds (granted) or fails (denied);
|
|
||||||
/// either way onboarding proceeds. The full dataset download happens
|
|
||||||
/// AFTER onboarding completes (ContentView's forced refresh).
|
|
||||||
func requestDataAccess() {
|
|
||||||
guard !dataLoading else { return }
|
|
||||||
dataLoading = true
|
|
||||||
dataDenied = false
|
|
||||||
let baseURL = RelayFuelProvider().baseURL
|
|
||||||
guard let host = baseURL.host,
|
|
||||||
let port = baseURL.port else {
|
|
||||||
dataLoading = false
|
|
||||||
dataDenied = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let connection = NWConnection(
|
|
||||||
host: NWEndpoint.Host(host),
|
|
||||||
port: NWEndpoint.Port(rawValue: UInt16(port))!,
|
|
||||||
using: .tcp
|
|
||||||
)
|
|
||||||
dataConnection = connection
|
|
||||||
connection.stateUpdateHandler = { [weak self] state in
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let self else { return }
|
|
||||||
switch state {
|
|
||||||
case .ready:
|
|
||||||
// Connect succeeded — local network allowed.
|
|
||||||
self.dataConnection = nil
|
|
||||||
self.dataLoading = false
|
|
||||||
self.dataGranted = true
|
|
||||||
self.dataDenied = false
|
|
||||||
case .failed, .cancelled:
|
|
||||||
// Connect failed (denied, or relay unreachable). Either
|
|
||||||
// way we do NOT dead-end: onboarding continues.
|
|
||||||
self.dataConnection = nil
|
|
||||||
self.dataLoading = false
|
|
||||||
self.dataGranted = false
|
|
||||||
self.dataDenied = true
|
|
||||||
default:
|
|
||||||
break // .preparing / .waiting — prompt pending, hold on
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connection.start(queue: .main)
|
|
||||||
// Safety timeout: if the prompt is ignored or the connect stalls, drop
|
|
||||||
// back to the enabled Continue button instead of an endless spinner.
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: 12_000_000_000)
|
|
||||||
guard let self, self.dataLoading else { return }
|
|
||||||
self.dataLoading = false
|
|
||||||
self.dataGranted = false
|
|
||||||
self.dataDenied = true
|
|
||||||
self.dataConnection = nil
|
|
||||||
connection.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replay pre-flight: determines the Local Network status WITHOUT any
|
|
||||||
/// user-visible check. Once the permission is decided (granted or denied)
|
|
||||||
/// a probe resolves silently — granted connects in milliseconds, denied
|
|
||||||
/// reports `.waiting` with `unsatisfiedReason == .localNetworkDenied`.
|
|
||||||
/// Only the FIRST-ever attempt (undetermined) shows the system alert; in
|
|
||||||
/// that case we cancel and leave the state unknown so the data page's
|
|
||||||
/// Continue triggers the prompt in context. First run never calls this.
|
|
||||||
func preflightDataAccess() {
|
|
||||||
guard !dataLoading, !dataGranted, !dataDenied else { return }
|
|
||||||
dataLoading = true
|
|
||||||
let baseURL = RelayFuelProvider().baseURL
|
|
||||||
guard let host = baseURL.host,
|
|
||||||
let port = baseURL.port else {
|
|
||||||
dataLoading = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let connection = NWConnection(
|
|
||||||
host: NWEndpoint.Host(host),
|
|
||||||
port: NWEndpoint.Port(rawValue: UInt16(port))!,
|
|
||||||
using: .tcp
|
|
||||||
)
|
|
||||||
dataConnection = connection
|
|
||||||
connection.stateUpdateHandler = { [weak self] state in
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let self else { return }
|
|
||||||
switch state {
|
|
||||||
case .ready:
|
|
||||||
self.dataConnection = nil
|
|
||||||
self.dataLoading = false
|
|
||||||
self.dataGranted = true
|
|
||||||
self.dataDenied = false
|
|
||||||
case .waiting:
|
|
||||||
if connection.currentPath?.unsatisfiedReason == .localNetworkDenied {
|
|
||||||
// Denied in Settings — resolved silently, no prompt.
|
|
||||||
self.dataConnection = nil
|
|
||||||
self.dataLoading = false
|
|
||||||
self.dataGranted = false
|
|
||||||
self.dataDenied = true
|
|
||||||
} else {
|
|
||||||
// Prompt pending (permission undetermined — only
|
|
||||||
// possible after a reinstall, where the onboarding
|
|
||||||
// flag persisted but the permission reset). Cancel so
|
|
||||||
// page 4's Continue fires the prompt in context.
|
|
||||||
self.dataConnection = nil
|
|
||||||
self.dataLoading = false
|
|
||||||
connection.cancel()
|
|
||||||
}
|
|
||||||
case .failed, .cancelled:
|
|
||||||
self.dataConnection = nil
|
|
||||||
self.dataLoading = false
|
|
||||||
self.dataGranted = false
|
|
||||||
self.dataDenied = true
|
|
||||||
default:
|
|
||||||
break // .preparing — probe starting, hold on
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connection.start(queue: .main)
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeLocation</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeDiagnostics</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>1C8F.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ 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
|
||||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||||
@@ -53,13 +56,13 @@ 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).
|
||||||
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
||||||
|
/// Dev-only: re-inserts the LAN relay into the live chain (off by default —
|
||||||
|
/// consumers must never attempt local-network access).
|
||||||
|
@State private var relayFallbackEnabled: Bool = FuelStore.loadRelayFallbackEnabled()
|
||||||
@State private var versionTapCount = 0
|
@State private var versionTapCount = 0
|
||||||
@State private var lastVersionTap = Date.distantPast
|
@State private var lastVersionTap = Date.distantPast
|
||||||
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
||||||
@@ -129,6 +132,10 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
if debugMode {
|
if debugMode {
|
||||||
Section {
|
Section {
|
||||||
|
Toggle("LAN relay fallback (dev)", isOn: $relayFallbackEnabled)
|
||||||
|
.onChange(of: relayFallbackEnabled) { _, enabled in
|
||||||
|
FuelStore.saveRelayFallbackEnabled(enabled)
|
||||||
|
}
|
||||||
Button {
|
Button {
|
||||||
sendTestAlert()
|
sendTestAlert()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -222,7 +229,7 @@ struct SettingsView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Data source")
|
Text("Data source")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Connection shows whether prices come from the official Fuel Finder API or the CSV mirror. Data updated is the latest price change reported by the GOV.UK server itself.")
|
Text("Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -291,23 +298,51 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button {
|
VStack(spacing: 10) {
|
||||||
Task { await tipStore.purchase() }
|
ForEach(TipStore.tiers) { tier in
|
||||||
} label: {
|
Button {
|
||||||
HStack {
|
Task { await tipStore.purchase(tier) }
|
||||||
Label("Leave a tip", systemImage: "heart.fill")
|
} label: {
|
||||||
.foregroundStyle(.pink)
|
HStack(spacing: 12) {
|
||||||
Spacer()
|
Image(systemName: "fuelpump.fill")
|
||||||
Text(tipStore.displayPrice)
|
.font(.title3)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(tier.accent)
|
||||||
.monospacedDigit()
|
.frame(width: 34)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(tier.name)
|
||||||
|
.font(.headline)
|
||||||
|
Text(tier.blurb)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(tipStore.displayPrice(for: tier))
|
||||||
|
.font(.headline.weight(.bold))
|
||||||
|
.foregroundStyle(tier.accent)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(tier.accent.opacity(0.05))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(tipStore.purchaseInProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(tipStore.purchaseInProgress)
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||||
} header: {
|
} header: {
|
||||||
Text("Support FuelBoard")
|
Text("Support FuelBoard")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("A small tip helps keep the data relay and app development going. Thank you!")
|
Text("A tip helps keep this app free and supports development. Thank you")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -324,13 +359,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 {
|
||||||
@@ -373,7 +401,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)
|
||||||
@@ -532,13 +560,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: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,34 +579,113 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads the £4.99 tip product and drives its purchase.
|
/// Loads the three tip tiers and drives their purchases.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class TipStore: ObservableObject {
|
final class TipStore: ObservableObject {
|
||||||
/// Product ID for the £4.99 tip (App Store Connect — consumable).
|
/// A purchasable tip tier (App Store Connect — all consumables).
|
||||||
static let productID = "com.apt.fuelboard.tip499"
|
struct TipTier: Identifiable {
|
||||||
|
let id: String // product ID
|
||||||
@Published private(set) var product: Product?
|
let name: String
|
||||||
@Published private(set) var purchaseInProgress = false
|
let blurb: String
|
||||||
@Published private(set) var message: String?
|
let fallbackPrice: String
|
||||||
|
let accent: Color // card/price accent (matches the fuel palette)
|
||||||
var displayPrice: String {
|
|
||||||
product?.displayPrice ?? "£4.99"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func load() async {
|
static let tiers: [TipTier] = [
|
||||||
// Refreshes product state on every visit so a newly-approved product
|
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
|
||||||
// (or a restored transaction) is picked up.
|
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99",
|
||||||
do {
|
accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF
|
||||||
let products = try await Product.products(for: [Self.productID])
|
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
|
||||||
product = products.first
|
blurb: "A generous top-up for development.", fallbackPrice: "£2.99",
|
||||||
} catch {
|
accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158
|
||||||
// No product yet (sideloaded build) — the button still shows the
|
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
|
||||||
// intended price and reports the purchase attempt gracefully.
|
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99",
|
||||||
product = nil
|
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: Equatable {
|
||||||
|
let message: String
|
||||||
|
let icon: String
|
||||||
|
let tint: Color
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var products: [String: Product] = [:]
|
||||||
|
@Published private(set) var purchaseInProgress = false
|
||||||
|
@Published private(set) var outcome: TipOutcome?
|
||||||
|
|
||||||
|
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
|
||||||
|
private var dismissTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Listens for transactions that complete OUTSIDE the direct purchase()
|
||||||
|
/// call — Ask to Buy approvals, payments finished on another device
|
||||||
|
/// signed into the same Apple ID. Without this, those purchases land in
|
||||||
|
/// the queue but are never finished or acknowledged.
|
||||||
|
private var updatesTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
updatesTask = Task { [weak self] in
|
||||||
|
for await update in Transaction.updates {
|
||||||
|
await self?.handle(update)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func purchase() async {
|
deinit {
|
||||||
|
updatesTask?.cancel()
|
||||||
|
dismissTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||||
|
// Never deliver or finish an untrusted purchase.
|
||||||
|
guard case .verified(let transaction) = update else { return }
|
||||||
|
// Only acknowledge our own consumables (future products get their own).
|
||||||
|
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
||||||
|
await transaction.finish()
|
||||||
|
showOutcome(
|
||||||
|
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||||
|
icon: "checkmark.circle.fill",
|
||||||
|
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the banner outcome and schedules its auto-dismiss (~3.5 s).
|
||||||
|
/// Re-triggering cancels the previous timer so rapid taps don't collide.
|
||||||
|
private func showOutcome(_ message: String, icon: String, tint: Color) {
|
||||||
|
dismissTask?.cancel()
|
||||||
|
outcome = TipOutcome(message: message, icon: icon, tint: tint)
|
||||||
|
dismissTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: 3_500_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
withAnimation(.easeOut(duration: 0.25)) { self?.outcome = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tap-to-dismiss — clears the banner and cancels the pending auto-dismiss.
|
||||||
|
func dismissOutcome() {
|
||||||
|
dismissTask?.cancel()
|
||||||
|
dismissTask = nil
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) { outcome = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayPrice(for tier: TipTier) -> String {
|
||||||
|
products[tier.id]?.displayPrice ?? tier.fallbackPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
// Refreshes product state on every visit so newly-approved products
|
||||||
|
// (or restored transactions) are picked up.
|
||||||
|
do {
|
||||||
|
let fetched = try await Product.products(for: Self.tiers.map(\.id))
|
||||||
|
products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
||||||
|
} catch {
|
||||||
|
// No products yet (sideloaded build) — the buttons still show the
|
||||||
|
// intended prices and report purchase attempts gracefully.
|
||||||
|
products = [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchase(_ tier: TipTier) async {
|
||||||
guard !purchaseInProgress else { return }
|
guard !purchaseInProgress else { return }
|
||||||
purchaseInProgress = true
|
purchaseInProgress = true
|
||||||
defer { purchaseInProgress = false }
|
defer { purchaseInProgress = false }
|
||||||
@@ -582,8 +693,12 @@ final class TipStore: ObservableObject {
|
|||||||
// If the product hasn't loaded (e.g. not configured in App Store
|
// If the product hasn't loaded (e.g. not configured in App Store
|
||||||
// Connect yet), still allow the attempt so the user sees a clear
|
// 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 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,20 +707,39 @@ final class TipStore: ObservableObject {
|
|||||||
switch result {
|
switch result {
|
||||||
case .success(let verification):
|
case .success(let verification):
|
||||||
switch verification {
|
switch verification {
|
||||||
case .verified:
|
case .verified(let transaction):
|
||||||
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
// Consume the consumable — otherwise StoreKit re-delivers
|
||||||
|
// it through Transaction.updates on every launch.
|
||||||
|
await transaction.finish()
|
||||||
|
showOutcome(
|
||||||
|
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||||
|
icon: "checkmark.circle.fill",
|
||||||
|
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||||
|
)
|
||||||
case .unverified:
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -29,27 +29,33 @@ struct StationsView: View {
|
|||||||
/// info button in the header — keeps the list focused on stations.
|
/// info button in the header — keeps the list focused on stations.
|
||||||
@State private var showKey = false
|
@State private var showKey = false
|
||||||
|
|
||||||
|
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
||||||
|
/// directions context + "N/TOTAL stations updated". The numerator is the
|
||||||
|
/// current list pool; the denominator is the full UK station total from
|
||||||
|
/// the last chain fetch (8,022 mirror snapshot) — hidden until known.
|
||||||
|
private var footerCaption: String {
|
||||||
|
let miles = distanceUnit.displayMiles(stationLimit)
|
||||||
|
let unit = distanceUnit.label(for: Double(miles))
|
||||||
|
let fuel = selectedFuel.displayName
|
||||||
|
let mode = sortMode == .closest ? "Closest" : "Cheapest"
|
||||||
|
let ratio: String
|
||||||
|
if let total = FuelStore.loadStationCount() {
|
||||||
|
ratio = "\(totalCount)/\(total)"
|
||||||
|
} else {
|
||||||
|
ratio = "\(totalCount)"
|
||||||
|
}
|
||||||
|
if location != nil {
|
||||||
|
if sortMode == .closest {
|
||||||
|
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||||
|
}
|
||||||
|
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · tap a station for directions."
|
||||||
|
}
|
||||||
|
return "\(mode) \(fuel) — \(ratio) stations updated · tap a station for directions."
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section {
|
|
||||||
if let location {
|
|
||||||
if sortMode == .closest {
|
|
||||||
Text("Closest \(selectedFuel.displayName) stations — nearest first, best value within \(distanceUnit.displayMiles(stationLimit)) \(distanceUnit.label(for: Double(distanceUnit.displayMiles(stationLimit)))) · \(totalCount) stations")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
Text("Cheapest \(selectedFuel.displayName) within \(distanceUnit.displayMiles(stationLimit)) \(distanceUnit.label(for: Double(distanceUnit.displayMiles(stationLimit)))) — \(totalCount) stations · tap a station for directions.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text("\(sortMode == .closest ? "Closest" : "Cheapest") \(selectedFuel.displayName) — tap a station for directions.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Fuel type") {
|
Section("Fuel type") {
|
||||||
FuelTypeSegmentedPicker(selection: $selectedFuel) { newValue in
|
FuelTypeSegmentedPicker(selection: $selectedFuel) { newValue in
|
||||||
FuelStore.saveSelectedFuel(newValue)
|
FuelStore.saveSelectedFuel(newValue)
|
||||||
@@ -95,8 +101,20 @@ struct StationsView: View {
|
|||||||
Text("Fetching prices…")
|
Text("Fetching prices…")
|
||||||
}
|
}
|
||||||
} else if stations.isEmpty {
|
} else if stations.isEmpty {
|
||||||
Text("No \(selectedFuel.displayName) stations found.")
|
let miles = distanceUnit.displayMiles(stationLimit)
|
||||||
.foregroundStyle(.secondary)
|
let unit = distanceUnit.label(for: Double(miles))
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(String(format: NSLocalizedString("No %@ stations within %lld %@.", comment: "Empty state when the radius finds nothing nearby"), selectedFuel.displayName, miles, unit))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("FuelBoard covers the whole UK — try Closest to browse every station.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Browse all UK stations") {
|
||||||
|
sortMode = .closest
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
|
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
|
||||||
StationRow(
|
StationRow(
|
||||||
@@ -131,6 +149,15 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bottom-of-tab explainer (moved from the top 2026-08-16),
|
||||||
|
// styled like the Favourites tab's cheapest callout: a caption
|
||||||
|
// paragraph in its own section cell.
|
||||||
|
Section {
|
||||||
|
Text(footerCaption)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
// Manual override for the twice-a-day cache policy.
|
// Manual override for the twice-a-day cache policy.
|
||||||
@@ -147,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) {
|
||||||
@@ -166,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)
|
||||||
Text("TOP")
|
.padding(.vertical, 2)
|
||||||
.font(.caption2.bold())
|
HStack(spacing: 12) {
|
||||||
.padding(.horizontal, 5)
|
Text("TOP")
|
||||||
.padding(.vertical, 1)
|
.font(.caption2.bold())
|
||||||
.background(Capsule().fill(.blue.opacity(0.15)))
|
.padding(.horizontal, 7)
|
||||||
.foregroundStyle(.blue)
|
.padding(.vertical, 3)
|
||||||
Text("Top result for the current sort")
|
.background(Capsule().fill(.blue.opacity(0.16)))
|
||||||
.font(.caption)
|
.foregroundStyle(.blue)
|
||||||
}
|
Text("Top result for the current sort")
|
||||||
HStack(spacing: 8) {
|
.font(.subheadline.weight(.semibold))
|
||||||
Image(systemName: "star.fill")
|
Spacer(minLength: 0)
|
||||||
.font(.caption2)
|
}
|
||||||
.foregroundStyle(.yellow)
|
.padding(.horizontal, 14)
|
||||||
Text("Star a station to add it to Favourites")
|
.padding(.vertical, 12)
|
||||||
.font(.caption)
|
.keyCardFill(color: .blue)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
.frame(width: 34)
|
||||||
|
Text("Star a station to add it to Favourites")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.keyCardFill(color: .yellow)
|
||||||
|
Text("Colours match each station's rating on the list")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Key")
|
.navigationTitle("Key")
|
||||||
@@ -209,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)
|
||||||
@@ -252,20 +342,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 {
|
||||||
selection = fuel
|
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
|
||||||
|
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)
|
||||||
@@ -274,14 +370,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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,9 +13,17 @@ 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
|
||||||
|
|
||||||
@State private var fuel: FuelType = .e10
|
@State private var fuel: FuelType = .e10
|
||||||
@@ -26,6 +34,21 @@ struct TrendsView: View {
|
|||||||
@State private var loadFailed = false
|
@State private var loadFailed = false
|
||||||
@State private var firstSnapshot: String?
|
@State private var firstSnapshot: String?
|
||||||
|
|
||||||
|
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
|
||||||
|
/// opens where the user was — same pattern as FavouritesView.
|
||||||
|
init(favourites: [FavouriteEntry],
|
||||||
|
selectedFuel: FuelType,
|
||||||
|
priceDisplayStyle: PriceDisplayStyle,
|
||||||
|
onHistoryUnavailable: (() -> Void)? = nil,
|
||||||
|
onHistoryRecovered: (() -> Void)? = nil) {
|
||||||
|
self.favourites = favourites
|
||||||
|
self.selectedFuel = selectedFuel
|
||||||
|
self.priceDisplayStyle = priceDisplayStyle
|
||||||
|
self.onHistoryUnavailable = onHistoryUnavailable
|
||||||
|
self.onHistoryRecovered = onHistoryRecovered
|
||||||
|
_fuel = State(initialValue: selectedFuel)
|
||||||
|
}
|
||||||
|
|
||||||
enum TrendsMode: String, CaseIterable, Identifiable {
|
enum TrendsMode: String, CaseIterable, Identifiable {
|
||||||
case price
|
case price
|
||||||
case vsCheapest
|
case vsCheapest
|
||||||
@@ -79,7 +102,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 }
|
||||||
@@ -98,6 +158,14 @@ struct TrendsView: View {
|
|||||||
loadFailed = firstSnapshot == nil
|
loadFailed = firstSnapshot == nil
|
||||||
}
|
}
|
||||||
series = fetched
|
series = fetched
|
||||||
|
// A failure with no data IS a connection problem — raise the global
|
||||||
|
// banner so the user isn't stuck with a silent retry state. Success
|
||||||
|
// clears it (only if the banner is the connection banner).
|
||||||
|
if loadFailed {
|
||||||
|
onHistoryUnavailable?()
|
||||||
|
} else if hasAnyData {
|
||||||
|
onHistoryRecovered?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func yLabel(_ pence: Double) -> String {
|
private func yLabel(_ pence: Double) -> String {
|
||||||
@@ -109,6 +177,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 +272,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 +301,104 @@ struct TrendsView: View {
|
|||||||
.padding(.vertical, 24)
|
.padding(.vertical, 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RevenueCat-style card: rounded surface with a headline metric (period
|
||||||
|
/// average + change), the multi-series area chart, then the key. Mirrors
|
||||||
|
/// the mockup approved after the dashboard reference screenshot.
|
||||||
|
private var revenueCatCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
metricHeader
|
||||||
|
metricFooter
|
||||||
|
chart
|
||||||
|
legend
|
||||||
|
legendFooter
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Headline row: big average figure, signed change, right-aligned period.
|
||||||
|
/// Colours stay on the app palette — green when the move is good
|
||||||
|
/// (prices fell / gap narrowed), the accent tint when it rose.
|
||||||
|
@ViewBuilder
|
||||||
|
private var metricHeader: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
if let headAvg = headlineAverage {
|
||||||
|
Text(yLabel(headAvg))
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .default))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if let delta = headlineDelta, delta != 0 {
|
||||||
|
Label(
|
||||||
|
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
|
||||||
|
systemImage: deltaIsGood ? "arrow.down.right" : "arrow.up.right"
|
||||||
|
)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
.foregroundStyle(deltaIsGood ? Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) : Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(periodLabel)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line descriptor under the headline so the big figure (period
|
||||||
|
/// average across favourites) and the arrowed delta (change over the
|
||||||
|
/// window) are self-explanatory.
|
||||||
|
private var metricFooter: some View {
|
||||||
|
Text(chartFooterText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chartFooterText: String {
|
||||||
|
switch mode {
|
||||||
|
case .price:
|
||||||
|
return "Average across favourites · change since the first day"
|
||||||
|
case .vsCheapest:
|
||||||
|
return "Average gap above the day's cheapest · change since the first day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var chart: some View {
|
private var chart: some View {
|
||||||
Chart(displaySeries) { history in
|
// NOTE: each LineMark MUST carry an explicit `series:` — without it
|
||||||
ForEach(history.points) { point in
|
// Swift Charts merges every station's points into ONE polyline
|
||||||
LineMark(
|
// (points connect across stations, so only the first station's line
|
||||||
x: .value("Date", point.date),
|
// is recognisable). The outer ForEach keeps one chart with N series;
|
||||||
y: .value("Price", point.pence)
|
// per-mark foregroundStyle then colours each series from the palette.
|
||||||
)
|
// The AreaMark under each LineMark adds the gradient fill.
|
||||||
.foregroundStyle(seriesColor(index(of: history.stationID)))
|
//
|
||||||
|
// stacking: .unstacked is essential — the standard stacking mode
|
||||||
|
// piles each series' area ON TOP of the previous series' area, so
|
||||||
|
// with 2+ favourites the fills land in the wrong place (seen on
|
||||||
|
// device 2026-08-17: fills shifted/overlapping vs the lines).
|
||||||
|
// .unstacked draws each area from its own line down to the axis,
|
||||||
|
// the RevenueCat look.
|
||||||
|
// Two passes: ALL AreaMarks first, then ALL LineMarks. Same-series
|
||||||
|
// marks composite in insertion order — if fills and lines interleave,
|
||||||
|
// a later series' fill paints OVER an earlier series' line (the
|
||||||
|
// bottom favourite's line vanished under the next fill; seen on
|
||||||
|
// device 2026-08-17). Drawing every fill before every line keeps all
|
||||||
|
// lines on top of all fills, RevenueCat style. (Mark builders are
|
||||||
|
// extracted into small helpers — the inline expression grew past the
|
||||||
|
// type-checker's budget.)
|
||||||
|
Chart {
|
||||||
|
ForEach(displaySeries) { history in
|
||||||
|
ForEach(history.points) { point in
|
||||||
|
areaMark(point, series: history.name,
|
||||||
|
color: seriesColor(index(of: history.stationID)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(displaySeries) { history in
|
||||||
|
ForEach(history.points) { point in
|
||||||
|
lineMark(point, series: history.name,
|
||||||
|
color: seriesColor(index(of: history.stationID)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.chartXAxis {
|
.chartXAxis {
|
||||||
@@ -219,39 +406,122 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
/// Full-screen widget preview (launch arg `-widgets`): the real app icon
|
||||||
|
/// plus the REAL widget faces (Shared/FuelPriceWidgetViews.swift — the same
|
||||||
|
/// code the widget extension renders) at small + medium sizes. Used for App
|
||||||
|
/// Store screenshot captures; not reachable from the UI.
|
||||||
|
struct WidgetMockScreen: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Real iPhone wallpaper (extracted from the iOS 26.3 sim runtime —
|
||||||
|
// ActionButtonSelector Display.ca asset, 1320×2870 native res).
|
||||||
|
Image("PreviewWallpaper")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 26) {
|
||||||
|
// App icon + name, home-screen style. Uses the SAME artwork
|
||||||
|
// the app ships (AppIcon.appiconset), with the dark variant
|
||||||
|
// when the device is in dark mode — matching iOS behaviour.
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Image(colorScheme == .dark ? "AppIconPreviewDark" : "AppIconPreview")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 76, height: 76)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("FuelBoard")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.shadow(radius: 2, y: 1)
|
||||||
|
Text("Cheapest fuel near you")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.white.opacity(0.9))
|
||||||
|
.shadow(radius: 2, y: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
widgetCard(.systemSmall, label: "Small widget")
|
||||||
|
widgetCard(.systemMedium, label: "Medium widget")
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func widgetCard(_ family: WidgetFamily, label: String) -> some View {
|
||||||
|
// Real widget proportions: system small content is 158×158pt,
|
||||||
|
// medium 338×158pt (card adds the 14pt padding each side).
|
||||||
|
let contentW: CGFloat = family == .systemSmall ? 158 : 338
|
||||||
|
let cardW: CGFloat = contentW + 28
|
||||||
|
let cardH: CGFloat = contentW + 28
|
||||||
|
return VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.tracking(1)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
FuelPriceWidgetContent(entry: mockEntry(family: family), family: family, isWidget: false)
|
||||||
|
.padding(14)
|
||||||
|
.frame(width: cardW, height: cardH, alignment: .leading)
|
||||||
|
.background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real stations (loaded dump), cheapest-first for the chosen fuel — the
|
||||||
|
/// same selection the widget's own provider makes.
|
||||||
|
private func mockEntry(family: WidgetFamily) -> FuelPriceWidgetEntryData {
|
||||||
|
let fuel = FuelType.e10
|
||||||
|
let all = FuelStore.loadStations()
|
||||||
|
let selling = all.filter { $0.prices[fuel] != nil }
|
||||||
|
.sorted { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
|
||||||
|
let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
|
||||||
|
let count = family == .systemSmall ? 1 : 3
|
||||||
|
return FuelPriceWidgetEntryData(
|
||||||
|
date: Date(),
|
||||||
|
stations: Array(selling.prefix(count)),
|
||||||
|
fuel: fuel,
|
||||||
|
sort: .cheapest,
|
||||||
|
isFavourites: false,
|
||||||
|
location: location,
|
||||||
|
locationSource: location != nil ? "live" : "none",
|
||||||
|
unit: FuelStore.loadDistanceUnit()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -99,12 +103,20 @@
|
|||||||
/* Onboarding */
|
/* Onboarding */
|
||||||
"Welcome to FuelBoard" = "Welcome to FuelBoard";
|
"Welcome to FuelBoard" = "Welcome to FuelBoard";
|
||||||
"The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data." = "The cheapest fuel near you";
|
"The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data." = "The cheapest fuel near you";
|
||||||
"England-wide prices — 8,000+ stations, updated twice a day" = "England-wide prices — 8,000+ stations, updated daily";
|
"UK-wide prices — 8,000+ stations, updated twice a day" = "UK-wide prices — 8,000+ stations, updated twice a day";
|
||||||
"Cheapest within your chosen radius, or closest station first" = "Cheapest within your chosen radius, or closest station";
|
"Cheapest within your chosen radius, or closest station first" = "Cheapest within your chosen radius, or closest station";
|
||||||
"Favourites with instant price comparison" = "Favourites with instant price comparison";
|
"Favourites with instant price comparison" = "Favourites with instant price comparison";
|
||||||
"Green/amber/red rating vs the best nearby price" = "Rating vs the best nearby price";
|
"Green/amber/red rating vs the best nearby price" = "Rating vs the best nearby price";
|
||||||
"Home-screen widget showing the cheapest nearby" = "Customisable Home-screen widgets";
|
"Home-screen widget showing the cheapest nearby" = "Customisable Home-screen widgets";
|
||||||
"Alerts when you approach the cheapest station" = "Alerts you when you approach the cheapest station";
|
"Alerts when you approach the cheapest station" = "Alerts you when you approach the cheapest station";
|
||||||
|
|
||||||
|
/* Stations empty state */
|
||||||
|
"No %@ stations within %lld %@." = "No %@ stations within %lld %@.";
|
||||||
|
"FuelBoard covers the whole UK — try Closest to browse every station." = "FuelBoard covers the whole UK — try Closest to browse every station.";
|
||||||
|
"Browse all UK stations" = "Browse all UK stations";
|
||||||
|
|
||||||
|
/* Settings → Data source footer */
|
||||||
|
"Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day." = "Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day.";
|
||||||
"Prices near you" = "Prices near you";
|
"Prices near you" = "Prices near you";
|
||||||
"FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device." = "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.";
|
"FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device." = "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.";
|
||||||
"Location was denied. You can still browse all stations, but \"nearest\" sorting needs it." = "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it.";
|
"Location was denied. You can still browse all stations, but \"nearest\" sorting needs it." = "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it.";
|
||||||
@@ -113,10 +125,7 @@
|
|||||||
"FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab." = "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab.";
|
"FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab." = "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab.";
|
||||||
"Notifications were denied — you can still use FuelBoard, just without alert banners." = "Notifications were denied — you can still use FuelBoard, just without alert banners.";
|
"Notifications were denied — you can still use FuelBoard, just without alert banners." = "Notifications were denied — you can still use FuelBoard, just without alert banners.";
|
||||||
"Prices, ready when you are" = "Prices, ready when you are";
|
"Prices, ready when you are" = "Prices, ready when you are";
|
||||||
"FuelBoard downloads the latest prices from a relay on your local network — the full UK dataset, refreshed twice a day. Local network access is needed for that first download." = "FuelBoard downloads the latest prices daily";
|
"FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed." = "Prices download from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.";
|
||||||
"Waiting for network permission…" = "Waiting for network permission…";
|
|
||||||
"Connected" = "Connected";
|
|
||||||
"Local network access was denied — prices won't load until it's allowed, but you can keep using FuelBoard." = "Local network access was denied — prices won't load until it's allowed, but you can keep using FuelBoard.";
|
|
||||||
"You're all set" = "You're all set";
|
"You're all set" = "You're all set";
|
||||||
"Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby." = "Find the cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.";
|
"Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby." = "Find the cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.";
|
||||||
"Continue" = "Continue";
|
"Continue" = "Continue";
|
||||||
@@ -124,7 +133,6 @@
|
|||||||
"Allow Location Access" = "Allow Location Access";
|
"Allow Location Access" = "Allow Location Access";
|
||||||
"Continue without alerts" = "Continue without alerts";
|
"Continue without alerts" = "Continue without alerts";
|
||||||
"Allow Notifications" = "Allow Notifications";
|
"Allow Notifications" = "Allow Notifications";
|
||||||
"Waiting…" = "Waiting…";
|
|
||||||
"Start Using FuelBoard" = "Start Using FuelBoard";
|
"Start Using FuelBoard" = "Start Using FuelBoard";
|
||||||
|
|
||||||
/* Station map */
|
/* Station map */
|
||||||
@@ -148,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 @@
|
|||||||
|
../../../Shared/MirrorFuelProvider.swift
|
||||||
@@ -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,22 @@ 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
|
||||||
|
/// P0 live chain: GitHub mirror FIRST everywhere → [dev-only LAN relay] →
|
||||||
|
/// (app: bundled dump in ContentView's catch, widget: cache → placeholder).
|
||||||
|
/// The relay is NOT part of the consumer chain — it only joins behind the
|
||||||
|
/// hidden dev flag `fuelboard.relayFallback`.
|
||||||
|
final class LiveChainTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
FuelBeacon.isEnabled = false // chain tests must not fire real pings
|
||||||
|
FuelStore.saveRelayFallbackEnabled(false) // dev flag off by default
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
FuelBeacon.isEnabled = true
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Fixtures
|
||||||
|
|
||||||
|
private final class StubProvider: FuelPriceProviding {
|
||||||
|
var result: [FuelStation]
|
||||||
|
var error: Error?
|
||||||
|
private(set) var callCount = 0
|
||||||
|
|
||||||
|
init(result: [FuelStation]? = nil, error: Error? = nil) {
|
||||||
|
self.result = result ?? []
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
|
callCount += 1
|
||||||
|
if let error { throw error }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fixtureStation(_ id: String = "s1", lat: Double = 53.7, lng: Double = -1.8) -> FuelStation {
|
||||||
|
FuelStation(id: id, name: "Station \(id)", brand: "Test", address: "1 High St",
|
||||||
|
postcode: "SW1A 1AA", lat: lat, lng: lng,
|
||||||
|
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Full path (app refresh): GitHub first
|
||||||
|
|
||||||
|
func testFullPathPrefersMirror() async throws {
|
||||||
|
let mirror = StubProvider(result: [fixtureStation()])
|
||||||
|
let relay = StubProvider(error: FuelProviderError.relayUnavailable)
|
||||||
|
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||||
|
|
||||||
|
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(stations.count, 1)
|
||||||
|
XCTAssertEqual(mirror.callCount, 1, "full path must try the GitHub mirror first")
|
||||||
|
XCTAssertEqual(relay.callCount, 0, "relay must not run when the mirror serves")
|
||||||
|
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFullPathRelaySkippedWhenDisabled() async {
|
||||||
|
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
||||||
|
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||||
|
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||||
|
XCTFail("chain must throw when the mirror fails and the relay flag is off")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFullPathRelayFallbackWhenDevFlagOn() async throws {
|
||||||
|
FuelStore.saveRelayFallbackEnabled(true)
|
||||||
|
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
||||||
|
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||||
|
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||||
|
|
||||||
|
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(stations.first?.id, "relay")
|
||||||
|
XCTAssertEqual(relay.callCount, 1, "dev flag on → relay fallback serves")
|
||||||
|
XCTAssertEqual(LiveChainProvider.lastSource, "relay")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Focused path (alert checks + widget): GitHub first too
|
||||||
|
|
||||||
|
func testFocusedPathPrefersMirror() async throws {
|
||||||
|
let mirror = StubProvider(result: [fixtureStation("mirror")])
|
||||||
|
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||||
|
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||||
|
|
||||||
|
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
||||||
|
|
||||||
|
XCTAssertEqual(stations.first?.id, "mirror", "focused path must prefer the mirror — the relay is unreachable off-LAN")
|
||||||
|
XCTAssertEqual(relay.callCount, 0)
|
||||||
|
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFocusedPathRelaySkippedWhenDisabled() async {
|
||||||
|
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
||||||
|
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||||
|
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
||||||
|
XCTFail("focused path must throw when the mirror fails and the relay flag is off")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFocusedPathRelayFallbackWhenDevFlagOn() async throws {
|
||||||
|
FuelStore.saveRelayFallbackEnabled(true)
|
||||||
|
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
||||||
|
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||||
|
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||||
|
|
||||||
|
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
||||||
|
|
||||||
|
XCTAssertEqual(stations.first?.id, "relay")
|
||||||
|
XCTAssertEqual(relay.callCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Mirror focused projection (replaces the relay's server-side radius)
|
||||||
|
|
||||||
|
func testMirrorFocusedFiltersByFuelRadiusDistanceAndLimit() {
|
||||||
|
let here = fixtureStation("here", lat: 53.7, lng: -1.8) // e10+others, at origin
|
||||||
|
let far = fixtureStation("far", lat: 54.0, lng: -1.8) // ~33 km away, e10
|
||||||
|
let noFuel = FuelStation(id: "noFuel", name: "Diesel Only", brand: "Test",
|
||||||
|
address: "2 High St", postcode: "SW1A 1AA",
|
||||||
|
lat: 53.7, lng: -1.8, prices: [.diesel: 144.9], priceUpdated: nil)
|
||||||
|
let d1 = fixtureStation("d1", lat: 53.701, lng: -1.8)
|
||||||
|
let d2 = fixtureStation("d2", lat: 53.702, lng: -1.8)
|
||||||
|
|
||||||
|
let out = MirrorFuelProvider.focused([far, noFuel, d2, d1, here],
|
||||||
|
near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 10)
|
||||||
|
|
||||||
|
XCTAssertEqual(out.map(\.id), ["here", "d1", "d2"], "nearest-first, fuel-only, within radius")
|
||||||
|
XCTAssertFalse(out.contains { $0.id == "far" }, "outside radius must drop")
|
||||||
|
XCTAssertFalse(out.contains { $0.id == "noFuel" }, "missing fuel must drop")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMirrorFocusedPassesThroughWithoutRadius() {
|
||||||
|
let stations = [fixtureStation("a"), fixtureStation("b")]
|
||||||
|
XCTAssertEqual(MirrorFuelProvider.focused(stations, near: nil, lng: nil, fuel: .e10, radiusKM: nil).count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMirrorFocusedLimit() {
|
||||||
|
let stations = (0..<600).map { fixtureStation("s\($0)", lat: 53.7 + Double($0) * 0.0001, lng: -1.8) }
|
||||||
|
let out = MirrorFuelProvider.focused(stations, near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 100)
|
||||||
|
XCTAssertEqual(out.count, 500, "focused projection must stay bounded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Mirror day-cache decision
|
||||||
|
|
||||||
|
func testDumpCacheReuseDecision() {
|
||||||
|
XCTAssertTrue(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: "2026-08-15"),
|
||||||
|
"same day → reuse the cached dump, no ~2.8 MB re-download")
|
||||||
|
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-14", latestDay: "2026-08-15"))
|
||||||
|
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: nil, latestDay: "2026-08-15"))
|
||||||
|
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Beacon
|
||||||
|
|
||||||
|
func testBeaconURLCarriesAppAttribution() {
|
||||||
|
guard let url = FuelBeacon.beaconURL(source: "github", n: 8022) else {
|
||||||
|
return XCTFail("beacon URL must build")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(url.absoluteString.contains("api/v1/widget-diag"))
|
||||||
|
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||||
|
XCTAssertTrue(items.contains(URLQueryItem(name: "intent", value: "app-live")))
|
||||||
|
XCTAssertTrue(items.contains(URLQueryItem(name: "source", value: "github")))
|
||||||
|
XCTAssertTrue(items.contains(URLQueryItem(name: "n", value: "8022")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBeaconGatedByDevFlag() {
|
||||||
|
FuelBeacon.isEnabled = true
|
||||||
|
FuelStore.saveRelayFallbackEnabled(false)
|
||||||
|
XCTAssertFalse(FuelBeacon.shouldFire, "consumers: beacon must never fire (no local-network attempt)")
|
||||||
|
FuelStore.saveRelayFallbackEnabled(true)
|
||||||
|
XCTAssertTrue(FuelBeacon.shouldFire, "dev flag on: beacon fires for attribution")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,17 @@
|
|||||||
// 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: ActivityConfiguration shares ONE content view across the
|
||||||
|
// Lock Screen, banner, and the CarPlay small slot — there is no per-platform
|
||||||
|
// closure. So this view provides two layouts and lets ViewThatFits pick by
|
||||||
|
// available width:
|
||||||
|
// • richBody — the full three-column design (glyph · fuel+station · price),
|
||||||
|
// wins wherever there's Lock Screen width (it carries an
|
||||||
|
// explicit minWidth so it can never be squeezed into the car).
|
||||||
|
// • compactBody — a minimal price-strip (glyph+fuel+price, station caption
|
||||||
|
// below) that wins in the CarPlay/Apple Watch Smart Stack
|
||||||
|
// small slot.
|
||||||
|
|
||||||
import ActivityKit
|
import ActivityKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -17,7 +28,7 @@ import WidgetKit
|
|||||||
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 {
|
||||||
@@ -44,43 +55,81 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Link(destination: context.state.mapsURL) {
|
Link(destination: context.state.mapsURL) {
|
||||||
HStack(spacing: 12) {
|
ViewThatFits(in: .horizontal) {
|
||||||
// LEFT — station brand glyph
|
// rich first — wins on full-width Lock Screen / banner
|
||||||
Image(systemName: "fuelpump.circle.fill")
|
richBody
|
||||||
.font(.system(size: 32))
|
// CarPlay / Watch small slot is far narrower than this,
|
||||||
.foregroundStyle(.green, .white)
|
// so ViewThatFits reliably falls through to compactBody.
|
||||||
.frame(width: 40, height: 40)
|
.frame(minWidth: 280)
|
||||||
|
// compact fallback — the car's small supplemental slot
|
||||||
// MIDDLE — fuel + station
|
compactBody
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Cheapest \(context.state.fuel.displayName)")
|
|
||||||
.font(.headline)
|
|
||||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
|
|
||||||
// RIGHT — price
|
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
|
||||||
FuelStore.priceTextAttributed(context.state.pricePence,
|
|
||||||
style: context.state.priceDisplayStyle,
|
|
||||||
size: 22, weight: .bold)
|
|
||||||
Text("Tap for directions")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
|
||||||
|
private var richBody: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// LEFT — station brand glyph
|
||||||
|
Image(systemName: "fuelpump.circle.fill")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundStyle(.green, .white)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
// MIDDLE — fuel + station
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Cheapest \(context.state.fuel.displayName)")
|
||||||
|
.font(.headline)
|
||||||
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// RIGHT — price
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
|
style: context.state.priceDisplayStyle,
|
||||||
|
size: 22, weight: .bold)
|
||||||
|
Text("Tap for directions")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal strip for the small CarPlay / Watch Smart Stack slot:
|
||||||
|
/// glyph + fuel left, bold price right, truncated station below.
|
||||||
|
private var compactBody: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "fuelpump.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text(context.state.fuel.displayName)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dynamic Island expanded regions + compact trailing — price only.
|
/// Dynamic Island expanded regions + compact trailing — price only.
|
||||||
@@ -140,4 +189,4 @@ extension FuelBoardLiveActivityAttributes.ContentState {
|
|||||||
var mapsURL: URL {
|
var mapsURL: URL {
|
||||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,20 +2,9 @@ import WidgetKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppIntents
|
import AppIntents
|
||||||
|
|
||||||
// Fuel colour wheel — mirrors the app's palette in StationsView.swift
|
// Fuel colour wheel lives in Shared/FuelPriceWidgetViews.swift (moved from
|
||||||
// (FuelType.tintColor): green = unleaded (#30D158), yellow = premium
|
// here 2026-08-16 so the app's widget preview screen uses the same colours).
|
||||||
// (#FFD60A), cyan = diesel (#64D2FF). Kept here (widget target) because
|
// Shared/FuelStore.swift stays Foundation-only by design.
|
||||||
// Shared/FuelStore.swift is Foundation-only by design; if the app's copy
|
|
||||||
// ever moves to Shared, DELETE this duplicate to avoid redeclaration.
|
|
||||||
private extension FuelType {
|
|
||||||
var fuelColor: Color {
|
|
||||||
switch self {
|
|
||||||
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
|
||||||
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
|
||||||
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FuelBoard widget — petrol stations near you. ONE widget kind, TWO sizes:
|
// FuelBoard widget — petrol stations near you. ONE widget kind, TWO sizes:
|
||||||
// .systemSmall: single station (the first of the configured result — the
|
// .systemSmall: single station (the first of the configured result — the
|
||||||
@@ -84,14 +73,8 @@ struct FuelPriceWidget: Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FuelPriceEntry: TimelineEntry {
|
struct FuelPriceEntry: TimelineEntry {
|
||||||
let date: Date
|
let data: FuelPriceWidgetEntryData
|
||||||
let stations: [FuelStation] // already filtered + sorted per config
|
var date: Date { data.date }
|
||||||
let fuel: FuelType
|
|
||||||
let sort: SortMode // per-widget: cheapest | closest
|
|
||||||
let isFavourites: Bool // favourites mode: pinned stations, cheapest-first
|
|
||||||
let location: Coordinate?
|
|
||||||
let locationSource: String // "live" | "cached" | "none"
|
|
||||||
let unit: DistanceUnit // user's display unit for distances
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & WidgetConfigValues>: AppIntentTimelineProvider {
|
struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & WidgetConfigValues>: AppIntentTimelineProvider {
|
||||||
@@ -109,9 +92,11 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
.filter { $0.prices[.e10] != nil }
|
.filter { $0.prices[.e10] != nil }
|
||||||
.sorted { $0.prices[.e10]! < $1.prices[.e10]! }
|
.sorted { $0.prices[.e10]! < $1.prices[.e10]! }
|
||||||
return FuelPriceEntry(
|
return FuelPriceEntry(
|
||||||
date: Date(), stations: Array(sample.prefix(8)),
|
data: FuelPriceWidgetEntryData(
|
||||||
fuel: .e10, sort: .cheapest, isFavourites: false,
|
date: Date(), stations: Array(sample.prefix(8)),
|
||||||
location: nil, locationSource: "none", unit: unit
|
fuel: .e10, sort: .cheapest, isFavourites: false,
|
||||||
|
location: nil, locationSource: "none", unit: unit
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,31 +127,36 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
/// extension and what it produced. Deliberately outside the timeline
|
/// extension and what it produced. Deliberately outside the timeline
|
||||||
/// result — can never affect rendering.
|
/// result — can never affect rendering.
|
||||||
private func writeDiagBeacon(entry: FuelPriceEntry) {
|
private func writeDiagBeacon(entry: FuelPriceEntry) {
|
||||||
|
let d = entry.data
|
||||||
let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
|
let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
|
||||||
let first = entry.stations.first
|
let first = d.stations.first
|
||||||
let json = """
|
let json = """
|
||||||
{"intent":"\(intentType)","source":"\(entry.locationSource)",\
|
{"intent":"\(intentType)","source":"\(d.locationSource)",\
|
||||||
"n":\(entry.stations.count),"fuel":"\(entry.fuel.rawValue)",\
|
"n":\(d.stations.count),"fuel":"\(d.fuel.rawValue)",\
|
||||||
"sort":"\(entry.sort.rawValue)","fav":\(entry.isFavourites),\
|
"sort":"\(d.sort.rawValue)","fav":\(d.isFavourites),\
|
||||||
"station":"\(first?.name ?? "")","price":\(first?.prices[entry.fuel] ?? -1)}
|
"station":"\(first?.name ?? "")","price":\(first?.prices[d.fuel] ?? -1)}
|
||||||
"""
|
"""
|
||||||
FuelStore.saveWidgetDiag(json, intentType: intentType)
|
FuelStore.saveWidgetDiag(json, intentType: intentType)
|
||||||
|
// The relay GET is dev-only (same flag as the app beacon): consumers
|
||||||
|
// must never make a local-network attempt. The local app-group diag
|
||||||
|
// write above stays — Settings → Widget Diagnostics reads that.
|
||||||
|
guard FuelStore.loadRelayFallbackEnabled() else { return }
|
||||||
guard var components = URLComponents(
|
guard var components = URLComponents(
|
||||||
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"),
|
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"),
|
||||||
resolvingAgainstBaseURL: false
|
resolvingAgainstBaseURL: false
|
||||||
) else { return }
|
) else { return }
|
||||||
components.queryItems = [
|
components.queryItems = [
|
||||||
URLQueryItem(name: "intent", value: intentType),
|
URLQueryItem(name: "intent", value: intentType),
|
||||||
URLQueryItem(name: "source", value: entry.locationSource),
|
URLQueryItem(name: "source", value: d.locationSource),
|
||||||
URLQueryItem(name: "n", value: String(entry.stations.count)),
|
URLQueryItem(name: "n", value: String(d.stations.count)),
|
||||||
URLQueryItem(name: "fuel", value: entry.fuel.rawValue),
|
URLQueryItem(name: "fuel", value: d.fuel.rawValue),
|
||||||
URLQueryItem(name: "sort", value: entry.sort.rawValue),
|
URLQueryItem(name: "sort", value: d.sort.rawValue),
|
||||||
URLQueryItem(name: "fav", value: entry.isFavourites ? "1" : "0"),
|
URLQueryItem(name: "fav", value: d.isFavourites ? "1" : "0"),
|
||||||
URLQueryItem(name: "station", value: first?.name ?? ""),
|
URLQueryItem(name: "station", value: first?.name ?? ""),
|
||||||
URLQueryItem(name: "price", value: String(first?.prices[entry.fuel] ?? -1)),
|
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
|
||||||
]
|
]
|
||||||
guard let url = components.url else { return }
|
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)
|
||||||
}
|
}
|
||||||
@@ -290,17 +280,27 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
}
|
}
|
||||||
|
|
||||||
return FuelPriceEntry(
|
return FuelPriceEntry(
|
||||||
date: Date(), stations: Array(ordered.prefix(8)),
|
data: FuelPriceWidgetEntryData(
|
||||||
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
date: Date(), stations: Array(ordered.prefix(8)),
|
||||||
location: location, locationSource: source,
|
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
||||||
unit: unit
|
location: location, locationSource: source,
|
||||||
|
unit: unit
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One focused fetch from the relay around the widget's location — small
|
/// One focused fetch around the widget's location. GitHub mirror FIRST —
|
||||||
/// response (radius + limit 500), bounded so a dead relay can't stall the
|
/// HTTPS, works anywhere, and the app-group day-cache usually makes it a
|
||||||
/// timeline. Returns nil on any failure so callers fall back to cache.
|
/// local decode rather than a 2.8 MB download. The LAN relay only joins
|
||||||
|
/// behind the dev flag (fuelboard.relayFallback); consumers must never
|
||||||
|
/// attempt local-network access. Returns nil on any failure so callers
|
||||||
|
/// fall back to cache/placeholder.
|
||||||
private static func fetchFocused(near location: Coordinate, fuel: FuelType, radiusKM: Double) async -> [FuelStation]? {
|
private static func fetchFocused(near location: Coordinate, fuel: FuelType, radiusKM: Double) async -> [FuelStation]? {
|
||||||
|
if let stations = try? await MirrorFuelProvider().fetchStations(
|
||||||
|
near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM) {
|
||||||
|
return stations
|
||||||
|
}
|
||||||
|
guard FuelStore.loadRelayFallbackEnabled() else { return nil }
|
||||||
var components = URLComponents(
|
var components = URLComponents(
|
||||||
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
|
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
|
||||||
resolvingAgainstBaseURL: false
|
resolvingAgainstBaseURL: false
|
||||||
@@ -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 }
|
||||||
@@ -322,11 +322,16 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fuel-only UK-wide fetch (no lat/lng/radius) — the relay returns the
|
/// Fuel-only UK-wide fetch (no lat/lng/radius) — used when the widget has
|
||||||
/// cheapest-first dataset for the fuel, bounded by limit. Used when the
|
/// no location fix so the face shows real stations instead of sample
|
||||||
/// widget has no location fix so the face shows real stations instead of
|
/// data. GitHub mirror first (full dump + local fuel filter), relay only
|
||||||
/// sample data. Same timeout/bounded semantics as fetchFocused.
|
/// behind the dev flag. Same nil-on-failure semantics as fetchFocused.
|
||||||
private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [FuelStation]? {
|
private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [FuelStation]? {
|
||||||
|
if let stations = try? await MirrorFuelProvider().fetchStations(
|
||||||
|
near: nil, lng: nil, fuel: fuel, radiusKM: nil) {
|
||||||
|
return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit))
|
||||||
|
}
|
||||||
|
guard FuelStore.loadRelayFallbackEnabled() else { return nil }
|
||||||
var components = URLComponents(
|
var components = URLComponents(
|
||||||
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
|
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
|
||||||
resolvingAgainstBaseURL: false
|
resolvingAgainstBaseURL: false
|
||||||
@@ -335,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 }
|
||||||
@@ -351,123 +356,6 @@ struct FuelPriceWidgetView: View {
|
|||||||
let entry: FuelPriceEntry
|
let entry: FuelPriceEntry
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
FuelPriceWidgetContent(entry: entry.data, family: family)
|
||||||
if entry.stations.isEmpty {
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Image(systemName: entry.isFavourites ? "star" : "fuelpump")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(entry.isFavourites
|
|
||||||
? "No favourite \(entry.fuel.displayName) stations"
|
|
||||||
: "No \(entry.fuel.displayName) stations")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} else if family == .systemSmall {
|
|
||||||
singleStation
|
|
||||||
} else {
|
|
||||||
stationList
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var heading: String {
|
|
||||||
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
|
|
||||||
return entry.sort == .closest
|
|
||||||
? "Closest \(entry.fuel.displayName)"
|
|
||||||
: "Cheapest \(entry.fuel.displayName)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var singleStation: some View {
|
|
||||||
let station = entry.stations[0]
|
|
||||||
return VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "fuelpump.fill")
|
|
||||||
.foregroundStyle(entry.fuel.fuelColor)
|
|
||||||
Text(heading)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Text(station.name)
|
|
||||||
.font(.headline)
|
|
||||||
.lineLimit(1)
|
|
||||||
if let price = station.prices[entry.fuel] {
|
|
||||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
|
||||||
}
|
|
||||||
if let location = entry.location {
|
|
||||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
Text("Tap for directions")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
||||||
.widgetURL(station.widgetDirectionsURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many station rows each family can fit: medium widgets are short
|
|
||||||
/// (158pt) so 3 rows is the safe cap; large/extraLarge have ~2× the height
|
|
||||||
/// and fit 5 compact rows. Small widgets use singleStation instead.
|
|
||||||
private var maxRows: Int {
|
|
||||||
switch family {
|
|
||||||
case .systemMedium: return 3
|
|
||||||
default: return 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var stationList: some View {
|
|
||||||
let cheapest = entry.stations.compactMap { $0.prices[entry.fuel] }.min()
|
|
||||||
return VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "fuelpump.fill")
|
|
||||||
.foregroundStyle(entry.fuel.fuelColor)
|
|
||||||
Text(heading)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
|
||||||
Text(entry.isFavourites ? "cheapest first"
|
|
||||||
: (entry.locationSource == "none" ? "by price" : "near you"))
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
ForEach(entry.stations.prefix(maxRows)) { station in
|
|
||||||
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(station.name)
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
if let location = entry.location {
|
|
||||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
if let price = station.prices[entry.fuel] {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Circle()
|
|
||||||
.fill(ragColor(for: price, cheapest: cheapest))
|
|
||||||
.frame(width: 6, height: 6)
|
|
||||||
FuelStore.priceTextAttributed(price, size: 12, weight: .bold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ragColor(for price: Double, cheapest: Double?) -> Color {
|
|
||||||
guard let cheapest else { return .gray }
|
|
||||||
switch RAGRating.rating(price: price, cheapest: cheapest) {
|
|
||||||
case .green: return .green
|
|
||||||
case .amber: return .orange
|
|
||||||
case .red: return .red
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>1C8F.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ protocol FuelPriceProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum FuelPriceProvider {
|
enum FuelPriceProvider {
|
||||||
/// Default: the keyless relay (full-UK Fuel Finder data). Falls back to
|
/// Default: the live chain — GitHub mirror (primary, off-LAN) → LAN
|
||||||
/// the England-wide sample set when the relay is unreachable.
|
/// relay (fallback) → bundled dump (last resort in the app target).
|
||||||
static let active: FuelPriceProviding = RelayFuelProvider()
|
static let active: FuelPriceProviding = LiveChainProvider()
|
||||||
|
|
||||||
/// Decodes a relay payload into stations, applying the defensive price
|
/// Decodes a relay payload into stations, applying the defensive price
|
||||||
/// band. Internal so the unit-test target can exercise the guard.
|
/// band. Internal so the unit-test target can exercise the guard.
|
||||||
@@ -189,7 +189,7 @@ struct RelayMeta: Codable, Equatable {
|
|||||||
|
|
||||||
/// Representative stations spread across ENGLAND (one cluster per region) so
|
/// Representative stations spread across ENGLAND (one cluster per region) so
|
||||||
/// the fallback works anywhere in the country — it is NOT tied to one town.
|
/// the fallback works anywhere in the country — it is NOT tied to one town.
|
||||||
/// Real England-wide data comes from the relay (full-UK Fuel Finder CSV/API).
|
/// Real UK-wide data comes from the relay (full-UK Fuel Finder CSV/API).
|
||||||
/// Prices in pence/litre.
|
/// Prices in pence/litre.
|
||||||
struct SampleFuelProvider: FuelPriceProviding {
|
struct SampleFuelProvider: FuelPriceProviding {
|
||||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
@@ -296,12 +296,15 @@ struct FuelFinderProvider: FuelPriceProviding {
|
|||||||
enum FuelProviderError: LocalizedError {
|
enum FuelProviderError: LocalizedError {
|
||||||
case notImplemented
|
case notImplemented
|
||||||
case relayUnavailable
|
case relayUnavailable
|
||||||
|
case mirrorUnavailable
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .notImplemented:
|
case .notImplemented:
|
||||||
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
|
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
|
||||||
case .relayUnavailable:
|
case .relayUnavailable:
|
||||||
return "FuelBoard Relay unreachable — showing cached/sample data."
|
return "FuelBoard Relay unreachable — showing cached/sample data."
|
||||||
|
case .mirrorUnavailable:
|
||||||
|
return "GitHub price mirror unreachable."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
// Shared widget rendering — compiled by BOTH the widget extension and the
|
||||||
|
// app target. The app uses it for the in-app widget preview screen (launch
|
||||||
|
// arg `-widgets`); the widget extension uses it for real timelines.
|
||||||
|
// WidgetKit is available to the app target, so WidgetFamily is fine here.
|
||||||
|
// NOTE: FuelBoardTests only symlinks specific Shared files, so this file is
|
||||||
|
// NOT compiled by the SPM test target (it needs WidgetKit).
|
||||||
|
|
||||||
|
// Fuel colour wheel — mirrors the app's palette in StationsView.swift
|
||||||
|
// (FuelType.tintColor): green = unleaded (#30D158), yellow = premium
|
||||||
|
// (#FFD60A), cyan = diesel (#64D2FF). Lives here because both the widget and
|
||||||
|
// the app preview need it; Shared/FuelStore.swift stays Foundation-only.
|
||||||
|
// (Previously a private extension in the widget target — deleted on the move.)
|
||||||
|
private extension FuelType {
|
||||||
|
var fuelColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
||||||
|
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
||||||
|
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain data (no WidgetKit types) so both the widget and the app preview can
|
||||||
|
/// construct entries. The widget's `FuelPriceEntry: TimelineEntry` wraps this.
|
||||||
|
struct FuelPriceWidgetEntryData {
|
||||||
|
let date: Date
|
||||||
|
let stations: [FuelStation] // already filtered + sorted per config
|
||||||
|
let fuel: FuelType
|
||||||
|
let sort: SortMode // per-widget: cheapest | closest
|
||||||
|
let isFavourites: Bool // favourites mode: pinned stations, cheapest-first
|
||||||
|
let location: Coordinate?
|
||||||
|
let locationSource: String // "live" | "cached" | "none"
|
||||||
|
let unit: DistanceUnit // user's display unit for distances
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The widget face, parameterised by family so the app can preview it.
|
||||||
|
/// `isWidget` controls WidgetKit-only chrome (widgetURL / Link rows) — in-app
|
||||||
|
/// previews pass false.
|
||||||
|
struct FuelPriceWidgetContent: View {
|
||||||
|
let entry: FuelPriceWidgetEntryData
|
||||||
|
let family: WidgetFamily
|
||||||
|
var isWidget = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if entry.stations.isEmpty {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: entry.isFavourites ? "star" : "fuelpump")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(entry.isFavourites
|
||||||
|
? "No favourite \(entry.fuel.displayName) stations"
|
||||||
|
: "No \(entry.fuel.displayName) stations")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if family == .systemSmall {
|
||||||
|
singleStation
|
||||||
|
} else {
|
||||||
|
stationList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var heading: String {
|
||||||
|
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
|
||||||
|
return entry.sort == .closest
|
||||||
|
? "Closest \(entry.fuel.displayName)"
|
||||||
|
: "Cheapest \(entry.fuel.displayName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var singleStation: some View {
|
||||||
|
let station = entry.stations[0]
|
||||||
|
return VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "fuelpump.fill")
|
||||||
|
.foregroundStyle(entry.fuel.fuelColor)
|
||||||
|
Text(heading)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(station.name)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
if let price = station.prices[entry.fuel] {
|
||||||
|
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||||
|
}
|
||||||
|
if let location = entry.location {
|
||||||
|
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("Tap for directions")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
|
.modifier(WidgetURLModifier(url: isWidget ? station.widgetDirectionsURL : nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many station rows each family can fit: medium widgets are short
|
||||||
|
/// (158pt) so 3 rows is the safe cap; large/extraLarge have ~2× the height
|
||||||
|
/// and fit 5 compact rows. Small widgets use singleStation instead.
|
||||||
|
private var maxRows: Int {
|
||||||
|
switch family {
|
||||||
|
case .systemMedium: return 3
|
||||||
|
default: return 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stationList: some View {
|
||||||
|
let cheapest = entry.stations.compactMap { $0.prices[entry.fuel] }.min()
|
||||||
|
return VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "fuelpump.fill")
|
||||||
|
.foregroundStyle(entry.fuel.fuelColor)
|
||||||
|
Text(heading)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(entry.isFavourites ? "cheapest first"
|
||||||
|
: (entry.locationSource == "none" ? "by price" : "near you"))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach(entry.stations.prefix(maxRows)) { station in
|
||||||
|
if isWidget {
|
||||||
|
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
|
||||||
|
row(station, cheapest: cheapest)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else {
|
||||||
|
row(station, cheapest: cheapest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ station: FuelStation, cheapest: Double?) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(station.name)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
if let location = entry.location {
|
||||||
|
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let price = station.prices[entry.fuel] {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(ragColor(for: price, cheapest: cheapest))
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
FuelStore.priceTextAttributed(price, size: 12, weight: .bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ragColor(for price: Double, cheapest: Double?) -> Color {
|
||||||
|
guard let cheapest else { return .gray }
|
||||||
|
switch RAGRating.rating(price: price, cheapest: cheapest) {
|
||||||
|
case .green: return .green
|
||||||
|
case .amber: return .orange
|
||||||
|
case .red: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies widgetURL only when a URL is given (WidgetKit-only modifier).
|
||||||
|
private struct WidgetURLModifier: ViewModifier {
|
||||||
|
let url: URL?
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if let url {
|
||||||
|
content.widgetURL(url)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
#if canImport(AppIntents)
|
||||||
|
import AppIntents
|
||||||
|
#endif
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Fuel types
|
// MARK: - Fuel types
|
||||||
@@ -28,6 +31,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 +72,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 +99,10 @@ enum FuelType: String, Codable, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#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 +242,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,8 +344,12 @@ 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 liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle
|
static let liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle
|
||||||
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
|
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
|
||||||
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
||||||
@@ -515,7 +564,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]))
|
||||||
@@ -525,7 +574,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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,6 +601,22 @@ struct FuelStore {
|
|||||||
saveString(enabled ? "1" : "0", service: debugModeKey)
|
saveString(enabled ? "1" : "0", service: debugModeKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Relay fallback (dev-only)
|
||||||
|
|
||||||
|
/// Dev-only switch: re-inserts the LAN relay into the live chain as a
|
||||||
|
/// fallback for home testing. OFF by default — consumers must never make
|
||||||
|
/// a local-network attempt (the relay is unreachable off the developer's
|
||||||
|
/// LAN, and the attempt itself would fire the iOS Local Network prompt).
|
||||||
|
/// Toggled from Settings → Debug; keychain-first so it survives the
|
||||||
|
/// delete → reinstall test loop like every other small setting.
|
||||||
|
static func loadRelayFallbackEnabled() -> Bool {
|
||||||
|
loadString(service: relayFallbackKey) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveRelayFallbackEnabled(_ enabled: Bool) {
|
||||||
|
saveString(enabled ? "1" : "0", service: relayFallbackKey)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Favourites
|
// MARK: Favourites
|
||||||
|
|
||||||
/// A favourite pins a station FOR ONE fuel type. Starring a row while
|
/// A favourite pins a station FOR ONE fuel type. Starring a row while
|
||||||
@@ -591,6 +656,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
|
||||||
@@ -674,6 +757,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 {
|
||||||
@@ -765,6 +890,27 @@ 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 {
|
||||||
@@ -787,7 +933,7 @@ struct FuelStore {
|
|||||||
// 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,
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain).
|
||||||
|
//
|
||||||
|
// The mirror (aptonline/fuelboard-data, pushed daily by the LAN relay) is the
|
||||||
|
// PRODUCTION data source: its day files are the raw relay /api/v1/stations
|
||||||
|
// envelope, so the shared decode works verbatim. Fetching from
|
||||||
|
// raw.githubusercontent.com makes live prices work fully off-LAN — App
|
||||||
|
// Review cannot reach a LAN relay, and consumers never have one to reach —
|
||||||
|
// and the bundled dump (app target) is the no-network last resort.
|
||||||
|
//
|
||||||
|
// Chain (LiveChainProvider) — GitHub first, EVERYWHERE:
|
||||||
|
// GitHub mirror → [dev-only LAN relay] → (app target: bundled dump in the
|
||||||
|
// ContentView catch, widget: cache → placeholder)
|
||||||
|
// The relay is NOT part of the consumer chain at all: it is unreachable from
|
||||||
|
// any phone outside the developer's LAN, and even attempting it from a
|
||||||
|
// consumer phone would fire the iOS Local Network permission prompt for an
|
||||||
|
// address that can never be reached. It remains reachable only behind the
|
||||||
|
// hidden dev flag `fuelboard.relayFallback` (Settings → Debug) for home
|
||||||
|
// testing. The relay keeps its server-side job (GOV.UK ingest + daily mirror
|
||||||
|
// push) unchanged.
|
||||||
|
//
|
||||||
|
// Telemetry (FuelBeacon): raw GitHub fetches are invisible to repo analytics
|
||||||
|
// (page views/clones only), so every chain fetch opportunistically pings the
|
||||||
|
// relay's existing widget-diag route (zero relay changes) — but ONLY when the
|
||||||
|
// same dev flag is on, so consumers never make a local-network attempt.
|
||||||
|
// /stats app-hit spikes (dev flag on) = GitHub path failing → fallback active.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Mirror provider (GitHub raw)
|
||||||
|
|
||||||
|
/// Fetches CURRENT prices from the GitHub mirror. Same envelope as the
|
||||||
|
/// relay, so `FuelPriceProvider.decodeStations` applies unchanged.
|
||||||
|
struct MirrorFuelProvider: FuelPriceProviding {
|
||||||
|
var baseURL = FuelHistoryStore.mirrorBase
|
||||||
|
|
||||||
|
/// Envelope metadata from the most recent successful mirror fetch —
|
||||||
|
/// surfaced via `LiveChainProvider.latestMeta` in Settings → About.
|
||||||
|
static var latestMeta: RelayMeta?
|
||||||
|
|
||||||
|
/// App-group cache of the last fetched FULL dump, keyed by snapshot day.
|
||||||
|
/// The mirror pushes once/day, so between pushes the app reuses the
|
||||||
|
/// cached dump instead of re-downloading ~2.8 MB at every 12 h gate.
|
||||||
|
static let liveDumpCacheKey = "fuelboard.liveDumpCache"
|
||||||
|
|
||||||
|
struct DumpCache: Codable {
|
||||||
|
let day: String
|
||||||
|
let data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadDumpCache() -> DumpCache? {
|
||||||
|
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
||||||
|
let raw = defaults.data(forKey: liveDumpCacheKey),
|
||||||
|
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveDumpCache(day: String, data: Data) {
|
||||||
|
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
||||||
|
let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return }
|
||||||
|
defaults.set(raw, forKey: liveDumpCacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the cached dump is already the freshest the mirror has —
|
||||||
|
/// avoids the ~2.8 MB re-download when the mirror hasn't pushed a new day.
|
||||||
|
static func canReuseCache(cachedDay: String?, latestDay: String?) -> Bool {
|
||||||
|
guard let cachedDay, let latestDay else { return false }
|
||||||
|
return cachedDay == latestDay
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focused-path projection, replacing what the relay's radius endpoint
|
||||||
|
/// used to do server-side: stations WITH the fuel, within radius of the
|
||||||
|
/// point, nearest first, bounded. The alert path re-checks radius and
|
||||||
|
/// fuel itself afterwards, so this only narrows the payload.
|
||||||
|
static func focused(_ stations: [FuelStation], near lat: Double?, lng: Double?,
|
||||||
|
fuel: FuelType, radiusKM: Double?, limit: Int = 500) -> [FuelStation] {
|
||||||
|
guard let lat, let lng, let radiusKM else { return stations }
|
||||||
|
return Array(
|
||||||
|
stations
|
||||||
|
.filter { $0.prices[fuel] != nil && $0.distanceKM(to: lat, lng2: lng) <= radiusKM }
|
||||||
|
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
||||||
|
.prefix(limit)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
|
// 1. Tiny pointer fetch — the freshest snapshot day.
|
||||||
|
guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL),
|
||||||
|
let day = latest.availableTo ?? latest.date else {
|
||||||
|
throw FuelProviderError.mirrorUnavailable
|
||||||
|
}
|
||||||
|
// 2. Reuse the cached full dump when the mirror hasn't pushed a new day.
|
||||||
|
let cache = Self.loadDumpCache()
|
||||||
|
let data: Data
|
||||||
|
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
|
||||||
|
data = cached
|
||||||
|
} else {
|
||||||
|
// Bypass the HTTP cache + short timeout: an offline refresh must
|
||||||
|
// FAIL (→ offline banner), never re-serve a stale cached dump as
|
||||||
|
// a "successful" live fetch.
|
||||||
|
var req = URLRequest(url: FuelHistoryStore.historyFileURL(day: day, base: baseURL))
|
||||||
|
req.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
req.timeoutInterval = 10
|
||||||
|
let (fetched, response) = try await URLSession.shared.data(for: req)
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
|
throw FuelProviderError.mirrorUnavailable
|
||||||
|
}
|
||||||
|
Self.saveDumpCache(day: day, data: fetched)
|
||||||
|
data = fetched
|
||||||
|
}
|
||||||
|
let stations = try FuelPriceProvider.decodeStations(from: data)
|
||||||
|
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
|
||||||
|
return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Live chain provider
|
||||||
|
|
||||||
|
/// The live price chain — GitHub first, everywhere. The LAN relay is NOT part
|
||||||
|
/// of the consumer chain: consumers can never reach it (and must never even
|
||||||
|
/// attempt it — that would prompt for Local Network access on a stranger's
|
||||||
|
/// phone). It only joins behind the hidden dev flag `fuelboard.relayFallback`
|
||||||
|
/// (Settings → Debug) for the developer's own home testing. Whichever leg
|
||||||
|
/// serves is recorded for the About section + telemetry.
|
||||||
|
struct LiveChainProvider: FuelPriceProviding {
|
||||||
|
var mirror: FuelPriceProviding
|
||||||
|
var relay: FuelPriceProviding
|
||||||
|
|
||||||
|
/// Which leg served the last fetch — Settings → About metadata + beacon.
|
||||||
|
static var latestMeta: RelayMeta?
|
||||||
|
static var lastSource: String?
|
||||||
|
|
||||||
|
init(mirror: FuelPriceProviding = MirrorFuelProvider(),
|
||||||
|
relay: FuelPriceProviding = RelayFuelProvider()) {
|
||||||
|
self.mirror = mirror
|
||||||
|
self.relay = relay
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
|
do {
|
||||||
|
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||||
|
Self.latestMeta = MirrorFuelProvider.latestMeta
|
||||||
|
Self.lastSource = "github"
|
||||||
|
FuelBeacon.fire(source: "github", n: stations.count)
|
||||||
|
return stations
|
||||||
|
} catch {
|
||||||
|
// Dev-only fallback: consumers never reach this branch — the flag
|
||||||
|
// is off by default and the flag itself lives in the Debug section.
|
||||||
|
guard FuelStore.loadRelayFallbackEnabled() else { throw error }
|
||||||
|
let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||||
|
Self.latestMeta = RelayFuelProvider.latestMeta
|
||||||
|
Self.lastSource = "relay"
|
||||||
|
FuelBeacon.fire(source: "relay", n: stations.count)
|
||||||
|
return stations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Telemetry beacon
|
||||||
|
|
||||||
|
/// Fire-and-forget attribution for the LIVE chain (see header note). Never
|
||||||
|
/// awaited, never user-visible; a failed ping is silent. Fires ONLY when the
|
||||||
|
/// dev relay flag is on — consumers must never make a local-network attempt,
|
||||||
|
/// and an off-LAN ping failing silently is itself the reachability datum.
|
||||||
|
/// Disabled in unit tests via `isEnabled`.
|
||||||
|
enum FuelBeacon {
|
||||||
|
static var isEnabled = true
|
||||||
|
|
||||||
|
static var shouldFire: Bool {
|
||||||
|
isEnabled && FuelStore.loadRelayFallbackEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fire(source: String, n: Int, timeout: TimeInterval = 2) {
|
||||||
|
guard shouldFire,
|
||||||
|
let url = beaconURL(source: source, n: n) else { return }
|
||||||
|
var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout)
|
||||||
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
Task { _ = try? await URLSession.shared.data(for: request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Testable URL construction — hits the relay's existing widget-diag
|
||||||
|
/// beacon route with app attribution (zero relay changes).
|
||||||
|
static func beaconURL(source: String, n: Int, base: URL = RelayFuelProvider().baseURL) -> URL? {
|
||||||
|
guard var components = URLComponents(
|
||||||
|
url: base.appendingPathComponent("api/v1/widget-diag"),
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
) else { return nil }
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "intent", value: "app-live"),
|
||||||
|
URLQueryItem(name: "source", value: source),
|
||||||
|
URLQueryItem(name: "n", value: String(n)),
|
||||||
|
]
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>logFormatVersion</key>
|
||||||
|
<integer>12</integer>
|
||||||
|
<key>logs</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>logFormatVersion</key>
|
||||||
|
<integer>12</integer>
|
||||||
|
<key>logs</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>logFormatVersion</key>
|
||||||
|
<integer>12</integer>
|
||||||
|
<key>logs</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>logFormatVersion</key>
|
||||||
|
<integer>12</integer>
|
||||||
|
<key>logs</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||