Compare commits
@@ -13,20 +13,30 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
|
|
||||||
## P1 — Soon
|
## P1 — Soon
|
||||||
|
|
||||||
- [ ] **Move relay + mirror off the Mac mini (plan for 2026-08-16)** — dedicated
|
- [x] **Move relay + mirror off the Mac mini (DONE 2026-08-16)** — dedicated
|
||||||
Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh`
|
Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh`
|
||||||
(CT ~110, hostname `fuelboard-relay`, 1 core/1 GB/8 GB, static IP
|
(CT **111**, hostname **`fuelboard-relay`**, 1 core/512 MB/2 GB, static IP
|
||||||
~192.168.1.150, plain LXC — NO Docker layer). Scope: relay AND mirror both
|
**192.168.1.113**, plain LXC — NO Docker layer). Scope: relay AND mirror
|
||||||
move (full Mac independence; relay binds loopback only, GitHub is the only
|
both moved (full Mac independence; relays bind **loopback only**, GitHub is
|
||||||
public surface). Steps: user creates CT on pve → install Python (trixie =
|
the only public surface). What landed: Python 3.13.5 (trixie) boots both
|
||||||
3.13 vs Mac 3.14 — verify relay boots) + relay source + `.env` (GOV.UK
|
relays (relay 8788 demo/CSV + relay-api 8789 `source: api`, 8,024 stations,
|
||||||
token) → copy deploy key `~/.ssh/fuelboard_deploy` + `github-fuelboard-data`
|
~2 min first sync, 0 failures); source at `/root/workspace/fuelboard-{relay,relay-api}`
|
||||||
ssh config → systemd `fuelboard-relay.service` (Restart=always,
|
(venvs + pip, `.env` chmod 600); deploy key `fuelboard_deploy` +
|
||||||
127.0.0.1:8789/8788) + `fuelboard-mirror.timer` (10:00 + 16:00) + service →
|
`github-fuelboard-data` ssh config + `fuelboard-data` clone at
|
||||||
relocate `mirror_push.py` (RELAY line → localhost on CT) + fuelboard-data
|
`/root/workspace/fuelboard-data` (origin = ssh alias, green + synced);
|
||||||
clone → verify one live push → remove Mac launchd
|
`mirror_push.py` → `/root/scripts/mirror_push.py` (RELAY line already
|
||||||
`com.apt.fuelboard-{relay,mirror}`. Open: confirm IP; logs move to
|
`127.0.0.1:8789`); systemd `fuelboard-relay.service` +
|
||||||
journalctl.
|
`fuelboard-relay-api.service` (Restart=always, `--host 127.0.0.1`) +
|
||||||
|
`fuelboard-mirror.service` (oneshot) + `.timer` (10:00 + 16:00, Persistent,
|
||||||
|
armed — next Mon 10:00); first live push VERIFIED from outside
|
||||||
|
(commit `735a292`, latest.json `data_updated 16:50:53Z`); Mac launchd
|
||||||
|
`com.apt.fuelboard-{relay,relay-api,mirror}` unloaded + plists parked in
|
||||||
|
`~/Library/LaunchAgents/fuelboard-migrated-to-ct/`; editor 8790 + IPA 8765
|
||||||
|
stay on the Mac; price watchdog `relay_price_watchdog.py` now fetches via
|
||||||
|
SSH (`~/.ssh/fuelboard_ct` → `/root/scripts/watchdog_fetch.py`). Logs via
|
||||||
|
`journalctl -u fuelboard-*` on the CT; SSH into CT: `ssh -i ~/.ssh/fuelboard_ct root@192.168.1.113`.
|
||||||
|
**Open: rootfs is 2 GB (1.1 GB free) — mirror retention ~940 MB will get
|
||||||
|
tight; rootfs grown to 8 GB on pve 2026-08-16 (5.9 GB free).**
|
||||||
|
|
||||||
- [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
|
- [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
|
||||||
(App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first
|
(App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first
|
||||||
@@ -96,6 +106,19 @@ 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).
|
||||||
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
|
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
|
||||||
@@ -121,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
|
||||||
|
|||||||
@@ -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 |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 747 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 208 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 |
@@ -15,4 +15,14 @@ enum BundledDumpProvider {
|
|||||||
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
|
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
|
||||||
return try? FuelPriceProvider.decodeStations(from: asset.data)
|
return try? FuelPriceProvider.decodeStations(from: asset.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The GOV.UK `data_updated` stamp from the bundled envelope (ISO 8601).
|
||||||
|
/// Labels the offline-data banner honestly ("Offline data from 15 Aug")
|
||||||
|
/// instead of presenting the build's snapshot as live.
|
||||||
|
static var dataUpdatedStamp: String? {
|
||||||
|
guard let asset = NSDataAsset(name: "FuelBoardDump"),
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: asset.data)
|
||||||
|
as? [String: Any] else { return nil }
|
||||||
|
return obj["data_updated"] as? String
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: Hashable {
|
||||||
|
case offlineDump(date: String)
|
||||||
|
case connectionProblem
|
||||||
|
case tip(TipStore.TipOutcome)
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -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,13 +41,73 @@ struct ContentView: View {
|
|||||||
private var effectiveAlertsRadiusKM: Double {
|
private var effectiveAlertsRadiusKM: Double {
|
||||||
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func installLocationUpdateHook() {
|
||||||
|
locationManager.onLocationUpdate = { [weak monitor] in
|
||||||
|
monitor?.update(stations: stations, favourites: refreshedFavourites,
|
||||||
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
|
// The Live Activity follows the same wake-ups — this hook
|
||||||
|
// fires on every fix incl. background significant-change
|
||||||
|
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||||
|
updateLiveActivity()
|
||||||
|
refreshRoadDistancesIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kicks off a (throttled) Apple-Maps road-distance recompute for the
|
||||||
|
/// stations around the current fix. The app owns routing — the widget and
|
||||||
|
/// Live Activity only read the cached result.
|
||||||
|
private func refreshRoadDistancesIfNeeded() {
|
||||||
|
guard let location else { return }
|
||||||
|
Task {
|
||||||
|
await RoadDistanceService.refreshIfNeeded(
|
||||||
|
stations: stations,
|
||||||
|
lat: location.lat,
|
||||||
|
lng: location.lng
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureBackgroundMotionTrackingIfNeeded() {
|
||||||
|
if alertsEnabled || liveActivityEnabled {
|
||||||
|
locationManager.startBackgroundTracking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var statusMessage = ""
|
@State private var statusMessage = ""
|
||||||
|
/// What data is on screen, driving which (if any) status banner shows
|
||||||
|
/// above the tabs:
|
||||||
|
/// - `.live`: fetched or cached data — nothing to say.
|
||||||
|
/// - `.offlineDump(date)`: serving the BUNDLED no-network snapshot —
|
||||||
|
/// the banner labels it honestly with the snapshot's own date.
|
||||||
|
/// - `.connectionProblem`: fetch failed but a saved cache is showing —
|
||||||
|
/// the banner says to check connectivity (tap = retry).
|
||||||
|
enum DataSourceStatus: Equatable {
|
||||||
|
case live
|
||||||
|
case offlineDump(date: String)
|
||||||
|
case connectionProblem
|
||||||
|
}
|
||||||
|
@State private var dataStatus: DataSourceStatus = .live
|
||||||
@State private var showOnboarding = false
|
@State private var showOnboarding = false
|
||||||
@State private var showWidgetMock = false
|
@State private var showWidgetMock = false
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@State private var locationManager = LocationManager()
|
@State private var locationManager = LocationManager()
|
||||||
@StateObject private var monitor = ProximityMonitor()
|
@StateObject private var monitor = ProximityMonitor()
|
||||||
|
/// Owns tip purchases; the single instance is shared down to Settings.
|
||||||
|
/// Its transient outcome rides the SAME status-banner chrome as the
|
||||||
|
/// network/offline strip (ContentView renders it above the tabs).
|
||||||
|
@StateObject private var tipStore = TipStore()
|
||||||
|
|
||||||
|
// --- Explicit banner animation state (robust fade-out) ---
|
||||||
|
// SwiftUI's removal transition for the banner proved unreliable here
|
||||||
|
// (the tip faded to nothing only in the cross-nil case, and even then
|
||||||
|
// inconsistently). Drive opacity/offset explicitly instead so a
|
||||||
|
// dismissal ALWAYS fades + slides up on ANY banner change.
|
||||||
|
@State private var currentBanner: AppBanner?
|
||||||
|
@State private var bannerOpacity: Double = 0
|
||||||
|
@State private var bannerOffset: CGFloat = 0
|
||||||
|
@State private var bannerClearTask: Task<Void, Never>?
|
||||||
|
|
||||||
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
||||||
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
||||||
@@ -139,61 +210,69 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
configuredContent
|
||||||
stationsTab
|
}
|
||||||
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
|
||||||
.tag(0)
|
|
||||||
|
|
||||||
favouritesTab
|
private var configuredContent: AnyView {
|
||||||
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
interactionObservedContent
|
||||||
.tag(1)
|
}
|
||||||
|
|
||||||
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") }
|
)
|
||||||
.tag(2)
|
}
|
||||||
|
|
||||||
settingsTab
|
private var lifecycleObservedContent: AnyView {
|
||||||
.tag(3)
|
AnyView(
|
||||||
}
|
presentedContent
|
||||||
.fullScreenCover(isPresented: $showOnboarding) {
|
.onAppear {
|
||||||
OnboardingView {
|
|
||||||
showOnboarding = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.fullScreenCover(isPresented: $showWidgetMock) {
|
|
||||||
WidgetMockScreen()
|
|
||||||
}
|
|
||||||
.sheet(item: $monitor.pendingStationMap) { request in
|
|
||||||
StationMapView(request: request)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
// Launch-arg hooks for the screenshot/UI-test harness (same
|
// Launch-arg hooks for the screenshot/UI-test harness (same
|
||||||
// pattern as StationsView's `-showKeySheet`): `-tab <name>`
|
// pattern as StationsView's `-showKeySheet`): `-tab <name>`
|
||||||
// opens the given tab; `-skipOnboarding` skips onboarding
|
// opens the given tab; `-skipOnboarding` skips onboarding
|
||||||
// without touching the stored flag.
|
// without touching the stored flag.
|
||||||
let args = ProcessInfo.processInfo.arguments
|
let args = ProcessInfo.processInfo.arguments
|
||||||
|
#if DEBUG
|
||||||
showWidgetMock = args.contains("-widgets")
|
showWidgetMock = args.contains("-widgets")
|
||||||
// `-seedFavourite <substring>` pins the first matching station
|
// `-seedFavourite <substring>` pins the first matching station
|
||||||
// for Unleaded via the normal save path (keychain + app group)
|
// for Unleaded via the normal save path (keychain + app group)
|
||||||
// so screenshot captures can show a populated Favourites tab.
|
// so screenshot captures can show a populated Favourites tab.
|
||||||
if let i = args.firstIndex(of: "-seedFavourite"), i + 1 < args.count {
|
// Repeatable: each occurrence adds another favourite (QA temp).
|
||||||
let query = args[i + 1]
|
// Optional ":fuel" suffix (e10|e5|diesel) picks the
|
||||||
if let station = FuelStore.loadStations()
|
// fuel, so the fuel-aware Trends open can be verified (QA temp).
|
||||||
.first(where: { $0.name.localizedCaseInsensitiveContains(query) }) {
|
let seedQueries = args.enumerated()
|
||||||
FuelStore.saveFavourites([FavouriteEntry(station: station, fuel: .e10)])
|
.filter { $0.element == "-seedFavourite" }
|
||||||
|
.compactMap { i, _ in
|
||||||
|
i + 1 < args.count ? args[i + 1] : nil
|
||||||
|
}
|
||||||
|
if !seedQueries.isEmpty {
|
||||||
|
let stations = FuelStore.loadStations()
|
||||||
|
var favs = seedQueries.compactMap { query in
|
||||||
|
let parts = query.split(separator: ":", maxSplits: 1)
|
||||||
|
let name = String(parts[0])
|
||||||
|
let fuel = parts.count > 1
|
||||||
|
? FuelType(rawValue: String(parts[1])) ?? .e10
|
||||||
|
: FuelType.e10
|
||||||
|
return stations.first { $0.name.localizedCaseInsensitiveContains(name) }
|
||||||
|
.map { FavouriteEntry(station: $0, fuel: fuel) }
|
||||||
|
}
|
||||||
|
if favs.isEmpty, let q = seedQueries.first,
|
||||||
|
let s = stations.first(where: { $0.name.localizedCaseInsensitiveContains(q.split(separator: ":").first.map(String.init) ?? q) }) {
|
||||||
|
favs = [FavouriteEntry(station: s, fuel: .e10)]
|
||||||
|
}
|
||||||
|
if !favs.isEmpty {
|
||||||
|
FuelStore.saveFavourites(favs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
||||||
@@ -204,6 +283,30 @@ struct ContentView: View {
|
|||||||
default: selectedTab = 0
|
default: selectedTab = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// QA temp: `-fuel <e10|e5|diesel>` sets the app-wide selected
|
||||||
|
// fuel so the fuel-aware Trends open can be verified (simulates
|
||||||
|
// the user having switched to the diesel tab).
|
||||||
|
if let i = args.firstIndex(of: "-fuel"), i + 1 < args.count,
|
||||||
|
let f = FuelType(rawValue: args[i + 1]) {
|
||||||
|
selectedFuel = f
|
||||||
|
FuelStore.saveSelectedFuel(f)
|
||||||
|
}
|
||||||
|
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
|
||||||
|
// failure legs for the screenshot harness. The auto-refresh below
|
||||||
|
// is skipped so the banner stays up (a live fetch would clear it).
|
||||||
|
if args.contains("-forceOfflineDump") {
|
||||||
|
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
|
||||||
|
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
|
||||||
|
}
|
||||||
|
if args.contains("-forceConnectionProblem") {
|
||||||
|
stations = FuelStore.loadStations().isEmpty
|
||||||
|
? (BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations)
|
||||||
|
: FuelStore.loadStations()
|
||||||
|
dataStatus = .connectionProblem
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
showWidgetMock = false
|
||||||
|
#endif
|
||||||
// Onboarding runs first on a fresh install — it owns the initial
|
// Onboarding runs first on a fresh install — it owns the initial
|
||||||
// permission prompts (location, notifications, and the data/local
|
// permission prompts (location, notifications, and the data/local
|
||||||
// network probe on the Data page). Location tracking and the first
|
// network probe on the Data page). Location tracking and the first
|
||||||
@@ -211,29 +314,36 @@ struct ContentView: View {
|
|||||||
// Launch-arg hook (UI-testing/screenshot harness, same pattern as
|
// Launch-arg hook (UI-testing/screenshot harness, same pattern as
|
||||||
// StationsView's `-showKeySheet`): skip onboarding without
|
// StationsView's `-showKeySheet`): skip onboarding without
|
||||||
// touching the stored flag.
|
// touching the stored flag.
|
||||||
|
#if DEBUG
|
||||||
|
let shouldSkipOnboarding = args.contains("-skipOnboarding")
|
||||||
|
#else
|
||||||
|
let shouldSkipOnboarding = false
|
||||||
|
#endif
|
||||||
if FuelStore.loadHasCompletedOnboarding()
|
if FuelStore.loadHasCompletedOnboarding()
|
||||||
|| args.contains("-skipOnboarding") {
|
|| 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
|
||||||
}
|
}
|
||||||
@@ -248,6 +358,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()
|
||||||
@@ -280,9 +392,16 @@ struct ContentView: View {
|
|||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
|
refreshRoadDistancesIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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()
|
||||||
}
|
}
|
||||||
@@ -305,9 +424,7 @@ struct ContentView: View {
|
|||||||
monitor.setEnabled(newValue)
|
monitor.setEnabled(newValue)
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
if newValue {
|
ensureBackgroundMotionTrackingIfNeeded()
|
||||||
locationManager.startBackgroundTracking()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: alertsRadius) { _, newValue in
|
.onChange(of: alertsRadius) { _, newValue in
|
||||||
FuelStore.saveAlertsRadius(newValue)
|
FuelStore.saveAlertsRadius(newValue)
|
||||||
@@ -328,10 +445,17 @@ struct ContentView: View {
|
|||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
}
|
}
|
||||||
|
.onChange(of: favouriteDropAlertsEnabled) { _, newValue in
|
||||||
|
FuelStore.saveFavouriteDropAlertsEnabled(newValue)
|
||||||
|
}
|
||||||
|
.onChange(of: favouriteDropAlertsFuel) { _, newValue in
|
||||||
|
FuelStore.saveFavouriteDropAlertsFuel(newValue)
|
||||||
|
}
|
||||||
.onChange(of: liveActivityEnabled) { _, newValue in
|
.onChange(of: liveActivityEnabled) { _, newValue in
|
||||||
// Toggling the Live Activity on starts it with the current best
|
// Toggling the Live Activity on starts it with the current best
|
||||||
// station; toggling off ends any running activity.
|
// station; toggling off ends any running activity.
|
||||||
FuelStore.saveLiveActivityEnabled(newValue)
|
FuelStore.saveLiveActivityEnabled(newValue)
|
||||||
|
ensureBackgroundMotionTrackingIfNeeded()
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
}
|
}
|
||||||
.onChange(of: liveActivityFuel) { _, newValue in
|
.onChange(of: liveActivityFuel) { _, newValue in
|
||||||
@@ -367,6 +491,205 @@ struct ContentView: View {
|
|||||||
// so reading storage here could push the OLD style.
|
// so reading storage here could push the OLD style.
|
||||||
updateLiveActivity(priceDisplayStyleOverride: newValue)
|
updateLiveActivity(priceDisplayStyleOverride: newValue)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one banner to show right now. Network/offline and tip outcomes all
|
||||||
|
/// funnel here and render through the SAME floating chrome. A transient
|
||||||
|
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
|
||||||
|
/// network state for its ~3.5 s.
|
||||||
|
private var activeBanner: AppBanner? {
|
||||||
|
if let outcome = tipStore.outcome { return .tip(outcome) }
|
||||||
|
switch dataStatus {
|
||||||
|
case .live: return nil
|
||||||
|
case .offlineDump(let date): return .offlineDump(date: date)
|
||||||
|
case .connectionProblem: return .connectionProblem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainContent: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
rootTabView
|
||||||
|
if let banner = currentBanner {
|
||||||
|
// Floating near the top of the screen, over the nav area —
|
||||||
|
// overlays content (never pushes it) and sits above the
|
||||||
|
// main content so it doesn't cover or block the list/pill
|
||||||
|
// beneath it. Opacity/offset are driven explicitly by
|
||||||
|
// reflectBanner so the fade-out reliably animates.
|
||||||
|
floatingBanner(banner)
|
||||||
|
.padding(.top, geo.safeAreaInsets.top + 10)
|
||||||
|
.opacity(bannerOpacity)
|
||||||
|
.offset(y: bannerOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: activeBanner) {
|
||||||
|
// Fires on launch with the current banner AND whenever it changes —
|
||||||
|
// unlike onChange(computed) which can miss the first non-nil value
|
||||||
|
// when dataStatus is set slightly after the view appears.
|
||||||
|
reflectBanner(activeBanner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly animates the banner in/out — a robust replacement for the
|
||||||
|
/// SwiftUI removal transition (which was silently not firing). Handles:
|
||||||
|
/// - first appearance → fade + settle down from slightly above
|
||||||
|
/// - any content change (tip→network, new tip) → fade the old out, then
|
||||||
|
/// fade the new in — so a tip ALWAYS visibly fades away
|
||||||
|
/// - dismissal → fade out + slide up, then clear after the fade
|
||||||
|
private func reflectBanner(_ newBanner: AppBanner?) {
|
||||||
|
guard newBanner != currentBanner else { return }
|
||||||
|
bannerClearTask?.cancel()
|
||||||
|
if let newBanner {
|
||||||
|
if currentBanner == nil {
|
||||||
|
present(newBanner)
|
||||||
|
} else {
|
||||||
|
// Crossfade: slide+fade the current out, then present the new.
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
bannerOpacity = 0
|
||||||
|
bannerOffset = -28
|
||||||
|
}
|
||||||
|
bannerClearTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
present(newBanner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withAnimation(.easeOut(duration: 0.35)) {
|
||||||
|
bannerOpacity = 0
|
||||||
|
bannerOffset = -28
|
||||||
|
}
|
||||||
|
bannerClearTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
currentBanner = nil
|
||||||
|
bannerOpacity = 0
|
||||||
|
bannerOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fade a banner in from slightly above and settle it into place.
|
||||||
|
private func present(_ banner: AppBanner) {
|
||||||
|
currentBanner = banner
|
||||||
|
bannerOpacity = 0
|
||||||
|
bannerOffset = -16
|
||||||
|
withAnimation(.easeOut(duration: 0.3)) {
|
||||||
|
bannerOpacity = 1
|
||||||
|
bannerOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rootTabView: some View {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
stationsTab
|
||||||
|
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
favouritesTab
|
||||||
|
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
|
alertsTab
|
||||||
|
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
||||||
|
.tag(2)
|
||||||
|
|
||||||
|
settingsTab
|
||||||
|
.tag(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The banner title for the bundled-snapshot case: date when the stamp
|
||||||
|
/// parsed, plain "Offline data" otherwise.
|
||||||
|
private func offlineTitle(date: String) -> String {
|
||||||
|
date.isEmpty
|
||||||
|
? NSLocalizedString("Offline data", comment: "")
|
||||||
|
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ONE floating banner style for all notifications. Network/offline and
|
||||||
|
/// tip outcomes share the same chrome; only the tap action differs —
|
||||||
|
/// network → retry the fetch, tip → dismiss (TipStore auto-dismisses too).
|
||||||
|
@ViewBuilder
|
||||||
|
private func floatingBanner(_ banner: AppBanner) -> some View {
|
||||||
|
switch banner {
|
||||||
|
case .offlineDump(let date):
|
||||||
|
let title = offlineTitle(date: date)
|
||||||
|
statusBannerCard(
|
||||||
|
icon: "wifi.slash", tint: .orange, title: title,
|
||||||
|
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
|
||||||
|
trailingIcon: "arrow.clockwise",
|
||||||
|
accessibilityLabel: date.isEmpty
|
||||||
|
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||||||
|
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||||||
|
) {
|
||||||
|
Task { await refresh(force: true) }
|
||||||
|
}
|
||||||
|
case .connectionProblem:
|
||||||
|
statusBannerCard(
|
||||||
|
icon: "wifi.exclamationmark", tint: .red,
|
||||||
|
title: NSLocalizedString("Check your internet connection", comment: ""),
|
||||||
|
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
||||||
|
trailingIcon: "arrow.clockwise",
|
||||||
|
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
||||||
|
) {
|
||||||
|
Task { await refresh(force: true) }
|
||||||
|
}
|
||||||
|
case .tip(let outcome):
|
||||||
|
statusBannerCard(
|
||||||
|
icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
|
||||||
|
trailingIcon: "xmark", accessibilityLabel: outcome.message
|
||||||
|
) {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single floating-banner chrome shared by every notification
|
||||||
|
/// (network/offline + tip). Overlaid — never in flow — so it never moves
|
||||||
|
/// the content beneath it.
|
||||||
|
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
|
||||||
|
trailingIcon: String, accessibilityLabel: String,
|
||||||
|
action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.frame(width: 30)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if let subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: trailingIcon)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(accessibilityLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the current best-in-radius station into the Live Activity.
|
/// Pushes the current best-in-radius station into the Live Activity.
|
||||||
@@ -420,7 +743,33 @@ struct ContentView: View {
|
|||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onReorder: reorderFavourites
|
onReorder: reorderFavourites,
|
||||||
|
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
||||||
|
onHistoryRecovered: { if dataStatus == .connectionProblem { dataStatus = .live } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Alerts tab, extracted from `body` so the TabView expression stays
|
||||||
|
/// within the compiler's type-check budget.
|
||||||
|
private var alertsTab: some View {
|
||||||
|
AlertsView(
|
||||||
|
enabled: $alertsEnabled,
|
||||||
|
radius: $alertsRadius,
|
||||||
|
fuel: $alertsFuel,
|
||||||
|
favouriteDropEnabled: $favouriteDropAlertsEnabled,
|
||||||
|
favouriteDropFuel: $favouriteDropAlertsFuel,
|
||||||
|
followsSearch: $alertsFollowsSearch,
|
||||||
|
liveActivityEnabled: $liveActivityEnabled,
|
||||||
|
liveActivityFuel: $liveActivityFuel,
|
||||||
|
liveActivityRadiusMiles: $liveActivityRadiusMiles,
|
||||||
|
liveActivityFollowsSearch: $liveActivityFollowsSearch,
|
||||||
|
stationLimit: stationLimit,
|
||||||
|
distanceUnit: distanceUnit,
|
||||||
|
monitoredCount: monitor.monitoredStationIDs.count,
|
||||||
|
lastAlert: monitor.lastAlert,
|
||||||
|
onCheckFavouriteDropNow: {
|
||||||
|
Task { await refresh(force: true) }
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +777,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,
|
||||||
@@ -483,6 +833,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,
|
||||||
@@ -502,16 +853,34 @@ struct ContentView: View {
|
|||||||
// channel shared on SideStore free), so stale star-time snapshots
|
// channel shared on SideStore free), so stale star-time snapshots
|
||||||
// would otherwise show old prices.
|
// would otherwise show old prices.
|
||||||
FuelStore.saveFavourites(refreshedFavourites)
|
FuelStore.saveFavourites(refreshedFavourites)
|
||||||
|
monitor.evaluateFavouritePriceDropAlert(
|
||||||
|
favourites: refreshedFavourites,
|
||||||
|
previousSnapshots: previousFavouriteSnapshots,
|
||||||
|
monitoredFuel: favouriteDropAlertsFuel,
|
||||||
|
enabled: favouriteDropAlertsEnabled
|
||||||
|
)
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
||||||
|
// Live data restored — any status banner no longer applies.
|
||||||
|
dataStatus = .live
|
||||||
} catch {
|
} catch {
|
||||||
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
||||||
if FuelStore.loadStations().isEmpty {
|
if FuelStore.loadStations().isEmpty {
|
||||||
// No cached prices — last resort is the bundled REAL dump
|
// No cached prices — last resort is the bundled REAL dump
|
||||||
// (stale but genuine), then the demo sample set.
|
// (stale but genuine), then the demo sample set. The banner
|
||||||
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
|
// labels the bundled snapshot honestly with its own date.
|
||||||
|
if let dump = BundledDumpProvider.stations {
|
||||||
|
stations = dump
|
||||||
|
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
|
||||||
|
} else {
|
||||||
|
stations = SampleFuelProvider.sampleStations
|
||||||
|
dataStatus = .live
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Saved prices are still on screen — but the fetch failed, so
|
||||||
|
// say so: a stale cache must not look like a live app.
|
||||||
stations = FuelStore.loadStations()
|
stations = FuelStore.loadStations()
|
||||||
|
dataStatus = .connectionProblem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Keep monitor geofences in sync with the freshest data.
|
// Keep monitor geofences in sync with the freshest data.
|
||||||
@@ -564,14 +933,14 @@ struct StationRow: View {
|
|||||||
.padding(5)
|
.padding(5)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "fuelpump.fill")
|
Image(systemName: "fuelpump.fill")
|
||||||
.font(.system(size: 22))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Color.black.opacity(0.82))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 46, height: 46)
|
.frame(width: 46, height: 46)
|
||||||
.background(Circle().fill(.white))
|
.background(Circle().fill(Color.white))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.overlay(Circle().stroke(Color.primary.opacity(0.08), lineWidth: 1))
|
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
|
||||||
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
|
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
|
||||||
|
|
||||||
// MIDDLE — name, full address, distance
|
// MIDDLE — name, full address, distance
|
||||||
@@ -596,7 +965,7 @@ struct StationRow: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
if let location {
|
if let location {
|
||||||
Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
|
import ActivityKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct FuelBoardApp: App {
|
struct FuelBoardApp: App {
|
||||||
|
init() {
|
||||||
|
#if DEBUG
|
||||||
|
// QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a
|
||||||
|
// Live Activity with a long station name so the Lock Screen / island
|
||||||
|
// layout can be rendered in the Simulator for visual QA.
|
||||||
|
let args = ProcessInfo.processInfo.arguments
|
||||||
|
if let idx = args.firstIndex(of: "-qaLiveActivity"),
|
||||||
|
args.indices.contains(idx + 1),
|
||||||
|
let fuel = FuelType(rawValue: args[idx + 1]) {
|
||||||
|
startQALiveActivity(fuel: fuel)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
@@ -9,6 +24,34 @@ struct FuelBoardApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private func startQALiveActivity(fuel: FuelType) {
|
||||||
|
let state = FuelBoardLiveActivityAttributes.ContentState(
|
||||||
|
fuel: fuel,
|
||||||
|
stationID: "qa-phoenix",
|
||||||
|
stationName: "Phoenix Filling Stations",
|
||||||
|
brand: "Phoenix",
|
||||||
|
pricePence: 1499,
|
||||||
|
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
|
||||||
|
distanceKM: 8.0,
|
||||||
|
lat: 51.5,
|
||||||
|
lng: -0.12,
|
||||||
|
updatedAt: Date()
|
||||||
|
)
|
||||||
|
let attrs = FuelBoardLiveActivityAttributes()
|
||||||
|
do {
|
||||||
|
let activity = try Activity.request(
|
||||||
|
attributes: attrs,
|
||||||
|
content: .init(state: state, staleDate: nil),
|
||||||
|
pushType: nil
|
||||||
|
)
|
||||||
|
print("QA-LIVE-ACTIVITY STARTED id=\(activity.id)")
|
||||||
|
} catch {
|
||||||
|
print("QA-LIVE-ACTIVITY FAILED: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Handles deep links that end up in the app. Widget taps arrive here in
|
/// Handles deep links that end up in the app. Widget taps arrive here in
|
||||||
/// two cases:
|
/// two cases:
|
||||||
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
|
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ enum LiveActivityManager {
|
|||||||
brand: best.brand,
|
brand: best.brand,
|
||||||
pricePence: price,
|
pricePence: price,
|
||||||
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
||||||
distanceKM: best.distanceKM(to: location.lat, lng2: location.lng),
|
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
|
||||||
lat: best.lat,
|
lat: best.lat,
|
||||||
lng: best.lng,
|
lng: best.lng,
|
||||||
updatedAt: Date()
|
updatedAt: Date()
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ struct OnboardingView: View {
|
|||||||
private var welcomePage: some View {
|
private var welcomePage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
ZStack {
|
AnimatedOnboardingIcon(tint: .accentColor) {
|
||||||
// The app icon itself, with the standard iOS rounded-corner
|
// The app icon itself, with the standard iOS rounded-corner
|
||||||
// treatment (~22% of the size), so onboarding matches the
|
// treatment (~22% of the size), so onboarding matches the
|
||||||
// Home Screen.
|
// Home Screen.
|
||||||
@@ -122,8 +122,7 @@ struct OnboardingView: View {
|
|||||||
private var locationPage: some View {
|
private var locationPage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
ZStack {
|
AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
|
||||||
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
|
|
||||||
Image(systemName: "location.fill")
|
Image(systemName: "location.fill")
|
||||||
.font(.system(size: 40, weight: .semibold))
|
.font(.system(size: 40, weight: .semibold))
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(.blue)
|
||||||
@@ -154,8 +153,7 @@ struct OnboardingView: View {
|
|||||||
private var notificationsPage: some View {
|
private var notificationsPage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
ZStack {
|
AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
|
||||||
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
|
|
||||||
Image(systemName: "bell.badge.fill")
|
Image(systemName: "bell.badge.fill")
|
||||||
.font(.system(size: 40, weight: .semibold))
|
.font(.system(size: 40, weight: .semibold))
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
@@ -186,8 +184,7 @@ struct OnboardingView: View {
|
|||||||
private var dataPage: some View {
|
private var dataPage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
ZStack {
|
AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
|
||||||
Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
|
|
||||||
Image(systemName: "arrow.down.circle.fill")
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
.font(.system(size: 40, weight: .semibold))
|
.font(.system(size: 40, weight: .semibold))
|
||||||
.foregroundStyle(.teal)
|
.foregroundStyle(.teal)
|
||||||
@@ -248,19 +245,27 @@ struct OnboardingView: View {
|
|||||||
) {
|
) {
|
||||||
if prompter.locationDenied {
|
if prompter.locationDenied {
|
||||||
openSettings()
|
openSettings()
|
||||||
|
} else if prompter.locationGranted {
|
||||||
|
page = 2 // already decided — just move on
|
||||||
} else {
|
} else {
|
||||||
// The prompt fires on LEAVING this page (onChange(of:
|
// Fire the system prompt NOW, while this page is still on
|
||||||
// page), forward advance only) — the description is on
|
// screen (after it's been read) and BEFORE advancing — so
|
||||||
// screen until the user moves on.
|
// the prompt never covers the next page's animation. Auto-
|
||||||
page = 2
|
// advance on grant moves us on once the user responds;
|
||||||
|
// denying leaves the page's "Open Settings" path.
|
||||||
|
prompter.requestLocation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
primaryButton(
|
primaryButton(
|
||||||
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
||||||
) {
|
) {
|
||||||
// Prompt fires on leaving this page (same rule as Location).
|
if prompter.notificationsDenied || prompter.notificationsGranted {
|
||||||
page = 3
|
page = 3 // decided either way — move on
|
||||||
|
} else {
|
||||||
|
// Same rule as Location: prompt now, before advancing.
|
||||||
|
prompter.requestNotifications()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case 3:
|
case 3:
|
||||||
// No permission on this page — prices need none. Straight on.
|
// No permission on this page — prices need none. Straight on.
|
||||||
@@ -326,10 +331,103 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct AnimatedOnboardingIcon<Content: View>: View {
|
||||||
|
enum Motion {
|
||||||
|
case gentle
|
||||||
|
case locationArrow
|
||||||
|
case alarmBell
|
||||||
|
case downloadArrow
|
||||||
|
}
|
||||||
|
|
||||||
|
let tint: Color
|
||||||
|
var motion: Motion = .gentle
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
|
||||||
|
@State private var settled = false
|
||||||
|
@State private var iconRotation: Double = 0
|
||||||
|
@State private var iconOffsetY: CGFloat = 0
|
||||||
|
@State private var iconScale: CGFloat = 0.92
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(tint.opacity(0.10))
|
||||||
|
.frame(width: 108, height: 108)
|
||||||
|
.scaleEffect(settled ? 1.0 : 0.94)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.stroke(tint.opacity(0.18), lineWidth: 1)
|
||||||
|
.frame(width: 118, height: 118)
|
||||||
|
.scaleEffect(settled ? 1.03 : 0.97)
|
||||||
|
.opacity(settled ? 1.0 : 0.55)
|
||||||
|
|
||||||
|
content
|
||||||
|
.scaleEffect(iconScale)
|
||||||
|
.rotationEffect(.degrees(iconRotation))
|
||||||
|
.offset(y: iconOffsetY)
|
||||||
|
}
|
||||||
|
.shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2)
|
||||||
|
.animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled)
|
||||||
|
.onAppear {
|
||||||
|
playAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playAnimation() {
|
||||||
|
settled = false
|
||||||
|
iconRotation = 0
|
||||||
|
iconOffsetY = 4
|
||||||
|
iconScale = 0.92
|
||||||
|
|
||||||
|
switch motion {
|
||||||
|
case .gentle:
|
||||||
|
withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
|
||||||
|
settled = true
|
||||||
|
iconOffsetY = 0
|
||||||
|
iconScale = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
case .locationArrow:
|
||||||
|
iconRotation = -120
|
||||||
|
withAnimation(.linear(duration: 0.45)) {
|
||||||
|
settled = true
|
||||||
|
iconRotation = 24
|
||||||
|
iconOffsetY = 0
|
||||||
|
iconScale = 1.02
|
||||||
|
}
|
||||||
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) {
|
||||||
|
iconRotation = 0
|
||||||
|
iconScale = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
case .alarmBell:
|
||||||
|
settled = true
|
||||||
|
iconOffsetY = 0
|
||||||
|
iconScale = 1.0
|
||||||
|
withAnimation(.linear(duration: 0.10)) { iconRotation = -14 }
|
||||||
|
withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 }
|
||||||
|
withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 }
|
||||||
|
withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 }
|
||||||
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 }
|
||||||
|
|
||||||
|
case .downloadArrow:
|
||||||
|
iconOffsetY = -22
|
||||||
|
withAnimation(.easeIn(duration: 0.22)) {
|
||||||
|
settled = true
|
||||||
|
iconScale = 1.0
|
||||||
|
iconOffsetY = 8
|
||||||
|
}
|
||||||
|
withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) {
|
||||||
|
iconOffsetY = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Owns the system permission requests during onboarding and publishes
|
/// Owns the system permission requests during onboarding and publishes
|
||||||
/// their outcomes so the pages can reflect them live.
|
/// their outcomes so the pages can reflect them live.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
|
final class OnboardingPermissionPrompter: NSObject, ObservableObject, CLLocationManagerDelegate {
|
||||||
@Published private(set) var locationGranted = false
|
@Published private(set) var locationGranted = false
|
||||||
@Published private(set) var locationDenied = false
|
@Published private(set) var locationDenied = false
|
||||||
@Published private(set) var notificationsGranted = false
|
@Published private(set) var notificationsGranted = false
|
||||||
@@ -358,11 +456,14 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
switch settings.authorizationStatus {
|
switch settings.authorizationStatus {
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
do {
|
||||||
Task { @MainActor in
|
let granted = try await UNUserNotificationCenter.current()
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// RoadDistanceService.swift — computes Apple-Maps-matched ROAD distances for
|
||||||
|
// nearby stations and caches them (keychain) so widgets + Live Activity can
|
||||||
|
// show real driving distance instead of straight-line haversine.
|
||||||
|
//
|
||||||
|
// Runs only in the APP: MKDirections is network-bound and the widget extension
|
||||||
|
// has a tiny execution budget + a ~40-70/day refresh budget, so routing belongs
|
||||||
|
// here, not in the widget. The widget/Live Activity just read the cache.
|
||||||
|
//
|
||||||
|
// Throttling: recompute at most every `throttleMinutes`, or when the user has
|
||||||
|
// moved `moveThresholdMeters` from where the cache was built. Bounded to the
|
||||||
|
// `candidatesPerPass` nearest stations so a pass stays a handful of route calls.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import MapKit
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
enum RoadDistanceService {
|
||||||
|
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
|
||||||
|
/// route calls. Raised from 12 so stations past the old nearest-12 cutoff
|
||||||
|
/// still get real road distances instead of a straight-line fallback.
|
||||||
|
static let candidatesPerPass = 40
|
||||||
|
/// Only route stations within this straight-line radius (km). Covers the
|
||||||
|
/// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so
|
||||||
|
/// every station a widget/Live Activity/list can actually show gets routed.
|
||||||
|
static let maxRadiusKM: Double = 25
|
||||||
|
/// Don't route again more often than this (minutes).
|
||||||
|
static let throttleMinutes: Double = 10
|
||||||
|
/// Recompute when the user moves more than this (metres) from the last
|
||||||
|
/// source location.
|
||||||
|
static let moveThresholdMeters: Double = 400
|
||||||
|
|
||||||
|
/// Refreshes the cached road distances for the in-radius stations around
|
||||||
|
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
||||||
|
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
|
||||||
|
guard !stations.isEmpty else { return }
|
||||||
|
|
||||||
|
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
||||||
|
if let cache = FuelStore.loadRoadDistances() {
|
||||||
|
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
||||||
|
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
||||||
|
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate stations: the nearest-by-straight-line subset that the UI
|
||||||
|
// could actually display, capped so a pass stays bounded.
|
||||||
|
let nearest = stations
|
||||||
|
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
||||||
|
.prefix(candidatesPerPass)
|
||||||
|
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
||||||
|
|
||||||
|
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||||
|
var entries: [String: Double] = [:]
|
||||||
|
for station in nearest {
|
||||||
|
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||||
|
if let meters = await roadMeters(from: origin, to: dest) {
|
||||||
|
entries[station.id] = meters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !entries.isEmpty else { return }
|
||||||
|
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
|
||||||
|
// Wake the widgets so the new road distances surface immediately.
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||||
|
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
||||||
|
let request = MKDirections.Request()
|
||||||
|
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
|
||||||
|
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
|
||||||
|
request.transportType = .automobile
|
||||||
|
request.requestsAlternateRoutes = false
|
||||||
|
do {
|
||||||
|
let response = try await MKDirections(request: request).calculate()
|
||||||
|
return response.routes.first?.distance
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Straight-line haversine distance between two coordinates, in metres.
|
||||||
|
private static func haversineMeters(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
|
||||||
|
let r = 6371000.0
|
||||||
|
let dLat = (lat2 - lat1) * .pi / 180
|
||||||
|
let dLng = (lng2 - lng1) * .pi / 180
|
||||||
|
let a = sin(dLat / 2) * sin(dLat / 2) +
|
||||||
|
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
|
||||||
|
sin(dLng / 2) * sin(dLng / 2)
|
||||||
|
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,9 +56,6 @@ struct SettingsView: View {
|
|||||||
/// Clears the armed fence (monitor.clearDebugFence).
|
/// Clears the armed fence (monitor.clearDebugFence).
|
||||||
var onClearDebugFence: () -> Void = {}
|
var onClearDebugFence: () -> Void = {}
|
||||||
|
|
||||||
@StateObject private var tipStore = TipStore()
|
|
||||||
@State private var showTipAlert = false
|
|
||||||
@State private var tipAlertMessage = ""
|
|
||||||
@State private var testAlertMessage: String?
|
@State private var testAlertMessage: String?
|
||||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||||
@@ -298,25 +298,47 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
ForEach(TipStore.tiers) { tier in
|
VStack(spacing: 10) {
|
||||||
Button {
|
ForEach(TipStore.tiers) { tier in
|
||||||
Task { await tipStore.purchase(tier) }
|
Button {
|
||||||
} label: {
|
Task { await tipStore.purchase(tier) }
|
||||||
HStack {
|
} label: {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
HStack(spacing: 12) {
|
||||||
Text(tier.name)
|
Image(systemName: "fuelpump.fill")
|
||||||
Text(tier.blurb)
|
.font(.title3)
|
||||||
.font(.caption)
|
.foregroundStyle(tier.accent)
|
||||||
.foregroundStyle(.secondary)
|
.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()
|
||||||
}
|
}
|
||||||
Spacer()
|
.padding(.horizontal, 14)
|
||||||
Text(tipStore.displayPrice(for: tier))
|
.padding(.vertical, 12)
|
||||||
.foregroundStyle(.secondary)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.monospacedDigit()
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(tier.accent.opacity(0.05))
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(tipStore.purchaseInProgress)
|
||||||
}
|
}
|
||||||
.disabled(tipStore.purchaseInProgress)
|
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||||
} header: {
|
} header: {
|
||||||
Text("Support FuelBoard")
|
Text("Support FuelBoard")
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -337,13 +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 {
|
||||||
@@ -386,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)
|
||||||
@@ -545,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: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,20 +588,34 @@ final class TipStore: ObservableObject {
|
|||||||
let name: String
|
let name: String
|
||||||
let blurb: String
|
let blurb: String
|
||||||
let fallbackPrice: String
|
let fallbackPrice: String
|
||||||
|
let accent: Color // card/price accent (matches the fuel palette)
|
||||||
}
|
}
|
||||||
|
|
||||||
static let tiers: [TipTier] = [
|
static let tiers: [TipTier] = [
|
||||||
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
|
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
|
||||||
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99"),
|
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99",
|
||||||
|
accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF
|
||||||
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
|
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
|
||||||
blurb: "A generous top-up for development.", fallbackPrice: "£2.99"),
|
blurb: "A generous top-up for development.", fallbackPrice: "£2.99",
|
||||||
|
accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158
|
||||||
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
|
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
|
||||||
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99"),
|
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99",
|
||||||
|
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// A banner outcome: the exact copy to show plus its icon + tint.
|
||||||
|
struct TipOutcome: Hashable {
|
||||||
|
let message: String
|
||||||
|
let icon: String
|
||||||
|
let tint: Color
|
||||||
|
}
|
||||||
|
|
||||||
@Published private(set) var products: [String: Product] = [:]
|
@Published private(set) var products: [String: Product] = [:]
|
||||||
@Published private(set) var purchaseInProgress = false
|
@Published private(set) var purchaseInProgress = false
|
||||||
@Published private(set) var message: String?
|
@Published private(set) var outcome: TipOutcome?
|
||||||
|
|
||||||
|
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
|
||||||
|
private var dismissTask: Task<Void, Never>?
|
||||||
|
|
||||||
/// Listens for transactions that complete OUTSIDE the direct purchase()
|
/// Listens for transactions that complete OUTSIDE the direct purchase()
|
||||||
/// call — Ask to Buy approvals, payments finished on another device
|
/// call — Ask to Buy approvals, payments finished on another device
|
||||||
@@ -600,6 +633,7 @@ final class TipStore: ObservableObject {
|
|||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
updatesTask?.cancel()
|
updatesTask?.cancel()
|
||||||
|
dismissTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||||
@@ -608,7 +642,30 @@ final class TipStore: ObservableObject {
|
|||||||
// Only acknowledge our own consumables (future products get their own).
|
// Only acknowledge our own consumables (future products get their own).
|
||||||
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
showOutcome(
|
||||||
|
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||||
|
icon: "checkmark.circle.fill",
|
||||||
|
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the banner outcome and schedules its auto-dismiss (~3.5 s).
|
||||||
|
/// Re-triggering cancels the previous timer so rapid taps don't collide.
|
||||||
|
private func showOutcome(_ message: String, icon: String, tint: Color) {
|
||||||
|
dismissTask?.cancel()
|
||||||
|
outcome = TipOutcome(message: message, icon: icon, tint: tint)
|
||||||
|
dismissTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: 3_500_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
withAnimation(.easeOut(duration: 0.25)) { self?.outcome = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tap-to-dismiss — clears the banner and cancels the pending auto-dismiss.
|
||||||
|
func dismissOutcome() {
|
||||||
|
dismissTask?.cancel()
|
||||||
|
dismissTask = nil
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) { outcome = nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayPrice(for tier: TipTier) -> String {
|
func displayPrice(for tier: TipTier) -> String {
|
||||||
@@ -637,7 +694,11 @@ final class TipStore: ObservableObject {
|
|||||||
// Connect yet), still allow the attempt so the user sees a clear
|
// Connect yet), still allow the attempt so the user sees a clear
|
||||||
// outcome rather than a dead button.
|
// outcome rather than a dead button.
|
||||||
guard let product = products[tier.id] else {
|
guard let product = products[tier.id] else {
|
||||||
message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "")
|
showOutcome(
|
||||||
|
NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: ""),
|
||||||
|
icon: "info.circle.fill",
|
||||||
|
tint: Color(.secondaryLabel)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,19 +711,35 @@ final class TipStore: ObservableObject {
|
|||||||
// Consume the consumable — otherwise StoreKit re-delivers
|
// Consume the consumable — otherwise StoreKit re-delivers
|
||||||
// it through Transaction.updates on every launch.
|
// it through Transaction.updates on every launch.
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
showOutcome(
|
||||||
|
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||||
|
icon: "checkmark.circle.fill",
|
||||||
|
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||||
|
)
|
||||||
case .unverified:
|
case .unverified:
|
||||||
message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "")
|
showOutcome(
|
||||||
|
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
||||||
|
icon: "exclamationmark.triangle.fill",
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
}
|
}
|
||||||
case .userCancelled:
|
case .userCancelled:
|
||||||
message = nil // silent — the user just closed the sheet
|
dismissOutcome() // silent — the user just closed the sheet
|
||||||
case .pending:
|
case .pending:
|
||||||
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
|
showOutcome(
|
||||||
|
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
|
||||||
|
icon: "hourglass",
|
||||||
|
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
|
||||||
|
)
|
||||||
@unknown default:
|
@unknown default:
|
||||||
message = nil
|
dismissOutcome()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
|
showOutcome(
|
||||||
|
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
||||||
|
icon: "xmark.circle.fill",
|
||||||
|
tint: .red
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct StationsView: View {
|
|||||||
} else {
|
} else {
|
||||||
ratio = "\(totalCount)"
|
ratio = "\(totalCount)"
|
||||||
}
|
}
|
||||||
if let location {
|
if location != nil {
|
||||||
if sortMode == .closest {
|
if sortMode == .closest {
|
||||||
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||||
}
|
}
|
||||||
@@ -174,11 +174,13 @@ struct StationsView: View {
|
|||||||
.navigationTitle("FuelBoard")
|
.navigationTitle("FuelBoard")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
#if DEBUG
|
||||||
// QA hook: launch with `-showKeySheet` to verify the legend
|
// QA hook: launch with `-showKeySheet` to verify the legend
|
||||||
// sheet without driving a tap (simctl has no tap command).
|
// sheet without driving a tap (simctl has no tap command).
|
||||||
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
||||||
showKey = true
|
showKey = true
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
@@ -193,39 +195,58 @@ struct StationsView: View {
|
|||||||
.sheet(isPresented: $showKey) {
|
.sheet(isPresented: $showKey) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section("Key") {
|
Section {
|
||||||
HStack(spacing: 8) {
|
VStack(spacing: 10) {
|
||||||
Circle().fill(.green).frame(width: 12, height: 12)
|
keyCard(
|
||||||
Text("Best value — within 1.5p of the cheapest")
|
icon: "checkmark.circle.fill", color: .green,
|
||||||
.font(.caption)
|
title: "Best value", blurb: "Within 1.5p of the cheapest",
|
||||||
}
|
threshold: "≤1.5p")
|
||||||
HStack(spacing: 8) {
|
keyCard(
|
||||||
Circle().fill(.orange).frame(width: 12, height: 12)
|
icon: "equal.circle.fill", color: .orange,
|
||||||
Text("Okay — within 4p of the cheapest")
|
title: "Okay", blurb: "Within 4p of the cheapest",
|
||||||
.font(.caption)
|
threshold: "≤4p")
|
||||||
}
|
keyCard(
|
||||||
HStack(spacing: 8) {
|
icon: "exclamationmark.circle.fill", color: .red,
|
||||||
Circle().fill(.red).frame(width: 12, height: 12)
|
title: "Pricey", blurb: "More than 4p over the cheapest",
|
||||||
Text("Pricey — more than 4p over the cheapest")
|
threshold: ">4p")
|
||||||
.font(.caption)
|
Rectangle()
|
||||||
}
|
.fill(Color(.separator))
|
||||||
HStack(spacing: 8) {
|
.frame(height: 0.5)
|
||||||
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")
|
||||||
@@ -236,10 +257,52 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.medium])
|
.presentationDetents([.fraction(0.6)])
|
||||||
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One price-rating card for the Key sheet: accent icon, title + blurb,
|
||||||
|
/// and the rating threshold in the accent colour (mirrors the tip cards).
|
||||||
|
private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.frame(width: 34)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(LocalizedStringKey(title)).font(.headline)
|
||||||
|
Text(LocalizedStringKey(blurb))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle().fill(color).frame(width: 10, height: 10)
|
||||||
|
Text(threshold)
|
||||||
|
.font(.headline.weight(.bold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.keyCardFill(color: color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Card background shared by the Key sheet cards — subtle accent tint over
|
||||||
|
/// the grouped background, matching the tip-section card style.
|
||||||
|
private extension View {
|
||||||
|
func keyCardFill(color: Color) -> some View {
|
||||||
|
background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).fill(color.opacity(0.08)))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
|
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
|
||||||
@@ -254,17 +317,6 @@ extension FuelType {
|
|||||||
case .diesel: return "Diesel"
|
case .diesel: return "Diesel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
|
|
||||||
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Used for the
|
|
||||||
/// fuel-type tab icons and the title icon.
|
|
||||||
var tintColor: Color {
|
|
||||||
switch self {
|
|
||||||
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
|
||||||
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
|
||||||
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fuel-type selector styled like a segmented control, with a coloured pump
|
/// Fuel-type selector styled like a segmented control, with a coloured pump
|
||||||
@@ -279,20 +331,26 @@ struct FuelTypeSegmentedPicker: View {
|
|||||||
var fuels: [FuelType] = FuelType.allCases
|
var fuels: [FuelType] = FuelType.allCases
|
||||||
var onSelect: (FuelType) -> Void = { _ in }
|
var onSelect: (FuelType) -> Void = { _ in }
|
||||||
|
|
||||||
|
@Namespace private var selectionSlider
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
ForEach(fuels) { fuel in
|
ForEach(fuels) { fuel in
|
||||||
let isSelected = fuel == selection
|
let isSelected = fuel == selection
|
||||||
Button {
|
Button {
|
||||||
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)
|
||||||
@@ -301,14 +359,29 @@ struct FuelTypeSegmentedPicker: View {
|
|||||||
if isSelected {
|
if isSelected {
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(Color(UIColor.systemBackground))
|
.fill(Color(UIColor.systemBackground))
|
||||||
.shadow(color: .black.opacity(0.08), radius: 1, y: 0.5)
|
.overlay {
|
||||||
|
Capsule()
|
||||||
|
.stroke(fuel.tintColor.opacity(0.28), lineWidth: 1)
|
||||||
|
}
|
||||||
|
.shadow(color: fuel.tintColor.opacity(0.16), radius: 6, y: 2)
|
||||||
|
.matchedGeometryEffect(id: "fuelSelectionSlider", in: selectionSlider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.scaleEffect(isSelected ? 1.02 : 0.98)
|
||||||
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(3)
|
.padding(3)
|
||||||
.background(Capsule().fill(Color(.secondarySystemBackground)))
|
.background(Capsule().fill(Color(.secondarySystemBackground)))
|
||||||
|
.animation(.spring(response: 0.42, dampingFraction: 0.78), value: selection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview("Fuel Picker") {
|
||||||
|
@Previewable @State var selectedFuel: FuelType = .e10
|
||||||
|
FuelTypeSegmentedPicker(selection: $selectedFuel)
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,15 @@
|
|||||||
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
|
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
|
||||||
"All %lld stations shown" = "All %lld stations shown";
|
"All %lld stations shown" = "All %lld stations shown";
|
||||||
"Key" = "Key";
|
"Key" = "Key";
|
||||||
"Best value — within 1.5p of the cheapest" = "Best value — within 1.5p of the cheapest";
|
"Best value" = "Best value";
|
||||||
"Okay — within 4p of the cheapest" = "Okay — within 4p of the cheapest";
|
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
|
||||||
"Pricey — more than 4p over the cheapest" = "Pricey — more than 4p over the cheapest";
|
"Okay" = "Okay";
|
||||||
|
"Within 4p of the cheapest" = "Within 4p of the cheapest";
|
||||||
|
"Pricey" = "Pricey";
|
||||||
|
"More than 4p over the cheapest" = "More than 4p over the cheapest";
|
||||||
"Top result for the current sort" = "Top result for the current sort";
|
"Top result for the current sort" = "Top result for the current sort";
|
||||||
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
||||||
|
"Colours match each station's rating on the list" = "Colours match each station's rating on the list";
|
||||||
|
|
||||||
/* Favourites tab */
|
/* Favourites tab */
|
||||||
"No favourites yet" = "No favourites yet";
|
"No favourites yet" = "No favourites yet";
|
||||||
@@ -152,3 +156,15 @@
|
|||||||
"No price history yet" = "No price history yet";
|
"No price history yet" = "No price history yet";
|
||||||
"First snapshot %@ — a few days are needed to draw a trend." = "First snapshot %@ — a few days are needed to draw a trend.";
|
"First snapshot %@ — a few days are needed to draw a trend." = "First snapshot %@ — a few days are needed to draw a trend.";
|
||||||
"Prices are recorded each day FuelBoard's relay runs — check back in a few days." = "Prices are recorded each day FuelBoard's relay runs — check back in a few days.";
|
"Prices are recorded each day FuelBoard's relay runs — check back in a few days." = "Prices are recorded each day FuelBoard's relay runs — check back in a few days.";
|
||||||
|
"Average price over the days shown" = "Average price over the days shown";
|
||||||
|
"Average pence above the day's cheapest favourite" = "Average pence above the day's cheapest favourite";
|
||||||
|
|
||||||
|
/* Offline data banner */
|
||||||
|
"Offline data from %@" = "Offline data from %@";
|
||||||
|
"Offline data" = "Offline data";
|
||||||
|
"Pull to refresh on the Stations tab" = "Pull to refresh on the Stations tab";
|
||||||
|
"Offline data from %@. Pull to refresh on the Stations tab" = "Offline data from %@. Pull to refresh on the Stations tab";
|
||||||
|
"Offline data. Pull to refresh on the Stations tab" = "Offline data. Pull to refresh on the Stations tab";
|
||||||
|
"Check your internet connection" = "Check your internet connection";
|
||||||
|
"Tap to try again" = "Tap to try again";
|
||||||
|
"Check your internet connection. Tap to try again" = "Check your internet connection. Tap to try again";
|
||||||
|
|||||||
@@ -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,73 @@ final class PriceDisplayTests: XCTestCase {
|
|||||||
XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009")
|
XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Offline data banner label
|
||||||
|
|
||||||
|
final class OfflineDataLabelTests: XCTestCase {
|
||||||
|
func testOfflineDataLabelFormatsStampWithFractionalSeconds() {
|
||||||
|
// The bundled dump's real envelope stamp.
|
||||||
|
XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33.000Z"), "15 Aug")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOfflineDataLabelToleratesPlainISODate() {
|
||||||
|
XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33Z"), "15 Aug")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOfflineDataLabelNilWhenMissingOrUnparseable() {
|
||||||
|
XCTAssertNil(FuelStore.offlineDataLabel(from: nil))
|
||||||
|
XCTAssertNil(FuelStore.offlineDataLabel(from: ""))
|
||||||
|
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Road distance cache
|
||||||
|
|
||||||
|
final class RoadDistanceCacheTests: XCTestCase {
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
// Keychain persists across invocations, so a cache left by an earlier
|
||||||
|
// test or run would pollute these. Overwrite with an empty, far-away
|
||||||
|
// cache (source at (0,0)) so every test starts from a clean slate.
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
|
||||||
|
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
|
||||||
|
lat: lat, lng: lng, prices: [:], priceUpdated: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisplayDistanceFallsBackToStraightLineWhenNoCache() {
|
||||||
|
// London user, station ~ London -> no cache -> straight-line haversine.
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoadDistanceUsedWhenCachedNear() {
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
// Cache a road distance of 3.2 km for this station from the user's fix.
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200])
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(km, 3.2, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoadDistanceNilWhenOriginFar() {
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
// Cache built in London, but the user is now ~200 km away -> stale.
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278, entries: ["a": 3200])
|
||||||
|
let meters = FuelStore.roadDistanceMeters(for: "a", userLat: 53.4808, userLng: -2.2426)
|
||||||
|
XCTAssertNil(meters)
|
||||||
|
// And display falls back to straight-line.
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426)
|
||||||
|
XCTAssertEqual(km, s.distanceKM(to: 53.4808, lng2: -2.2426), accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoadDistanceUsedForOtherStationNotFound() {
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200])
|
||||||
|
// A station that isn't in the cache falls back to straight-line.
|
||||||
|
let s = station("z", 51.51, -0.13)
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -3,21 +3,30 @@
|
|||||||
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
||||||
// Shows the cheapest station for the pinned fuel within the app's chosen
|
// Shows the cheapest station for the pinned fuel within the app's chosen
|
||||||
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
||||||
|
//
|
||||||
|
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
||||||
|
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
||||||
|
// declared (see note on the config), so iPhone/iPad always render the full
|
||||||
|
// three-column card (`richBody`) with proper text sizes. `compactBody` is kept
|
||||||
|
// as a safety fallback should any surface ever hand this view a narrow width.
|
||||||
|
//
|
||||||
|
// IMPORTANT: `.supplementalActivityFamilies([.small])` is deliberately absent —
|
||||||
|
// it made iOS render the squeezed `.small` card on the iPhone Lock Screen.
|
||||||
|
// CarPlay's small form comes from the Dynamic Island compact closures.
|
||||||
|
|
||||||
import ActivityKit
|
import ActivityKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
/// The Live Activity itself — registered in the widget bundle alongside the
|
/// The Live Activity itself — registered in the widget bundle alongside the
|
||||||
/// regular price widget. No CarPlay entitlement involved: this renders on the
|
/// regular price widget. No CarPlay entitlement involved: renders on the
|
||||||
/// Lock Screen, Dynamic Island, and the car display (CarPlay Ultra, iOS 26+).
|
/// Lock Screen (full-width card on iPhone/iPad) and the Dynamic Island
|
||||||
/// `.supplementalActivityFamilies([.small])` makes it eligible for the car's
|
/// (incl. the island's compact form used in the car, display-only — FuelBoard
|
||||||
/// small Live Activity slot — display-only there (FuelBoard is not a
|
/// is not a CarPlay-enabled app, so car-side taps can't launch anything).
|
||||||
/// CarPlay-enabled app, so car-side taps can't launch anything).
|
|
||||||
struct FuelBoardLiveActivity: Widget {
|
struct FuelBoardLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
|
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
|
||||||
// Lock Screen / banner presentation
|
// Lock Screen / banner / CarPlay small slot presentation
|
||||||
FuelBoardLiveActivityView(context: context)
|
FuelBoardLiveActivityView(context: context)
|
||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
@@ -32,7 +41,7 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "fuelpump.fill")
|
Image(systemName: "fuelpump.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
} minimal: {
|
} minimal: {
|
||||||
@@ -41,46 +50,127 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.supplementalActivityFamilies([.small])
|
.supplementalActivityFamilies([.small])
|
||||||
|
// Why `.small` is kept: it lets the SHARED body render a compact form
|
||||||
|
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
|
||||||
|
// instead of falling back to the Dynamic Island compact closure —
|
||||||
|
// which could NOT show the station distance the user wants on CarPlay.
|
||||||
|
// The full-width iPhone/iPad card is protected by the `richMinWidth`
|
||||||
|
// gate on `richBody` + its flexible, truncating middle column, so
|
||||||
|
// iPhone/iPad still get the full card; only truly small space picks
|
||||||
|
// the compact strip below.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lock Screen / banner body — the main presentation.
|
/// Lock Screen / banner / CarPlay body — adaptive.
|
||||||
private struct FuelBoardLiveActivityView: View {
|
private struct FuelBoardLiveActivityView: View {
|
||||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||||
|
|
||||||
|
/// Below this ACTUAL proposed width we show the compact strip (CarPlay
|
||||||
|
/// small / Watch smart stack); at/above it we show the full card. The
|
||||||
|
/// decision is made from the real width the system hands the body, read
|
||||||
|
/// via a background GeometryReader — NOT ViewThatFits ideal-width
|
||||||
|
/// measurement (that's broken for truncating text: a long station name
|
||||||
|
/// inflated the "ideal" width past the iPhone Lock Screen and collapsed
|
||||||
|
/// the full card).
|
||||||
|
private let compactWidthThreshold: CGFloat = 280
|
||||||
|
|
||||||
|
/// Measured slot width (drives the rich-vs-compact branch). Measured in a
|
||||||
|
/// background GeometryReader so it does NOT act as the layout container:
|
||||||
|
/// a GeometryReader root pins content top-left, and forcing a
|
||||||
|
/// maxHeight:.infinity frame on it over-claims the whole proposed height,
|
||||||
|
/// centring the content below true vertical centre (bigger gap above) on
|
||||||
|
/// the Lock Screen. Measuring behind the scenes keeps the content
|
||||||
|
/// intrinsic-sized so the system vertically centres it itself.
|
||||||
|
@State private var slotWidth: CGFloat = 400
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Link(destination: context.state.mapsURL) {
|
Link(destination: context.state.mapsURL) {
|
||||||
HStack(spacing: 12) {
|
Group {
|
||||||
// LEFT — station brand glyph
|
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
||||||
Image(systemName: "fuelpump.circle.fill")
|
// full Lock Screen width (>= threshold) → rich card, no matter
|
||||||
.font(.system(size: 32))
|
// how long the station name is. Truly small slots (CarPlay /
|
||||||
.foregroundStyle(.green, .white)
|
// Watch) offer much less → compact strip.
|
||||||
.frame(width: 40, height: 40)
|
if slotWidth >= compactWidthThreshold {
|
||||||
|
richBody
|
||||||
// MIDDLE — fuel + station
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
compactBody
|
||||||
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()
|
// Fill the card width so the background measure reads the real
|
||||||
|
// slot, not the intrinsic content width.
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
// Side-channel width measurement — never the layout container.
|
||||||
|
.background(
|
||||||
|
GeometryReader { geo in
|
||||||
|
Color.clear
|
||||||
|
.onAppear { slotWidth = geo.size.width }
|
||||||
|
.onChange(of: geo.size.width) { _, w in slotWidth = w }
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
|
||||||
|
private var richBody: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// LEFT — station brand glyph: the fuel-tinted pump on its own. No
|
||||||
|
// background circle behind it (user request).
|
||||||
|
Image(systemName: "fuelpump.fill")
|
||||||
|
.font(.system(size: 28, weight: .semibold))
|
||||||
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
// MIDDLE — fuel + station
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Cheapest \(context.state.fuel.displayName)")
|
||||||
|
.font(.headline)
|
||||||
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.75)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// RIGHT — price
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
|
style: context.state.priceDisplayStyle,
|
||||||
|
size: 22, weight: .bold)
|
||||||
|
Text("Tap for directions")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal strip for small space (CarPlay small / Watch smart stack):
|
||||||
|
/// fuel type + bold price on one line, station · distance below.
|
||||||
|
/// Deliberately no app name and no "Tap for directions" — CarPlay is
|
||||||
|
/// display-only, and the user's asks here are just fuel + price + distance.
|
||||||
|
private var compactBody: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Text(context.state.fuel.displayName)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
|
style: context.state.priceDisplayStyle,
|
||||||
|
size: 15, weight: .bold)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dynamic Island expanded regions + compact trailing — price only.
|
/// Dynamic Island expanded regions + compact trailing — price only.
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
|
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
|
||||||
]
|
]
|
||||||
guard let url = components.url else { return }
|
guard let url = components.url else { return }
|
||||||
var request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
|
let request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
|
||||||
Task {
|
Task {
|
||||||
_ = try? await URLSession.shared.data(for: request)
|
_ = try? await URLSession.shared.data(for: request)
|
||||||
}
|
}
|
||||||
@@ -312,7 +312,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
URLQueryItem(name: "radius", value: String(radiusKM)),
|
URLQueryItem(name: "radius", value: String(radiusKM)),
|
||||||
URLQueryItem(name: "limit", value: "500"),
|
URLQueryItem(name: "limit", value: "500"),
|
||||||
]
|
]
|
||||||
var request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
|
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
|
||||||
do {
|
do {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||||
@@ -340,7 +340,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
URLQueryItem(name: "fuel", value: fuel.rawValue),
|
URLQueryItem(name: "fuel", value: fuel.rawValue),
|
||||||
URLQueryItem(name: "limit", value: String(limit)),
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
]
|
]
|
||||||
var request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
|
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
|
||||||
do {
|
do {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||||
}
|
}
|
||||||
if let location = entry.location {
|
if let location = entry.location {
|
||||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
|
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
@@ -148,7 +148,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let location = entry.location {
|
if let location = entry.location {
|
||||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@
|
|||||||
// keychain → app-group defaults → fallback.
|
// keychain → app-group defaults → fallback.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
import Security
|
import Security
|
||||||
|
#if canImport(AppIntents)
|
||||||
|
import AppIntents
|
||||||
|
#endif
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Fuel types
|
// MARK: - Fuel types
|
||||||
@@ -28,6 +32,16 @@ struct FavouriteEntry: Identifiable, Codable, Equatable {
|
|||||||
var id: String { "\(fuel.rawValue)|\(station.id)" }
|
var id: String { "\(fuel.rawValue)|\(station.id)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persisted baseline for the per-fuel cheapest-favourite price-drop alert.
|
||||||
|
/// Stored separately from the favourites list so refresh-time comparison can
|
||||||
|
/// survive app relaunches without immediately alerting on first fetch.
|
||||||
|
struct FavouriteAlertSnapshot: Codable, Equatable {
|
||||||
|
let fuel: FuelType
|
||||||
|
let stationID: String
|
||||||
|
let stationName: String
|
||||||
|
let price: Double
|
||||||
|
}
|
||||||
|
|
||||||
enum SortMode: String, Codable, CaseIterable, Identifiable {
|
enum SortMode: String, Codable, CaseIterable, Identifiable {
|
||||||
case cheapest
|
case cheapest
|
||||||
case closest
|
case closest
|
||||||
@@ -59,6 +73,18 @@ enum RAGRating: Int, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum FuelType: String, Codable, CaseIterable, Identifiable {
|
enum FuelType: String, Codable, CaseIterable, Identifiable {
|
||||||
|
#if canImport(AppIntents)
|
||||||
|
typealias DisplayRepresentation = AppIntents.DisplayRepresentation
|
||||||
|
typealias TypeDisplayRepresentation = AppIntents.TypeDisplayRepresentation
|
||||||
|
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
|
||||||
|
|
||||||
|
static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [
|
||||||
|
.e10: "Unleaded",
|
||||||
|
.e5: "Premium",
|
||||||
|
.diesel: "Diesel",
|
||||||
|
]
|
||||||
|
#endif
|
||||||
case e10 // Unleaded (E10)
|
case e10 // Unleaded (E10)
|
||||||
case e5 // Premium (E5)
|
case e5 // Premium (E5)
|
||||||
case diesel // B7 diesel
|
case diesel // B7 diesel
|
||||||
@@ -74,6 +100,24 @@ enum FuelType: String, Codable, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
|
||||||
|
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Lives here in Shared
|
||||||
|
/// so the app, widget, and Live Activity all tint the pump/fuel glyphs from one
|
||||||
|
/// definition.
|
||||||
|
extension FuelType {
|
||||||
|
var tintColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
||||||
|
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
||||||
|
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(AppIntents)
|
||||||
|
extension FuelType: AppEnum {}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Display unit for all distances in the app + widget. Internally distances
|
/// Display unit for all distances in the app + widget. Internally distances
|
||||||
/// are always stored/computed in km; conversion happens at the display and
|
/// are always stored/computed in km; conversion happens at the display and
|
||||||
/// filter boundary so nothing else needs to know the unit.
|
/// filter boundary so nothing else needs to know the unit.
|
||||||
@@ -213,21 +257,37 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
|||||||
var brandImageName: String? {
|
var brandImageName: String? {
|
||||||
let raw = brand.uppercased()
|
let raw = brand.uppercased()
|
||||||
let known: [(String, String)] = [
|
let known: [(String, String)] = [
|
||||||
|
("CIRCLE K", "brand_circle_k"),
|
||||||
|
("MAXOL", "brand_maxol"),
|
||||||
|
("SPAR", "brand_spar"),
|
||||||
("SHELL", "brand_shell"),
|
("SHELL", "brand_shell"),
|
||||||
("SAINSBURY", "brand_sainsburys"),
|
("SAINSBURY", "brand_sainsburys"),
|
||||||
("MORRISONS", "brand_morrisons"),
|
("MORRISONS", "brand_morrisons"),
|
||||||
("APPLEGREEN", "brand_applegreen"),
|
("APPLEGREEN", "brand_applegreen"),
|
||||||
("TESCO", "brand_tesco"),
|
("TESCO", "brand_tesco"),
|
||||||
("TEXACO", "brand_texaco"),
|
("TEXACO", "brand_texaco"),
|
||||||
|
("ESSAR", "brand_essar"),
|
||||||
("ESSO", "brand_esso"),
|
("ESSO", "brand_esso"),
|
||||||
("ASDA", "brand_asda"),
|
("ASDA", "brand_asda"),
|
||||||
("GULF", "brand_gulf"),
|
("GULF", "brand_gulf"),
|
||||||
|
("VALERO", "brand_valero"),
|
||||||
|
("WELCOME BREAK", "brand_welcome_break"),
|
||||||
|
("THE CO-OPERATIVE", "brand_the_co_operative"),
|
||||||
|
("CO-OPERATIVE", "brand_the_co_operative"),
|
||||||
|
("CO OPERATIVE", "brand_the_co_operative"),
|
||||||
|
("CO-OP", "brand_the_co_operative"),
|
||||||
|
("CO OP", "brand_the_co_operative"),
|
||||||
|
("MURCO", "brand_murco"),
|
||||||
|
("GLEANER", "brand_gleaner"),
|
||||||
|
("HIGHLAND FUELS", "brand_highland_fuels"),
|
||||||
("BP", "brand_bp"),
|
("BP", "brand_bp"),
|
||||||
("JET", "brand_jet"),
|
("JET", "brand_jet"),
|
||||||
]
|
]
|
||||||
for (needle, asset) in known where raw.contains(needle) {
|
for (needle, asset) in known where raw.contains(needle) {
|
||||||
return asset
|
return asset
|
||||||
}
|
}
|
||||||
|
if raw.contains("EG ON THE MOVE") { return "brand_esso" }
|
||||||
|
if raw == "GO" || raw.contains("GO FORECOURT") || raw.contains("GO FUEL") { return "brand_gulf" }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +359,9 @@ struct FuelStore {
|
|||||||
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
||||||
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
|
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
|
||||||
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance
|
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance
|
||||||
|
static let favouriteDropAlertsEnabledKey = "fuelboard.favouriteDropAlertsEnabled" // Bool
|
||||||
|
static let favouriteDropAlertsFuelKey = "fuelboard.favouriteDropAlertsFuel" // FuelType raw value
|
||||||
|
static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON
|
||||||
static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance
|
static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance
|
||||||
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
||||||
static let relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback
|
static let relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback
|
||||||
@@ -516,7 +579,7 @@ struct FuelStore {
|
|||||||
let whole = tenths / 1000
|
let whole = tenths / 1000
|
||||||
let major = (tenths % 1000) / 10
|
let major = (tenths % 1000) / 10
|
||||||
let minor = tenths % 10
|
let minor = tenths % 10
|
||||||
let main = Text(String(format: "£%d.%02d", whole, major))
|
let amount = Text(String(format: "£%d.%02d", whole, major))
|
||||||
.font(base)
|
.font(base)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
let sup = Text(String(superscriptDigits[minor]))
|
let sup = Text(String(superscriptDigits[minor]))
|
||||||
@@ -526,7 +589,7 @@ struct FuelStore {
|
|||||||
let perL = Text("/L")
|
let perL = Text("/L")
|
||||||
.font(.system(size: size * 0.5, weight: .regular).monospaced())
|
.font(.system(size: size * 0.5, weight: .regular).monospaced())
|
||||||
.foregroundColor(color.opacity(0.55))
|
.foregroundColor(color.opacity(0.55))
|
||||||
return main + sup + perL
|
return Text("\(amount)\(sup)\(perL)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +671,24 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cheapest favourite for one fuel, preserving the user's stored order
|
||||||
|
/// on price ties. Used by the favourite price-drop alert so a tie does not
|
||||||
|
/// flap between equally-priced favourites.
|
||||||
|
static func cheapestFavourite(in favourites: [FavouriteEntry], fuel: FuelType) -> FavouriteEntry? {
|
||||||
|
var best: FavouriteEntry?
|
||||||
|
for entry in favourites where entry.fuel == fuel {
|
||||||
|
guard let price = entry.station.prices[fuel] else { continue }
|
||||||
|
guard let current = best, let currentPrice = current.station.prices[fuel] else {
|
||||||
|
best = entry
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if price < currentPrice {
|
||||||
|
best = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
/// Reorders ONE fuel's favourites within the global array (drag-and-drop in
|
/// Reorders ONE fuel's favourites within the global array (drag-and-drop in
|
||||||
/// the Favourites tab). The moved fuel's block stays at its original
|
/// the Favourites tab). The moved fuel's block stays at its original
|
||||||
/// position in the array; other fuels keep their relative order. The array
|
/// position in the array; other fuels keep their relative order. The array
|
||||||
@@ -691,6 +772,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 {
|
||||||
@@ -782,6 +905,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 {
|
||||||
@@ -801,10 +945,66 @@ struct FuelStore {
|
|||||||
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
|
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Road distances (Apple-Maps-matched, computed by the app)
|
||||||
|
|
||||||
|
/// Cached road/routed distances (metres) keyed by station ID, computed by
|
||||||
|
/// the app via MapKit `MKDirections`. Stored in KEYCHAIN (survives on free
|
||||||
|
/// SideStore accounts where the app-group container isn't provisioned) so
|
||||||
|
/// the widget extension can read it too. Widget + Live Activity prefer
|
||||||
|
/// these over straight-line haversine for the displayed distance.
|
||||||
|
static let roadDistancesKey = "fuelboard.roadDistances"
|
||||||
|
|
||||||
|
/// How far (metres) the cache's source location may be from the current
|
||||||
|
/// user position before a cached road distance is treated as stale.
|
||||||
|
static let roadDistanceOriginToleranceMeters: Double = 600
|
||||||
|
|
||||||
|
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: Double]) {
|
||||||
|
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
|
||||||
|
updatedAt: Date().timeIntervalSince1970, entries: entries)
|
||||||
|
if let data = try? JSONEncoder().encode(cache) {
|
||||||
|
saveString(data.base64EncodedString(), service: roadDistancesKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadRoadDistances() -> RoadDistanceCache? {
|
||||||
|
guard let raw = loadString(service: roadDistancesKey),
|
||||||
|
let data = Data(base64Encoded: raw),
|
||||||
|
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
|
||||||
|
else { return nil }
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached road distance (metres) to a station from the user's location, or
|
||||||
|
/// nil when not cached / the cache was built too far from where the user
|
||||||
|
/// is now.
|
||||||
|
static func roadDistanceMeters(for stationID: String, userLat: Double, userLng: Double) -> Double? {
|
||||||
|
guard let cache = loadRoadDistances(),
|
||||||
|
let meters = cache.entries[stationID] else { return nil }
|
||||||
|
// The cache is only valid near the location it was built from.
|
||||||
|
let dLat = (userLat - cache.sourceLat) * .pi / 180
|
||||||
|
let dLng = (userLng - cache.sourceLng) * .pi / 180
|
||||||
|
let r = 6371000.0
|
||||||
|
let a = sin(dLat / 2) * sin(dLat / 2) +
|
||||||
|
cos(cache.sourceLat * .pi / 180) * cos(userLat * .pi / 180) *
|
||||||
|
sin(dLng / 2) * sin(dLng / 2)
|
||||||
|
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
|
||||||
|
return meters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distance (km) to display for a station: cached ROAD distance when
|
||||||
|
/// available (matches Apple Maps), else straight-line haversine.
|
||||||
|
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
|
||||||
|
if let meters = roadDistanceMeters(for: station.id, userLat: userLat, userLng: userLng) {
|
||||||
|
return meters / 1000.0
|
||||||
|
}
|
||||||
|
return station.distanceKM(to: userLat, lng2: userLng)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Low-level keychain helpers
|
// MARK: Low-level keychain helpers
|
||||||
|
|
||||||
private static func keychainData(service: String) -> Data? {
|
private static func keychainData(service: String) -> Data? {
|
||||||
var query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecReturnData as String: true,
|
kSecReturnData as String: true,
|
||||||
@@ -860,3 +1060,12 @@ struct FuelStore {
|
|||||||
loadString(service: "widget.diag.\(intentType)")
|
loadString(service: "widget.diag.\(intentType)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cached Apple-Maps road distances for nearby stations (see
|
||||||
|
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres.
|
||||||
|
struct RoadDistanceCache: Codable {
|
||||||
|
let sourceLat: Double
|
||||||
|
let sourceLng: Double
|
||||||
|
let updatedAt: TimeInterval
|
||||||
|
let entries: [String: Double]
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,9 +96,13 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
|||||||
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
|
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
|
||||||
data = cached
|
data = cached
|
||||||
} else {
|
} else {
|
||||||
let (fetched, response) = try await URLSession.shared.data(
|
// Bypass the HTTP cache + short timeout: an offline refresh must
|
||||||
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL)
|
// FAIL (→ offline banner), never re-serve a stale cached dump as
|
||||||
)
|
// a "successful" live fetch.
|
||||||
|
var req = URLRequest(url: FuelHistoryStore.historyFileURL(day: day, base: baseURL))
|
||||||
|
req.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
req.timeoutInterval = 10
|
||||||
|
let (fetched, response) = try await URLSession.shared.data(for: req)
|
||||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
throw FuelProviderError.mirrorUnavailable
|
throw FuelProviderError.mirrorUnavailable
|
||||||
}
|
}
|
||||||
|
|||||||