Author SHA1 Message Date
FuelBoard Contributor cfd435d12a banner: top-floating; fix offline-trigger cache; onboarding prompt timing
- Banner floats at the very top (over the nav/title area), never pushing the
  content below and staying clear of the list/pill.
- Offline banner now fires on refresh in airplane mode / no data: the mirror
  live chain bypassed the HTTP cache (+10s timeout) so an offline fetch really
  fails instead of silently re-serving a cached pointer+dump as a 'success'
  (which kept dataStatus .live and hid the banner).
- Onboarding permissions fire at the Continue/Allow tap BEFORE advancing, so
  the system prompt appears after the page is read and never covers the next
  page's animation (grant auto-advances).
2026-08-19 10:17:24 +01:00
FuelBoard Contributor 8936643e0e banner: float below nav bar (no glass pass-through) + slide-up dismiss
Position the floating banner just below the nav bar so it no longer overlays the iOS 26 Liquid Glass back/toolbar chrome behind it — tapping retry can no longer also select the control beneath. Banner now slides up + fades on dismiss/timeout (one style for all notifications); full-rect contentShape for a solid tap target.
2026-08-19 08:43:35 +01:00
FuelBoard Contributor ba83f9e1c0 banner: unify network+tip into ONE floating overlay (never layout-shifting)
Extract AppBanner; render offline/connection/tip through the single statusBannerCard chrome as a ZStack top overlay so it floats over content instead of shifting layout. Tip (auto-dismiss ~3.5s) takes precedence over network state.
2026-08-19 08:17:50 +01:00
FuelBoard Contributor 57dffb6c5e tip: show outcome in the shared status banner (auto-dismiss)
Tip purchase results now ride the SAME banner chrome as the network/offline
strip above the tabs instead of a hard-to-see Settings footnote. TipStore is
lifted to ContentView (the banner owner) and shared down to Settings. Each
outcome maps to an icon/tint; auto-dismisses after ~3.5 s; tap to dismiss.
Renderer refactored into a shared statusBannerCard(icon:tint:title:subtitle:
trailingIcon:action:) used by both the network strip (retry) and tips (x).
2026-08-19 07:48:10 +01:00
FuelBoard Contributor 2205a585f1 onboarding: animated feature icons 2026-08-19 07:09:54 +01:00
FuelBoard Contributor f9b70dbb9d anim: consistent bold crossfade in fuel-type picker 2026-08-19 07:09:54 +01:00
FuelBoard Contributor e00e3ff365 backlog: refine foresight live activity notes 2026-08-18 19:33:54 +01:00
FuelBoard Contributor 4f8bf96ca2 backlog: add foresight live activity investigation 2026-08-18 18:15:33 +01:00
FuelBoard Contributor 8945190da5 fix: keep live activity in sync with motion 2026-08-18 17:59:03 +01:00
FuelBoard Contributor 2e23dcb9ae ui: keep station brand badges white 2026-08-18 12:56:49 +01:00
FuelBoard Contributor db916cafcb ui: fix fallback station badge visibility 2026-08-18 12:47:49 +01:00
FuelBoard Contributor 1dd7836b8b assets: expand station brand icon coverage 2026-08-18 12:35:03 +01:00
FuelBoard Contributor e512cec958 data: refresh bundled fuel dump 2026-08-18 11:05:09 +01:00
FuelBoard Contributor dcd5517825 Live Activity: add distance to compact CarPlay layout 2026-08-18 10:00:19 +01:00
FuelBoard Contributor cd2632377a Expand watch app backlog 2026-08-17 20:23:20 +01:00
FuelBoard Contributor f1c5923c88 Add favourite price-drop Check now action 2026-08-17 19:59:45 +01:00
FuelBoard Contributor 276e0ff99e Swap Live Activity header and toggle labels 2026-08-17 17:28:14 +01:00
FuelBoard Contributor bed39956a3 Move cheapest station label to Live Activity header 2026-08-17 17:21:31 +01:00
FuelBoard Contributor b24b59df6f Rename Live Activity fuel label 2026-08-17 17:14:51 +01:00
FuelBoard Contributor 2495c7e47e Finalize alerts tab labels 2026-08-17 16:58:23 +01:00
FuelBoard Contributor 923209b933 Reorder alerts sections and refine labels 2026-08-17 16:53:26 +01:00
FuelBoard Contributor 02ac8540f3 Tweak alerts tab copy 2026-08-17 16:45:55 +01:00
FuelBoard Contributor 6f0fc43085 Add cheapest-favourite price-drop alerts 2026-08-17 16:33:46 +01:00
FuelBoard Contributor 63084322f6 Refine trends and debug harness; clean up warnings 2026-08-17 14:06:26 +01:00
FuelBoard Contributor 68d970720a backlog: CT 111 rootfs grown to 8 GB (pve); open item retired 2026-08-16 21:19:39 +01:00
FuelBoard Contributor 9952a096a4 docs: CT 111 is source of truth; Mac clone read-only mirror 2026-08-16 21:04:44 +01:00
FuelBoard Contributor 49503bef99 backlog: relay+mirror moved to Proxmox CT 111 (fuelboard-relay), live push verified 2026-08-16 18:55:07 +01:00
54 changed files with 1299 additions and 241 deletions
+75 -15
View File
@@ -13,20 +13,30 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
## P1 — Soon ## P1 — Soon
- [ ] **Move relay + mirror off the Mac mini (plan for 2026-08-16)** — dedicated - [x] **Move relay + mirror off the Mac mini (DONE 2026-08-16)** — dedicated
Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh` Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh`
(CT ~110, hostname `fuelboard-relay`, 1 core/1 GB/8 GB, static IP (CT **111**, hostname **`fuelboard-relay`**, 1 core/512 MB/2 GB, static IP
~192.168.1.150, plain LXC — NO Docker layer). Scope: relay AND mirror both **192.168.1.113**, plain LXC — NO Docker layer). Scope: relay AND mirror
move (full Mac independence; relay binds loopback only, GitHub is the only both moved (full Mac independence; relays bind **loopback only**, GitHub is
public surface). Steps: user creates CT on pve → install Python (trixie = the only public surface). What landed: Python 3.13.5 (trixie) boots both
3.13 vs Mac 3.14 — verify relay boots) + relay source + `.env` (GOV.UK relays (relay 8788 demo/CSV + relay-api 8789 `source: api`, 8,024 stations,
token) → copy deploy key `~/.ssh/fuelboard_deploy` + `github-fuelboard-data` ~2 min first sync, 0 failures); source at `/root/workspace/fuelboard-{relay,relay-api}`
ssh config → systemd `fuelboard-relay.service` (Restart=always, (venvs + pip, `.env` chmod 600); deploy key `fuelboard_deploy` +
127.0.0.1:8789/8788) + `fuelboard-mirror.timer` (10:00 + 16:00) + service → `github-fuelboard-data` ssh config + `fuelboard-data` clone at
relocate `mirror_push.py` (RELAY line → localhost on CT) + fuelboard-data `/root/workspace/fuelboard-data` (origin = ssh alias, green + synced);
clone → verify one live push → remove Mac launchd `mirror_push.py` → `/root/scripts/mirror_push.py` (RELAY line already
`com.apt.fuelboard-{relay,mirror}`. Open: confirm IP; logs move to `127.0.0.1:8789`); systemd `fuelboard-relay.service` +
journalctl. `fuelboard-relay-api.service` (Restart=always, `--host 127.0.0.1`) +
`fuelboard-mirror.service` (oneshot) + `.timer` (10:00 + 16:00, Persistent,
armed — next Mon 10:00); first live push VERIFIED from outside
(commit `735a292`, latest.json `data_updated 16:50:53Z`); Mac launchd
`com.apt.fuelboard-{relay,relay-api,mirror}` unloaded + plists parked in
`~/Library/LaunchAgents/fuelboard-migrated-to-ct/`; editor 8790 + IPA 8765
stay on the Mac; price watchdog `relay_price_watchdog.py` now fetches via
SSH (`~/.ssh/fuelboard_ct` → `/root/scripts/watchdog_fetch.py`). Logs via
`journalctl -u fuelboard-*` on the CT; SSH into CT: `ssh -i ~/.ssh/fuelboard_ct root@192.168.1.113`.
**Open: rootfs is 2 GB (1.1 GB free) — mirror retention ~940 MB will get
tight; rootfs grown to 8 GB on pve 2026-08-16 (5.9 GB free).**
- [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent` - [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
(App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first (App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first
@@ -96,6 +106,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
+21 -3
View File
@@ -12,6 +12,8 @@ struct AlertsView: View {
@Binding var enabled: Bool @Binding var enabled: Bool
@Binding var radius: Double // stored in km (monitor + storage) @Binding var radius: Double // stored in km (monitor + storage)
@Binding var fuel: FuelType // the fuel alerts monitor for @Binding var fuel: FuelType // the fuel alerts monitor for
@Binding var favouriteDropEnabled: Bool
@Binding var favouriteDropFuel: FuelType
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi). /// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
@Binding var followsSearch: Bool @Binding var followsSearch: Bool
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen /// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
@@ -30,6 +32,7 @@ struct AlertsView: View {
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
let monitoredCount: Int let monitoredCount: Int
let lastAlert: String? let lastAlert: String?
let onCheckFavouriteDropNow: () -> Void
/// The Stations-tab search distance shown in the user's unit, e.g. /// The Stations-tab search distance shown in the user's unit, e.g.
/// "5 miles" or "8 km" — for the "Follow search" rows. /// "5 miles" or "8 km" — for the "Follow search" rows.
@@ -131,7 +134,22 @@ struct AlertsView: View {
NavigationStack { NavigationStack {
List { List {
Section { Section {
Toggle("Cheapest-station alerts", isOn: $enabled) Toggle("Cheapest favourite", isOn: $favouriteDropEnabled)
Picker("Fuel", selection: $favouriteDropFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
Button("Check now", action: onCheckFavouriteDropNow)
.disabled(!favouriteDropEnabled)
} header: {
Text("Price-drop alerts")
} footer: {
Text("Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.")
}
Section {
Toggle("Cheapest station", isOn: $enabled)
Picker("Fuel", selection: $fuel) { Picker("Fuel", selection: $fuel) {
ForEach(FuelType.allCases) { fuel in ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel) Text(fuel.displayName).tag(fuel)
@@ -152,7 +170,7 @@ struct AlertsView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} header: { } header: {
Text("Cheapest-station alerts") Text("Proximity alerts")
} footer: { } footer: {
Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling \(fuel.displayName.lowercased()) are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.") Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling \(fuel.displayName.lowercased()) are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.")
} }
@@ -165,7 +183,7 @@ struct AlertsView: View {
} }
Section { Section {
Toggle("Live Activity", isOn: $liveActivityEnabled) Toggle("Cheapest station", isOn: $liveActivityEnabled)
Picker("Fuel", selection: $liveActivityFuel) { Picker("Fuel", selection: $liveActivityFuel) {
ForEach(FuelType.allCases) { fuel in ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel) Text(fuel.displayName).tag(fuel)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 208 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_circle_k_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_circle_k_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

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

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

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

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

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

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

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

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

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

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

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

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

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

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

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

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+276 -106
View File
@@ -2,6 +2,15 @@ import SwiftUI
import CoreLocation import CoreLocation
import WidgetKit import WidgetKit
/// The single floating notification banner. Network/offline state and tip
/// outcomes all funnel through ONE style and render via the same chrome in
/// ContentView — never pushing layout, always overlaid.
enum AppBanner: Equatable {
case offlineDump(date: String)
case connectionProblem
case tip(TipStore.TipOutcome)
}
struct ContentView: View { struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@@ -15,6 +24,8 @@ struct ContentView: View {
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled() @State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel() @State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
@State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch() @State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch()
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled() @State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel() @State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
@@ -30,6 +41,24 @@ struct ContentView: View {
private var effectiveAlertsRadiusKM: Double { private var effectiveAlertsRadiusKM: Double {
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius) FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
} }
private func installLocationUpdateHook() {
locationManager.onLocationUpdate = { [weak monitor] in
monitor?.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
// The Live Activity follows the same wake-ups — this hook
// fires on every fix incl. background significant-change
// wake-ups, so the Lock Screen pill stays live while driving.
updateLiveActivity()
}
}
private func ensureBackgroundMotionTrackingIfNeeded() {
if alertsEnabled || liveActivityEnabled {
locationManager.startBackgroundTracking()
}
}
@State private var isLoading = false @State private var isLoading = false
@State private var statusMessage = "" @State private var statusMessage = ""
/// What data is on screen, driving which (if any) status banner shows /// What data is on screen, driving which (if any) status banner shows
@@ -50,6 +79,10 @@ struct ContentView: View {
@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()
/// 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
@@ -152,85 +185,69 @@ struct ContentView: View {
} }
var body: some View { var body: some View {
VStack(spacing: 0) { configuredContent
switch dataStatus { }
case .offlineDump(let date):
let title = offlineTitle(date: date)
statusBanner(
icon: "wifi.slash",
tint: .orange,
title: title,
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
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)
)
case .connectionProblem:
statusBanner(
icon: "wifi.exclamationmark",
tint: .red,
title: NSLocalizedString("Check your internet connection", comment: ""),
subtitle: NSLocalizedString("Tap to try again", comment: ""),
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
)
case .live:
EmptyView()
}
TabView(selection: $selectedTab) {
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 {
@@ -241,6 +258,14 @@ 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 // `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
// failure legs for the screenshot harness. The auto-refresh below // failure legs for the screenshot harness. The auto-refresh below
// is skipped so the banner stays up (a live fetch would clear it). // is skipped so the banner stays up (a live fetch would clear it).
@@ -254,6 +279,9 @@ struct ContentView: View {
: FuelStore.loadStations() : FuelStore.loadStations()
dataStatus = .connectionProblem 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
@@ -261,30 +289,34 @@ 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).
// Skipped under the force-* hooks so the banner stays up. // Skipped under the force-* hooks so the banner stays up.
if !args.contains("-forceOfflineDump") && !args.contains("-forceConnectionProblem") && !args.contains("-forceHistoryFailure") { #if DEBUG
let shouldSkipInitialRefresh = args.contains("-forceOfflineDump")
|| args.contains("-forceConnectionProblem")
|| args.contains("-forceHistoryFailure")
#else
let shouldSkipInitialRefresh = false
#endif
if !shouldSkipInitialRefresh {
Task { await refresh() } Task { await refresh() }
} }
} else { } else {
@@ -301,6 +333,8 @@ struct ContentView: View {
// empty on a brand-new install. // empty on a brand-new install.
if !showing, FuelStore.loadHasCompletedOnboarding() { if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity() updateLiveActivity()
@@ -335,7 +369,13 @@ struct ContentView: View {
updateLiveActivity() updateLiveActivity()
} }
} }
.onChange(of: selectedFuel) { _, _ in )
}
private var interactionObservedContent: AnyView {
AnyView(
lifecycleObservedContent
.onChange(of: selectedFuel) { _, _ in
// No re-fetch needed — one response carries E5/E10/DIESEL prices. // No re-fetch needed — one response carries E5/E10/DIESEL prices.
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
} }
@@ -358,9 +398,7 @@ struct ContentView: View {
monitor.setEnabled(newValue) monitor.setEnabled(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
if newValue { ensureBackgroundMotionTrackingIfNeeded()
locationManager.startBackgroundTracking()
}
} }
.onChange(of: alertsRadius) { _, newValue in .onChange(of: alertsRadius) { _, newValue in
FuelStore.saveAlertsRadius(newValue) FuelStore.saveAlertsRadius(newValue)
@@ -381,10 +419,17 @@ struct ContentView: View {
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
} }
.onChange(of: favouriteDropAlertsEnabled) { _, newValue in
FuelStore.saveFavouriteDropAlertsEnabled(newValue)
}
.onChange(of: favouriteDropAlertsFuel) { _, newValue in
FuelStore.saveFavouriteDropAlertsFuel(newValue)
}
.onChange(of: liveActivityEnabled) { _, newValue in .onChange(of: liveActivityEnabled) { _, newValue in
// Toggling the Live Activity on starts it with the current best // Toggling the Live Activity on starts it with the current best
// station; toggling off ends any running activity. // station; toggling off ends any running activity.
FuelStore.saveLiveActivityEnabled(newValue) FuelStore.saveLiveActivityEnabled(newValue)
ensureBackgroundMotionTrackingIfNeeded()
updateLiveActivity() updateLiveActivity()
} }
.onChange(of: liveActivityFuel) { _, newValue in .onChange(of: liveActivityFuel) { _, newValue in
@@ -420,8 +465,61 @@ 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)
} }
} // VStack: status banner + TabView )
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus) }
/// The one banner to show right now. Network/offline and tip outcomes all
/// funnel here and render through the SAME floating chrome. A transient
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
/// network state for its ~3.5 s.
private var activeBanner: AppBanner? {
if let outcome = tipStore.outcome { return .tip(outcome) }
switch dataStatus {
case .live: return nil
case .offlineDump(let date): return .offlineDump(date: date)
case .connectionProblem: return .connectionProblem
}
}
private var mainContent: some View {
GeometryReader { geo in
ZStack(alignment: .top) {
rootTabView
if let banner = activeBanner {
// Floating near the top of the screen, over the nav area —
// overlays content (never pushes it) and sits above the
// main content so it doesn't cover or block the list/pill
// beneath it.
floatingBanner(banner)
.padding(.top, geo.safeAreaInsets.top + 10)
.transition(.asymmetric(
insertion: .move(edge: .top).combined(with: .opacity),
// Slide up + fade on dismiss/timeout (one style for all).
removal: .move(edge: .top).combined(with: .opacity)
))
}
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: activeBanner)
}
private var rootTabView: some View {
TabView(selection: $selectedTab) {
stationsTab
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
.tag(0)
favouritesTab
.tabItem { Label("Favourites", systemImage: "star.fill") }
.tag(1)
alertsTab
.tabItem { Label("Alerts", systemImage: "bell.fill") }
.tag(2)
settingsTab
.tag(3)
}
} }
/// The banner title for the bundled-snapshot case: date when the stamp /// The banner title for the bundled-snapshot case: date when the stamp
@@ -432,13 +530,51 @@ struct ContentView: View {
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date) : String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
} }
/// Shared status-strip chrome: a tappable card pinned above the tabs. /// ONE floating banner style for all notifications. Network/offline and
/// Tapping retries the live fetch from ANY screen — no pull gesture /// tip outcomes share the same chrome; only the tap action differs —
/// needed, so the offline banner isn't trapped on the Stations tab. /// network → retry the fetch, tip → dismiss (TipStore auto-dismisses too).
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View { @ViewBuilder
Button { private func floatingBanner(_ banner: AppBanner) -> some View {
Task { await refresh(force: true) } switch banner {
} label: { 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) { HStack(spacing: 10) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 17, weight: .semibold)) .font(.system(size: 17, weight: .semibold))
@@ -448,12 +584,14 @@ struct ContentView: View {
Text(title) Text(title)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
Text(subtitle) if let subtitle {
.font(.caption) Text(subtitle)
.foregroundStyle(.secondary) .font(.caption)
.foregroundStyle(.secondary)
}
} }
Spacer() Spacer()
Image(systemName: "arrow.clockwise") Image(systemName: trailingIcon)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint) .foregroundStyle(tint)
} }
@@ -466,13 +604,13 @@ struct ContentView: View {
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(tint.opacity(0.35), lineWidth: 1) .stroke(tint.opacity(0.35), lineWidth: 1)
) )
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
) )
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.bottom, 6)
} }
.contentShape(Rectangle())
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel) .accessibilityLabel(accessibilityLabel)
.transition(.move(edge: .top).combined(with: .opacity))
} }
/// Pushes the current best-in-radius station into the Live Activity. /// Pushes the current best-in-radius station into the Live Activity.
@@ -532,10 +670,35 @@ struct ContentView: View {
) )
} }
/// 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) }
}
)
}
/// The Settings tab, extracted from `body` so the TabView expression stays /// The Settings tab, extracted from `body` so the TabView expression stays
/// 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,
@@ -591,6 +754,7 @@ struct ContentView: View {
isLoading = true isLoading = true
defer { isLoading = false } defer { isLoading = false }
do { do {
let previousFavouriteSnapshots = FuelStore.loadFavouriteAlertSnapshots()
let fetched = try await FuelPriceProvider.active.fetchStations( let fetched = try await FuelPriceProvider.active.fetchStations(
near: location?.lat, lng: location?.lng, near: location?.lat, lng: location?.lng,
fuel: selectedFuel, fuel: selectedFuel,
@@ -610,6 +774,12 @@ 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. // Live data restored — any status banner no longer applies.
@@ -684,14 +854,14 @@ struct StationRow: View {
.padding(5) .padding(5)
} else { } else {
Image(systemName: "fuelpump.fill") Image(systemName: "fuelpump.fill")
.font(.system(size: 22)) .font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(Color.black.opacity(0.82))
} }
} }
.frame(width: 46, height: 46) .frame(width: 46, height: 46)
.background(Circle().fill(.white)) .background(Circle().fill(Color.white))
.clipShape(Circle()) .clipShape(Circle())
.overlay(Circle().stroke(Color.primary.opacity(0.08), lineWidth: 1)) .overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
.shadow(color: .black.opacity(0.08), radius: 2, y: 1) .shadow(color: .black.opacity(0.08), radius: 2, y: 1)
// MIDDLE — name, full address, distance // MIDDLE — name, full address, distance
+2
View File
@@ -172,11 +172,13 @@ struct FavouritesView: View {
) )
} }
.onAppear { .onAppear {
#if DEBUG
// QA hook: launch with `-showTrends` to open the sheet // QA hook: launch with `-showTrends` to open the sheet
// without a tap (same pattern as -showKeySheet). // without a tap (same pattern as -showKeySheet).
if ProcessInfo.processInfo.arguments.contains("-showTrends") { if ProcessInfo.processInfo.arguments.contains("-showTrends") {
showTrends = true showTrends = true
} }
#endif
} }
} }
} }
+120 -19
View File
@@ -85,7 +85,7 @@ struct OnboardingView: View {
private var welcomePage: some View { private var welcomePage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .accentColor) {
// The app icon itself, with the standard iOS rounded-corner // The app icon itself, with the standard iOS rounded-corner
// treatment (~22% of the size), so onboarding matches the // treatment (~22% of the size), so onboarding matches the
// Home Screen. // Home Screen.
@@ -122,8 +122,7 @@ struct OnboardingView: View {
private var locationPage: some View { private var locationPage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
Image(systemName: "location.fill") Image(systemName: "location.fill")
.font(.system(size: 40, weight: .semibold)) .font(.system(size: 40, weight: .semibold))
.foregroundStyle(.blue) .foregroundStyle(.blue)
@@ -154,8 +153,7 @@ struct OnboardingView: View {
private var notificationsPage: some View { private var notificationsPage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
Image(systemName: "bell.badge.fill") Image(systemName: "bell.badge.fill")
.font(.system(size: 40, weight: .semibold)) .font(.system(size: 40, weight: .semibold))
.foregroundStyle(.orange) .foregroundStyle(.orange)
@@ -186,8 +184,7 @@ struct OnboardingView: View {
private var dataPage: some View { private var dataPage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
Image(systemName: "arrow.down.circle.fill") Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 40, weight: .semibold)) .font(.system(size: 40, weight: .semibold))
.foregroundStyle(.teal) .foregroundStyle(.teal)
@@ -248,19 +245,27 @@ struct OnboardingView: View {
) { ) {
if prompter.locationDenied { if prompter.locationDenied {
openSettings() openSettings()
} else if prompter.locationGranted {
page = 2 // already decided — just move on
} else { } else {
// The prompt fires on LEAVING this page (onChange(of: // Fire the system prompt NOW, while this page is still on
// page), forward advance only) — the description is on // screen (after it's been read) and BEFORE advancing — so
// screen until the user moves on. // the prompt never covers the next page's animation. Auto-
page = 2 // advance on grant moves us on once the user responds;
// denying leaves the page's "Open Settings" path.
prompter.requestLocation()
} }
} }
case 2: case 2:
primaryButton( primaryButton(
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications") prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
) { ) {
// Prompt fires on leaving this page (same rule as Location). if prompter.notificationsDenied || prompter.notificationsGranted {
page = 3 page = 3 // decided either way — move on
} else {
// Same rule as Location: prompt now, before advancing.
prompter.requestNotifications()
}
} }
case 3: case 3:
// No permission on this page — prices need none. Straight on. // No permission on this page — prices need none. Straight on.
@@ -326,10 +331,103 @@ struct OnboardingView: View {
} }
} }
private struct AnimatedOnboardingIcon<Content: View>: View {
enum Motion {
case gentle
case locationArrow
case alarmBell
case downloadArrow
}
let tint: Color
var motion: Motion = .gentle
@ViewBuilder var content: Content
@State private var settled = false
@State private var iconRotation: Double = 0
@State private var iconOffsetY: CGFloat = 0
@State private var iconScale: CGFloat = 0.92
var body: some View {
ZStack {
Circle()
.fill(tint.opacity(0.10))
.frame(width: 108, height: 108)
.scaleEffect(settled ? 1.0 : 0.94)
Circle()
.stroke(tint.opacity(0.18), lineWidth: 1)
.frame(width: 118, height: 118)
.scaleEffect(settled ? 1.03 : 0.97)
.opacity(settled ? 1.0 : 0.55)
content
.scaleEffect(iconScale)
.rotationEffect(.degrees(iconRotation))
.offset(y: iconOffsetY)
}
.shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2)
.animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled)
.onAppear {
playAnimation()
}
}
private func playAnimation() {
settled = false
iconRotation = 0
iconOffsetY = 4
iconScale = 0.92
switch motion {
case .gentle:
withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
settled = true
iconOffsetY = 0
iconScale = 1.0
}
case .locationArrow:
iconRotation = -120
withAnimation(.linear(duration: 0.45)) {
settled = true
iconRotation = 24
iconOffsetY = 0
iconScale = 1.02
}
withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) {
iconRotation = 0
iconScale = 1.0
}
case .alarmBell:
settled = true
iconOffsetY = 0
iconScale = 1.0
withAnimation(.linear(duration: 0.10)) { iconRotation = -14 }
withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 }
withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 }
withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 }
withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 }
case .downloadArrow:
iconOffsetY = -22
withAnimation(.easeIn(duration: 0.22)) {
settled = true
iconScale = 1.0
iconOffsetY = 8
}
withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) {
iconOffsetY = 0
}
}
}
}
/// Owns the system permission requests during onboarding and publishes /// Owns the system permission requests during onboarding and publishes
/// their outcomes so the pages can reflect them live. /// their outcomes so the pages can reflect them live.
@MainActor @MainActor
final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate { final class OnboardingPermissionPrompter: NSObject, ObservableObject, CLLocationManagerDelegate {
@Published private(set) var locationGranted = false @Published private(set) var locationGranted = false
@Published private(set) var locationDenied = false @Published private(set) var locationDenied = false
@Published private(set) var notificationsGranted = false @Published private(set) var notificationsGranted = false
@@ -358,11 +456,14 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
Task { @MainActor in Task { @MainActor in
switch settings.authorizationStatus { switch settings.authorizationStatus {
case .notDetermined: case .notDetermined:
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in do {
Task { @MainActor in let granted = try await UNUserNotificationCenter.current()
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
+66
View File
@@ -79,6 +79,7 @@ struct TieChoice {
/// entering the winner's circle — not at the forecourt. /// entering the winner's circle — not at the forecourt.
@MainActor @MainActor
final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate { final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate {
private static let favouriteDropThresholdPence: Double = 1.0
@Published var monitoredStationIDs: [String] = [] @Published var monitoredStationIDs: [String] = []
@Published var lastAlert: String? @Published var lastAlert: String?
@Published private(set) var lastTestResult: String? @Published private(set) var lastTestResult: String?
@@ -218,6 +219,46 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
var isEnabled: Bool { enabled } var isEnabled: Bool { enabled }
/// Compares the latest refreshed favourites against the persisted cheapest-
/// favourite baseline for one fuel and schedules a notification when the
/// winner changes or the current winner drops by a meaningful amount.
func evaluateFavouritePriceDropAlert(
favourites: [FavouriteEntry],
previousSnapshots: [FuelType: FavouriteAlertSnapshot],
monitoredFuel: FuelType,
enabled: Bool
) {
var snapshots = previousSnapshots
guard let current = FuelStore.cheapestFavourite(in: favourites, fuel: monitoredFuel),
let currentPrice = current.station.prices[monitoredFuel] else {
snapshots.removeValue(forKey: monitoredFuel)
FuelStore.saveFavouriteAlertSnapshots(snapshots)
return
}
let currentSnapshot = FavouriteAlertSnapshot(
fuel: monitoredFuel,
stationID: current.station.id,
stationName: current.station.name,
price: currentPrice
)
defer {
snapshots[monitoredFuel] = currentSnapshot
FuelStore.saveFavouriteAlertSnapshots(snapshots)
}
guard enabled else { return }
guard let previous = previousSnapshots[monitoredFuel] else { return }
let winnerChanged = previous.stationID != currentSnapshot.stationID
let priceDropped = previous.stationID == currentSnapshot.stationID
&& currentSnapshot.price <= previous.price - Self.favouriteDropThresholdPence
guard winnerChanged || priceDropped else { return }
fireFavouriteDropAlert(current: current, previous: previous, price: currentPrice, winnerChanged: winnerChanged)
}
/// Records a stage in the live alert trace, newest first, capped at 8. /// Records a stage in the live alert trace, newest first, capped at 8.
private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) { private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) {
alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0) alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0)
@@ -452,6 +493,31 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away" lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away"
} }
private func fireFavouriteDropAlert(
current: FavouriteEntry,
previous: FavouriteAlertSnapshot,
price: Double,
winnerChanged: Bool
) {
let content = UNMutableNotificationContent()
content.sound = .default
let fuel = current.fuel
let previousPriceText = String(format: "%.1fp", previous.price)
let currentPriceText = String(format: "%.1fp", price)
if winnerChanged {
content.title = "New cheapest \(fuel.displayName.lowercased()) favourite"
content.body = "\(current.station.name) is now your cheapest favourite at \(currentPriceText), ahead of \(previous.stationName)."
} else {
content.title = "Cheapest favourite just dropped"
content.body = "\(current.station.name) fell from \(previousPriceText) to \(currentPriceText) for \(fuel.displayName.lowercased())."
}
addAlertRequest(content: content, station: current.station)
logAlert(.fired, "favourite alert scheduled — \(current.station.name) · \(currentPriceText)")
lastAlert = "Favourite · \(current.station.name) · \(currentPriceText)"
}
/// Adds a notification whose tap opens directions to `station`. The /// Adds a notification whose tap opens directions to `station`. The
/// station's coordinates ride in `userInfo` so the tap handler can route /// station's coordinates ride in `userInfo` so the tap handler can route
/// to Apple Maps (or an in-app fallback), and a map snapshot with a pin is /// to Apple Maps (or an in-app fallback), and a map snapshot with a pin is
+73 -22
View File
@@ -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).
@@ -359,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 {
@@ -408,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)
@@ -567,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: "")
} }
} }
} }
@@ -606,9 +603,19 @@ final class TipStore: ObservableObject {
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
] ]
/// A banner outcome: the exact copy to show plus its icon + tint.
struct TipOutcome: Equatable {
let message: String
let icon: String
let tint: Color
}
@Published private(set) var products: [String: Product] = [:] @Published private(set) var 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
@@ -626,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 {
@@ -634,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 {
@@ -663,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
} }
@@ -676,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
)
} }
} }
} }
-12
View File
@@ -22,18 +22,6 @@ import SwiftUI
/// "super unleaded") so Siri matches how people actually ask. The app UI's /// "super unleaded") so Siri matches how people actually ask. The app UI's
/// FuelType.displayName is untouched. /// FuelType.displayName is untouched.
// MARK: - Fuel parameter
extension FuelType: AppEnum {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [
.e10: "Unleaded",
.e5: "Premium",
.diesel: "Diesel",
]
}
// MARK: - Intent // MARK: - Intent
struct CheapestFuelIntent: AppIntent { struct CheapestFuelIntent: AppIntent {
+26 -3
View File
@@ -44,7 +44,7 @@ struct StationsView: View {
} else { } else {
ratio = "\(totalCount)" ratio = "\(totalCount)"
} }
if let location { if location != nil {
if sortMode == .closest { if sortMode == .closest {
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated" return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
} }
@@ -174,11 +174,13 @@ struct StationsView: View {
.navigationTitle("FuelBoard") .navigationTitle("FuelBoard")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.onAppear { .onAppear {
#if DEBUG
// QA hook: launch with `-showKeySheet` to verify the legend // QA hook: launch with `-showKeySheet` to verify the legend
// sheet without driving a tap (simctl has no tap command). // sheet without driving a tap (simctl has no tap command).
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") { if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
showKey = true showKey = true
} }
#endif
} }
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
@@ -340,20 +342,26 @@ struct FuelTypeSegmentedPicker: View {
var fuels: [FuelType] = FuelType.allCases var fuels: [FuelType] = FuelType.allCases
var onSelect: (FuelType) -> Void = { _ in } var onSelect: (FuelType) -> Void = { _ in }
@Namespace private var selectionSlider
var body: some View { var body: some View {
HStack(spacing: 3) { HStack(spacing: 3) {
ForEach(fuels) { fuel in ForEach(fuels) { fuel in
let isSelected = fuel == selection let isSelected = fuel == selection
Button { Button {
selection = fuel withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
selection = fuel
}
onSelect(fuel) onSelect(fuel)
} label: { } label: {
HStack(spacing: 5) { HStack(spacing: 5) {
Image(systemName: "fuelpump.fill") Image(systemName: "fuelpump.fill")
.font(.caption2) .font(.caption2)
.foregroundStyle(fuel.tintColor) .foregroundStyle(fuel.tintColor)
.scaleEffect(isSelected ? 1.08 : 0.94)
Text(fuel.shortName) Text(fuel.shortName)
.font(.subheadline.weight(isSelected ? .semibold : .regular)) .font(.subheadline.weight(isSelected ? .semibold : .regular))
.contentTransition(.opacity)
} }
.foregroundStyle(isSelected ? Color.primary : Color.secondary) .foregroundStyle(isSelected ? Color.primary : Color.secondary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -362,14 +370,29 @@ struct FuelTypeSegmentedPicker: View {
if isSelected { if isSelected {
Capsule() Capsule()
.fill(Color(UIColor.systemBackground)) .fill(Color(UIColor.systemBackground))
.shadow(color: .black.opacity(0.08), radius: 1, y: 0.5) .overlay {
Capsule()
.stroke(fuel.tintColor.opacity(0.28), lineWidth: 1)
}
.shadow(color: fuel.tintColor.opacity(0.16), radius: 6, y: 2)
.matchedGeometryEffect(id: "fuelSelectionSlider", in: selectionSlider)
} }
} }
.scaleEffect(isSelected ? 1.02 : 0.98)
.contentShape(Capsule())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
.padding(3) .padding(3)
.background(Capsule().fill(Color(.secondarySystemBackground))) .background(Capsule().fill(Color(.secondarySystemBackground)))
.animation(.spring(response: 0.42, dampingFraction: 0.78), value: selection)
} }
} }
#Preview("Fuel Picker") {
@Previewable @State var selectedFuel: FuelType = .e10
FuelTypeSegmentedPicker(selection: $selectedFuel)
.padding()
.background(Color(.systemGroupedBackground))
}
+221 -14
View File
@@ -13,6 +13,8 @@ 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
@@ -32,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
@@ -85,7 +102,34 @@ 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 // QA hook: force the unreachable state for screenshots (same pattern
// as -showTrends / -forceConnectionProblem). Runs before the fetch so // as -showTrends / -forceConnectionProblem). Runs before the fetch so
// the retry state renders immediately with no spinner flash. // the retry state renders immediately with no spinner flash.
@@ -95,6 +139,7 @@ struct TrendsView: View {
onHistoryUnavailable?() onHistoryUnavailable?()
return return
} }
#endif
isLoading = true isLoading = true
loadFailed = false loadFailed = false
defer { isLoading = false } defer { isLoading = false }
@@ -132,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) {
@@ -195,11 +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
legendFooter
}
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -228,21 +301,103 @@ 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 {
// NOTE: each LineMark MUST carry an explicit `series:` — without it // NOTE: each LineMark MUST carry an explicit `series:` — without it
// Swift Charts merges every station's points into ONE polyline // Swift Charts merges every station's points into ONE polyline
// (points connect across stations, so only the first station's line // (points connect across stations, so only the first station's line
// is recognisable). The outer ForEach keeps one chart with N series; // is recognisable). The outer ForEach keeps one chart with N series;
// per-mark foregroundStyle then colours each series from the palette. // per-mark foregroundStyle then colours each series from the palette.
// The AreaMark under each LineMark adds the gradient fill.
//
// stacking: .unstacked is essential — the standard stacking mode
// piles each series' area ON TOP of the previous series' area, so
// with 2+ favourites the fills land in the wrong place (seen on
// device 2026-08-17: fills shifted/overlapping vs the lines).
// .unstacked draws each area from its own line down to the axis,
// the RevenueCat look.
// Two passes: ALL AreaMarks first, then ALL LineMarks. Same-series
// marks composite in insertion order — if fills and lines interleave,
// a later series' fill paints OVER an earlier series' line (the
// bottom favourite's line vanished under the next fill; seen on
// device 2026-08-17). Drawing every fill before every line keeps all
// lines on top of all fills, RevenueCat style. (Mark builders are
// extracted into small helpers — the inline expression grew past the
// type-checker's budget.)
Chart { Chart {
ForEach(displaySeries) { history in ForEach(displaySeries) { history in
ForEach(history.points) { point in ForEach(history.points) { point in
LineMark( areaMark(point, series: history.name,
x: .value("Date", point.date), color: seriesColor(index(of: history.stationID)))
y: .value("Price", point.pence), }
series: .value("Station", history.name) }
) ForEach(displaySeries) { history in
.foregroundStyle(seriesColor(index(of: history.stationID))) ForEach(history.points) { point in
lineMark(point, series: history.name,
color: seriesColor(index(of: history.stationID)))
} }
} }
} }
@@ -251,31 +406,83 @@ 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)
@@ -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)
} }
} }
@@ -3,6 +3,17 @@
// Lives in the widget extension (the standard host for ActivityConfiguration). // Lives in the widget extension (the standard host for ActivityConfiguration).
// Shows the cheapest station for the pinned fuel within the app's chosen // Shows the cheapest station for the pinned fuel within the app's chosen
// radius. Tapping anywhere opens Apple Maps directions to that station. // radius. Tapping anywhere opens Apple Maps directions to that station.
//
// ADAPTIVE LAYOUT: ActivityConfiguration shares ONE content view across the
// Lock Screen, banner, and the CarPlay small slot — there is no per-platform
// closure. So this view provides two layouts and lets ViewThatFits pick by
// available width:
// • richBody — the full three-column design (glyph · fuel+station · price),
// wins wherever there's Lock Screen width (it carries an
// explicit minWidth so it can never be squeezed into the car).
// • compactBody — a minimal price-strip (glyph+fuel+price, station caption
// below) that wins in the CarPlay/Apple Watch Smart Stack
// small slot.
import ActivityKit import ActivityKit
import SwiftUI import SwiftUI
@@ -17,7 +28,7 @@ import WidgetKit
struct FuelBoardLiveActivity: Widget { struct FuelBoardLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
// Lock Screen / banner presentation // Lock Screen / banner / CarPlay small slot presentation
FuelBoardLiveActivityView(context: context) FuelBoardLiveActivityView(context: context)
} dynamicIsland: { context in } dynamicIsland: { context in
DynamicIsland { DynamicIsland {
@@ -44,43 +55,81 @@ struct FuelBoardLiveActivity: Widget {
} }
} }
/// Lock Screen / banner body — the main presentation. /// Lock Screen / banner / CarPlay body — adaptive.
private struct FuelBoardLiveActivityView: View { private struct FuelBoardLiveActivityView: View {
let context: ActivityViewContext<FuelBoardLiveActivityAttributes> let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
var body: some View { var body: some View {
Link(destination: context.state.mapsURL) { Link(destination: context.state.mapsURL) {
HStack(spacing: 12) { ViewThatFits(in: .horizontal) {
// LEFT — station brand glyph // rich first — wins on full-width Lock Screen / banner
Image(systemName: "fuelpump.circle.fill") richBody
.font(.system(size: 32)) // CarPlay / Watch small slot is far narrower than this,
.foregroundStyle(.green, .white) // so ViewThatFits reliably falls through to compactBody.
.frame(width: 40, height: 40) .frame(minWidth: 280)
// compact fallback — the car's small supplemental slot
// MIDDLE — fuel + station compactBody
VStack(alignment: .leading, spacing: 2) {
Text("Cheapest \(context.state.fuel.displayName)")
.font(.headline)
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
// RIGHT — price
VStack(alignment: .trailing, spacing: 2) {
FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle,
size: 22, weight: .bold)
Text("Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
} }
.padding()
} }
} }
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
private var richBody: some View {
HStack(spacing: 12) {
// LEFT — station brand glyph
Image(systemName: "fuelpump.circle.fill")
.font(.system(size: 32))
.foregroundStyle(.green, .white)
.frame(width: 40, height: 40)
// MIDDLE — fuel + station
VStack(alignment: .leading, spacing: 2) {
Text("Cheapest \(context.state.fuel.displayName)")
.font(.headline)
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
// RIGHT — price
VStack(alignment: .trailing, spacing: 2) {
FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle,
size: 22, weight: .bold)
Text("Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding()
}
/// Minimal strip for the small CarPlay / Watch Smart Stack slot:
/// glyph + fuel left, bold price right, truncated station below.
private var compactBody: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
Image(systemName: "fuelpump.fill")
.font(.caption2)
.foregroundStyle(.green)
Text(context.state.fuel.displayName)
.font(.caption2.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 4)
FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle,
size: 15, weight: .bold)
.lineLimit(1)
}
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.system(size: 9))
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(8)
}
} }
/// Dynamic Island expanded regions + compact trailing — price only. /// Dynamic Island expanded regions + compact trailing — price only.
+3 -3
View File
@@ -156,7 +156,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)), URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
] ]
guard let url = components.url else { return } guard let url = components.url else { return }
var request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2) let request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
Task { Task {
_ = try? await URLSession.shared.data(for: request) _ = try? await URLSession.shared.data(for: request)
} }
@@ -312,7 +312,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
URLQueryItem(name: "radius", value: String(radiusKM)), URLQueryItem(name: "radius", value: String(radiusKM)),
URLQueryItem(name: "limit", value: "500"), URLQueryItem(name: "limit", value: "500"),
] ]
var request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5) let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
do { do {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
@@ -340,7 +340,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
URLQueryItem(name: "fuel", value: fuel.rawValue), URLQueryItem(name: "fuel", value: fuel.rawValue),
URLQueryItem(name: "limit", value: String(limit)), URLQueryItem(name: "limit", value: String(limit)),
] ]
var request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5) let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
do { do {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
+9
View File
@@ -0,0 +1,9 @@
# Source of truth
The source of truth for this repo is the clone on Proxmox CT 111
(`fuelboard-relay`, 192.168.1.113): `/root/workspace/fuelboard` — since 2026-08-16.
- Edit BACKLOG.md and any repo content on the CT: `ssh fuelboard-relay`
- Commit as FuelBoard Contributor, push origin (Gitea; PAT in /root/.netrc on the CT)
- This Mac clone (`~/workspace/fuelboard`) is a READ-ONLY mirror — refresh it
interactively with `git pull` (Mac keychain required; agent sessions cannot read it)
+11 -3
View File
@@ -216,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
+111 -3
View File
@@ -9,6 +9,9 @@
import Foundation import Foundation
import Security import Security
#if canImport(AppIntents)
import AppIntents
#endif
import SwiftUI import SwiftUI
// MARK: - Fuel types // MARK: - Fuel types
@@ -28,6 +31,16 @@ struct FavouriteEntry: Identifiable, Codable, Equatable {
var id: String { "\(fuel.rawValue)|\(station.id)" } var id: String { "\(fuel.rawValue)|\(station.id)" }
} }
/// Persisted baseline for the per-fuel cheapest-favourite price-drop alert.
/// Stored separately from the favourites list so refresh-time comparison can
/// survive app relaunches without immediately alerting on first fetch.
struct FavouriteAlertSnapshot: Codable, Equatable {
let fuel: FuelType
let stationID: String
let stationName: String
let price: Double
}
enum SortMode: String, Codable, CaseIterable, Identifiable { enum SortMode: String, Codable, CaseIterable, Identifiable {
case cheapest case cheapest
case closest case closest
@@ -59,6 +72,18 @@ enum RAGRating: Int, Codable {
} }
enum FuelType: String, Codable, CaseIterable, Identifiable { enum FuelType: String, Codable, CaseIterable, Identifiable {
#if canImport(AppIntents)
typealias DisplayRepresentation = AppIntents.DisplayRepresentation
typealias TypeDisplayRepresentation = AppIntents.TypeDisplayRepresentation
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [
.e10: "Unleaded",
.e5: "Premium",
.diesel: "Diesel",
]
#endif
case e10 // Unleaded (E10) case e10 // Unleaded (E10)
case e5 // Premium (E5) case e5 // Premium (E5)
case diesel // B7 diesel case diesel // B7 diesel
@@ -74,6 +99,10 @@ enum FuelType: String, Codable, CaseIterable, Identifiable {
} }
} }
#if canImport(AppIntents)
extension FuelType: AppEnum {}
#endif
/// Display unit for all distances in the app + widget. Internally distances /// Display unit for all distances in the app + widget. Internally distances
/// are always stored/computed in km; conversion happens at the display and /// are always stored/computed in km; conversion happens at the display and
/// filter boundary so nothing else needs to know the unit. /// filter boundary so nothing else needs to know the unit.
@@ -213,21 +242,37 @@ struct FuelStation: Identifiable, Codable, Equatable {
var brandImageName: String? { var brandImageName: String? {
let raw = brand.uppercased() let raw = brand.uppercased()
let known: [(String, String)] = [ let known: [(String, String)] = [
("CIRCLE K", "brand_circle_k"),
("MAXOL", "brand_maxol"),
("SPAR", "brand_spar"),
("SHELL", "brand_shell"), ("SHELL", "brand_shell"),
("SAINSBURY", "brand_sainsburys"), ("SAINSBURY", "brand_sainsburys"),
("MORRISONS", "brand_morrisons"), ("MORRISONS", "brand_morrisons"),
("APPLEGREEN", "brand_applegreen"), ("APPLEGREEN", "brand_applegreen"),
("TESCO", "brand_tesco"), ("TESCO", "brand_tesco"),
("TEXACO", "brand_texaco"), ("TEXACO", "brand_texaco"),
("ESSAR", "brand_essar"),
("ESSO", "brand_esso"), ("ESSO", "brand_esso"),
("ASDA", "brand_asda"), ("ASDA", "brand_asda"),
("GULF", "brand_gulf"), ("GULF", "brand_gulf"),
("VALERO", "brand_valero"),
("WELCOME BREAK", "brand_welcome_break"),
("THE CO-OPERATIVE", "brand_the_co_operative"),
("CO-OPERATIVE", "brand_the_co_operative"),
("CO OPERATIVE", "brand_the_co_operative"),
("CO-OP", "brand_the_co_operative"),
("CO OP", "brand_the_co_operative"),
("MURCO", "brand_murco"),
("GLEANER", "brand_gleaner"),
("HIGHLAND FUELS", "brand_highland_fuels"),
("BP", "brand_bp"), ("BP", "brand_bp"),
("JET", "brand_jet"), ("JET", "brand_jet"),
] ]
for (needle, asset) in known where raw.contains(needle) { for (needle, asset) in known where raw.contains(needle) {
return asset return asset
} }
if raw.contains("EG ON THE MOVE") { return "brand_esso" }
if raw == "GO" || raw.contains("GO FORECOURT") || raw.contains("GO FUEL") { return "brand_gulf" }
return nil return nil
} }
} }
@@ -299,6 +344,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 +564,7 @@ struct FuelStore {
let whole = tenths / 1000 let whole = tenths / 1000
let major = (tenths % 1000) / 10 let major = (tenths % 1000) / 10
let minor = tenths % 10 let minor = tenths % 10
let main = Text(String(format: "£%d.%02d", whole, major)) let amount = Text(String(format: "£%d.%02d", whole, major))
.font(base) .font(base)
.foregroundColor(color) .foregroundColor(color)
let sup = Text(String(superscriptDigits[minor])) let sup = Text(String(superscriptDigits[minor]))
@@ -526,7 +574,7 @@ struct FuelStore {
let perL = Text("/L") let perL = Text("/L")
.font(.system(size: size * 0.5, weight: .regular).monospaced()) .font(.system(size: size * 0.5, weight: .regular).monospaced())
.foregroundColor(color.opacity(0.55)) .foregroundColor(color.opacity(0.55))
return main + sup + perL return Text("\(amount)\(sup)\(perL)")
} }
} }
@@ -608,6 +656,24 @@ struct FuelStore {
} }
} }
/// The cheapest favourite for one fuel, preserving the user's stored order
/// on price ties. Used by the favourite price-drop alert so a tie does not
/// flap between equally-priced favourites.
static func cheapestFavourite(in favourites: [FavouriteEntry], fuel: FuelType) -> FavouriteEntry? {
var best: FavouriteEntry?
for entry in favourites where entry.fuel == fuel {
guard let price = entry.station.prices[fuel] else { continue }
guard let current = best, let currentPrice = current.station.prices[fuel] else {
best = entry
continue
}
if price < currentPrice {
best = entry
}
}
return best
}
/// Reorders ONE fuel's favourites within the global array (drag-and-drop in /// Reorders ONE fuel's favourites within the global array (drag-and-drop in
/// the Favourites tab). The moved fuel's block stays at its original /// the Favourites tab). The moved fuel's block stays at its original
/// position in the array; other fuels keep their relative order. The array /// position in the array; other fuels keep their relative order. The array
@@ -691,6 +757,48 @@ struct FuelStore {
saveString(enabled ? "1" : "0", service: alertsFollowSearchKey) saveString(enabled ? "1" : "0", service: alertsFollowSearchKey)
} }
// MARK: Favourite price-drop alerts
static func loadFavouriteDropAlertsEnabled() -> Bool {
loadString(service: favouriteDropAlertsEnabledKey) == "1"
}
static func saveFavouriteDropAlertsEnabled(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: favouriteDropAlertsEnabledKey)
}
static func loadFavouriteDropAlertsFuel() -> FuelType {
if let raw = loadString(service: favouriteDropAlertsFuelKey), let fuel = FuelType(rawValue: raw) {
return fuel
}
return .e10
}
static func saveFavouriteDropAlertsFuel(_ fuel: FuelType) {
saveString(fuel.rawValue, service: favouriteDropAlertsFuelKey)
}
static func loadFavouriteAlertSnapshots() -> [FuelType: FavouriteAlertSnapshot] {
if let data = keychainData(service: favouriteAlertSnapshotsKey),
let stored = try? JSONDecoder().decode([String: FavouriteAlertSnapshot].self, from: data) {
var result: [FuelType: FavouriteAlertSnapshot] = [:]
for (key, value) in stored {
if let fuel = FuelType(rawValue: key) {
result[fuel] = value
}
}
return result
}
return [:]
}
static func saveFavouriteAlertSnapshots(_ snapshots: [FuelType: FavouriteAlertSnapshot]) {
let keyed = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.key.rawValue, $0.value) })
if let data = try? JSONEncoder().encode(keyed) {
writeKeychain(data: data, service: favouriteAlertSnapshotsKey)
}
}
// MARK: Live Activity // MARK: Live Activity
static func loadLiveActivityEnabled() -> Bool { static func loadLiveActivityEnabled() -> Bool {
@@ -825,7 +933,7 @@ struct FuelStore {
// MARK: Low-level keychain helpers // MARK: Low-level keychain helpers
private static func keychainData(service: String) -> Data? { private static func keychainData(service: String) -> Data? {
var query: [String: Any] = [ let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service, kSecAttrService as String: service,
kSecReturnData as String: true, kSecReturnData as String: true,
+7 -3
View File
@@ -96,9 +96,13 @@ struct MirrorFuelProvider: FuelPriceProviding {
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data { if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
data = cached data = cached
} else { } else {
let (fetched, response) = try await URLSession.shared.data( // Bypass the HTTP cache + short timeout: an offline refresh must
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL) // FAIL (→ offline banner), never re-serve a stale cached dump as
) // a "successful" live fetch.
var req = URLRequest(url: FuelHistoryStore.historyFileURL(day: day, base: baseURL))
req.cachePolicy = .reloadIgnoringLocalCacheData
req.timeoutInterval = 10
let (fetched, response) = try await URLSession.shared.data(for: req)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw FuelProviderError.mirrorUnavailable throw FuelProviderError.mirrorUnavailable
} }