Compare commits
@@ -1,6 +1,7 @@
|
||||
build/
|
||||
build-release/
|
||||
build-sim/
|
||||
build-watch/
|
||||
.DS_Store
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
|
||||
@@ -106,6 +106,19 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
scene (CPListTemplate) for navigation deep-links.
|
||||
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
|
||||
user-configurable (currently fixed 300 m trigger / 1 per station per hour).
|
||||
- [ ] **Price-drop alerts: threshold control + Check now** — keep the current
|
||||
cache-first / 12 h refresh policy, but add a per-alert sensitivity control
|
||||
for favourite price-drop alerts instead of a fetch-frequency slider. Scope:
|
||||
`Minimum change` picker (e.g. 0.5p / 1.0p / 2.0p), plus a `Check now`
|
||||
action in the Alerts tab that forces a fetch and immediately re-evaluates the
|
||||
cheapest-favourite alert. Copy should set expectation honestly: standard
|
||||
mode checks when FuelBoard refreshes prices (up to twice daily).
|
||||
- [ ] **Price-drop alerts: optional frequent-check mode** — if the app ever
|
||||
relaxes the current "twice daily max" data policy, expose this as a clearly
|
||||
separate mode rather than a freeform frequency slider. Example framing:
|
||||
`Standard` (current behaviour) vs `Frequent` (best-effort extra checks),
|
||||
with conservative caps (e.g. every 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
|
||||
(currently `refreshable` fires but no visible progress in the row list).
|
||||
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
|
||||
@@ -131,13 +144,50 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
separate phase. Backlog ancestry: this was the original "relay could keep a
|
||||
rolling archive" idea — the archive is now P0 and the Trends graph (P1) is its
|
||||
visible half.
|
||||
- [ ] **Foresight Live Activity (heading-aware next station)** — investigate a
|
||||
driving-time surface that predicts the likely next station ahead of the user
|
||||
from cached data only (no extra source-data refresh on motion). Best v1 shape:
|
||||
Live Activity option, not widget-first, because widget space/refresh budgets
|
||||
are tight and the lock-screen/CarPlay compact slot better matches the use case.
|
||||
Prefer ONE FuelBoard Live Activity with a mode selector (e.g. `Nearby cheapest`
|
||||
vs `Foresight / next ahead`) rather than trying to run two simultaneous
|
||||
FuelBoard activities; iOS can support multiple activities, but FuelBoard's
|
||||
current implementation is single-activity and the compact surfaces are too
|
||||
constrained for two competing driving modes anyway. Viable MVP: infer heading
|
||||
from recent motion/course, keep only stations in a forward cone, score
|
||||
candidates by forward distance + lateral offset, and show the next-ahead
|
||||
station with all three fuel prices or a compact rotating/focused fuel
|
||||
treatment if space allows. Key caveat: heading is not full route awareness,
|
||||
so avoid overpromising "the next station you will reach" on motorways/opposite
|
||||
carriageways; frame as "next ahead" / "likely next stop" unless route-quality
|
||||
heuristics improve. Must include hysteresis/speed thresholds to avoid flapping,
|
||||
and should be tested specifically against compact Live Activity/CarPlay layouts
|
||||
where space is extremely limited.
|
||||
- [ ] **Widget sparkline** — medium-widget mini sparkline of a favourite's 30-day
|
||||
trend, after the Trends UI lands (one-kind rule preserved).
|
||||
- [ ] **Price alerts for a specific station** — notify when a starred station's
|
||||
price drops below a user threshold.
|
||||
- [ ] **Price-drop alerts: mode = Cheapest favourite / All favourites** — keep
|
||||
`Cheapest favourite` as the default low-noise mode, but explore an optional
|
||||
`All favourites` mode for one fuel. Guardrails if built: threshold required,
|
||||
per-station dedupe based on last-alerted price (not just time), and combine
|
||||
multiple drops from one refresh into a single summary notification rather
|
||||
than one alert per station.
|
||||
- [ ] **Share sheet** — share "Cheapest Unleaded near me" as text/image card.
|
||||
- [ ] **Watch app** — glanceable cheapest-price complication (WatchKit + shared
|
||||
app-group store; watch target would need to be added to the project).
|
||||
- [ ] **Watch app v1 — favourites + Check now** — small Apple Watch companion,
|
||||
not a phone-app clone. Favourites-first home screen: fuel picker, `Cheapest
|
||||
favourite` summary card, top few favourites for that fuel, last-updated time,
|
||||
and a prominent `Check now` action that forces a refresh + re-evaluates
|
||||
favourite prices. Keep the watch UX pull-based and glanceable; avoid implying
|
||||
high-frequency background refresh. Shared-store architecture preferred over a
|
||||
second watch-only data model.
|
||||
- [ ] **Watch complications / Smart Stack** — complication/widget surfaces for
|
||||
the same watch concept: show the cheapest favourite price, fuel, and short
|
||||
station name at a glance. Keep copy minimal and honest about freshness.
|
||||
- [ ] **Watch app v2 — favourite detail + handoff** — tap a favourite row for a
|
||||
detail screen (station, brand, price, last updated) with actions like
|
||||
`Directions` and `Open on iPhone`. Reuse existing maps handoff patterns where
|
||||
possible instead of inventing watch-only navigation logic.
|
||||
- [ ] **App Clip** — 10 MB budget for "find cheapest fuel nearby" without install.
|
||||
- [ ] **Widget picker: dual-unit distance labels** — the Edit-Widget Distance row
|
||||
label is cached by the system at sheet-render time and only re-resolves on
|
||||
|
||||
@@ -32,6 +32,7 @@ struct AlertsView: View {
|
||||
let distanceUnit: DistanceUnit
|
||||
let monitoredCount: Int
|
||||
let lastAlert: String?
|
||||
let onCheckFavouriteDropNow: () -> Void
|
||||
|
||||
/// The Stations-tab search distance shown in the user's unit, e.g.
|
||||
/// "5 miles" or "8 km" — for the "Follow search" rows.
|
||||
@@ -139,6 +140,8 @@ struct AlertsView: View {
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
Button("Check now", action: onCheckFavouriteDropNow)
|
||||
.disabled(!favouriteDropEnabled)
|
||||
} header: {
|
||||
Text("Price-drop alerts")
|
||||
} footer: {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_circle_k_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_circle_k_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_essar_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_essar_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_gleaner_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_gleaner_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_highland_fuels_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_highland_fuels_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 970 B |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_maxol_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_maxol_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_murco_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_murco_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_spar_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_spar_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_the_co_operative_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_the_co_operative_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_valero_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_valero_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_welcome_break_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_welcome_break_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -2,6 +2,15 @@ import SwiftUI
|
||||
import CoreLocation
|
||||
import WidgetKit
|
||||
|
||||
/// The single floating notification banner. Network/offline state and tip
|
||||
/// outcomes all funnel through ONE style and render via the same chrome in
|
||||
/// ContentView — never pushing layout, always overlaid.
|
||||
enum AppBanner: Hashable {
|
||||
case offlineDump(date: String)
|
||||
case connectionProblem
|
||||
case tip(TipStore.TipOutcome)
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@@ -32,6 +41,41 @@ struct ContentView: View {
|
||||
private var effectiveAlertsRadiusKM: Double {
|
||||
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
||||
}
|
||||
|
||||
private func installLocationUpdateHook() {
|
||||
locationManager.onLocationUpdate = { [weak monitor] in
|
||||
monitor?.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
// The Live Activity follows the same wake-ups — this hook
|
||||
// fires on every fix incl. background significant-change
|
||||
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||
updateLiveActivity()
|
||||
refreshRoadDistancesIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// Kicks off a (throttled) Apple-Maps road-distance recompute for the
|
||||
/// stations around the current fix. The app owns routing — the widget and
|
||||
/// Live Activity only read the cached result. Falls back to the last saved
|
||||
/// location so it can run before the first fresh GPS fix arrives.
|
||||
private func refreshRoadDistancesIfNeeded() {
|
||||
let origin = location ?? FuelStore.loadLocation()
|
||||
guard let origin else { return }
|
||||
Task {
|
||||
await RoadDistanceService.refreshIfNeeded(
|
||||
stations: stations,
|
||||
lat: origin.lat,
|
||||
lng: origin.lng
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureBackgroundMotionTrackingIfNeeded() {
|
||||
if alertsEnabled || liveActivityEnabled {
|
||||
locationManager.startBackgroundTracking()
|
||||
}
|
||||
}
|
||||
|
||||
@State private var isLoading = false
|
||||
@State private var statusMessage = ""
|
||||
/// What data is on screen, driving which (if any) status banner shows
|
||||
@@ -52,6 +96,20 @@ struct ContentView: View {
|
||||
@State private var selectedTab = 0
|
||||
@State private var locationManager = LocationManager()
|
||||
@StateObject private var monitor = ProximityMonitor()
|
||||
/// Owns tip purchases; the single instance is shared down to Settings.
|
||||
/// Its transient outcome rides the SAME status-banner chrome as the
|
||||
/// network/offline strip (ContentView renders it above the tabs).
|
||||
@StateObject private var tipStore = TipStore()
|
||||
|
||||
// --- Explicit banner animation state (robust fade-out) ---
|
||||
// SwiftUI's removal transition for the banner proved unreliable here
|
||||
// (the tip faded to nothing only in the cross-nil case, and even then
|
||||
// inconsistently). Drive opacity/offset explicitly instead so a
|
||||
// dismissal ALWAYS fades + slides up on ANY banner change.
|
||||
@State private var currentBanner: AppBanner?
|
||||
@State private var bannerOpacity: Double = 0
|
||||
@State private var bannerOffset: CGFloat = 0
|
||||
@State private var bannerClearTask: Task<Void, Never>?
|
||||
|
||||
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
||||
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
||||
@@ -263,27 +321,23 @@ struct ContentView: View {
|
||||
#else
|
||||
let shouldSkipOnboarding = false
|
||||
#endif
|
||||
if FuelStore.loadHasCompletedOnboarding()
|
||||
if !FuelStore.shouldShowOnboarding()
|
||||
|| shouldSkipOnboarding {
|
||||
locationManager.startForegroundTracking()
|
||||
// Geofences must follow the user even in the background:
|
||||
// wire the delegate hook (fires on every fix incl. background
|
||||
// significant-change wake-ups) to re-register the region
|
||||
// window around the new position. SwiftUI onChange alone
|
||||
// never runs while suspended, so alerts would otherwise stay
|
||||
// frozen around the last foreground fix.
|
||||
locationManager.onLocationUpdate = { [weak monitor] in
|
||||
monitor?.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
// The Live Activity follows the same wake-ups — this hook
|
||||
// fires on every fix incl. background significant-change
|
||||
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||
updateLiveActivity()
|
||||
}
|
||||
// Geofences and the Live Activity must follow the user even in
|
||||
// the background: wire the delegate hook (fires on every fix
|
||||
// incl. background significant-change wake-ups) so SwiftUI's
|
||||
// foreground-only onChange isn't the only update path.
|
||||
installLocationUpdateHook()
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
monitor.setEnabled(alertsEnabled)
|
||||
updateLiveActivity()
|
||||
// Compute road distances early (throttled; falls back to the
|
||||
// last saved location) so distance surfaces are road-matched
|
||||
// as soon as stations are available.
|
||||
refreshRoadDistancesIfNeeded()
|
||||
// Refresh only when the cache is stale (twice-a-day policy).
|
||||
// Skipped under the force-* hooks so the banner stays up.
|
||||
#if DEBUG
|
||||
@@ -310,6 +364,8 @@ struct ContentView: View {
|
||||
// empty on a brand-new install.
|
||||
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
||||
locationManager.startForegroundTracking()
|
||||
installLocationUpdateHook()
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
updateLiveActivity()
|
||||
@@ -327,6 +383,7 @@ struct ContentView: View {
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
updateLiveActivity()
|
||||
refreshRoadDistancesIfNeeded()
|
||||
// No network fetch on foreground — pull-to-refresh is the override.
|
||||
} else {
|
||||
locationManager.stopForegroundTracking()
|
||||
@@ -342,6 +399,7 @@ struct ContentView: View {
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
updateLiveActivity()
|
||||
refreshRoadDistancesIfNeeded()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -373,9 +431,7 @@ struct ContentView: View {
|
||||
monitor.setEnabled(newValue)
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
if newValue {
|
||||
locationManager.startBackgroundTracking()
|
||||
}
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
}
|
||||
.onChange(of: alertsRadius) { _, newValue in
|
||||
FuelStore.saveAlertsRadius(newValue)
|
||||
@@ -406,6 +462,7 @@ struct ContentView: View {
|
||||
// Toggling the Live Activity on starts it with the current best
|
||||
// station; toggling off ends any running activity.
|
||||
FuelStore.saveLiveActivityEnabled(newValue)
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
updateLiveActivity()
|
||||
}
|
||||
.onChange(of: liveActivityFuel) { _, newValue in
|
||||
@@ -444,38 +501,91 @@ struct ContentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
statusBannerView
|
||||
rootTabView
|
||||
/// 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
|
||||
}
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBannerView: some View {
|
||||
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()
|
||||
private var mainContent: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .top) {
|
||||
rootTabView
|
||||
if let banner = currentBanner {
|
||||
// Floating near the top of the screen, over the nav area —
|
||||
// overlays content (never pushes it) and sits above the
|
||||
// main content so it doesn't cover or block the list/pill
|
||||
// beneath it. Opacity/offset are driven explicitly by
|
||||
// reflectBanner so the fade-out reliably animates.
|
||||
floatingBanner(banner)
|
||||
.padding(.top, geo.safeAreaInsets.top + 10)
|
||||
.opacity(bannerOpacity)
|
||||
.offset(y: bannerOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: activeBanner) {
|
||||
// Fires on launch with the current banner AND whenever it changes —
|
||||
// unlike onChange(computed) which can miss the first non-nil value
|
||||
// when dataStatus is set slightly after the view appears.
|
||||
reflectBanner(activeBanner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly animates the banner in/out — a robust replacement for the
|
||||
/// SwiftUI removal transition (which was silently not firing). Handles:
|
||||
/// - first appearance → fade + settle down from slightly above
|
||||
/// - any content change (tip→network, new tip) → fade the old out, then
|
||||
/// fade the new in — so a tip ALWAYS visibly fades away
|
||||
/// - dismissal → fade out + slide up, then clear after the fade
|
||||
private func reflectBanner(_ newBanner: AppBanner?) {
|
||||
guard newBanner != currentBanner else { return }
|
||||
bannerClearTask?.cancel()
|
||||
if let newBanner {
|
||||
if currentBanner == nil {
|
||||
present(newBanner)
|
||||
} else {
|
||||
// Crossfade: slide+fade the current out, then present the new.
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -28
|
||||
}
|
||||
bannerClearTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
present(newBanner)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withAnimation(.easeOut(duration: 0.35)) {
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -28
|
||||
}
|
||||
bannerClearTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
currentBanner = nil
|
||||
bannerOpacity = 0
|
||||
bannerOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade a banner in from slightly above and settle it into place.
|
||||
private func present(_ banner: AppBanner) {
|
||||
currentBanner = banner
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -16
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
bannerOpacity = 1
|
||||
bannerOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,13 +616,51 @@ struct ContentView: View {
|
||||
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
||||
}
|
||||
|
||||
/// Shared status-strip chrome: a tappable card pinned above the tabs.
|
||||
/// Tapping retries the live fetch from ANY screen — no pull gesture
|
||||
/// needed, so the offline banner isn't trapped on the Stations tab.
|
||||
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
|
||||
Button {
|
||||
/// ONE floating banner style for all notifications. Network/offline and
|
||||
/// tip outcomes share the same chrome; only the tap action differs —
|
||||
/// network → retry the fetch, tip → dismiss (TipStore auto-dismisses too).
|
||||
@ViewBuilder
|
||||
private func floatingBanner(_ banner: AppBanner) -> some View {
|
||||
switch banner {
|
||||
case .offlineDump(let date):
|
||||
let title = offlineTitle(date: date)
|
||||
statusBannerCard(
|
||||
icon: "wifi.slash", tint: .orange, title: title,
|
||||
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
|
||||
trailingIcon: "arrow.clockwise",
|
||||
accessibilityLabel: date.isEmpty
|
||||
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||||
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||||
) {
|
||||
Task { await refresh(force: true) }
|
||||
} label: {
|
||||
}
|
||||
case .connectionProblem:
|
||||
statusBannerCard(
|
||||
icon: "wifi.exclamationmark", tint: .red,
|
||||
title: NSLocalizedString("Check your internet connection", comment: ""),
|
||||
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
||||
trailingIcon: "arrow.clockwise",
|
||||
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
||||
) {
|
||||
Task { await refresh(force: true) }
|
||||
}
|
||||
case .tip(let outcome):
|
||||
statusBannerCard(
|
||||
icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
|
||||
trailingIcon: "xmark", accessibilityLabel: outcome.message
|
||||
) {
|
||||
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single floating-banner chrome shared by every notification
|
||||
/// (network/offline + tip). Overlaid — never in flow — so it never moves
|
||||
/// the content beneath it.
|
||||
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
|
||||
trailingIcon: String, accessibilityLabel: String,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
@@ -522,12 +670,14 @@ struct ContentView: View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Image(systemName: trailingIcon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
@@ -540,13 +690,13 @@ struct ContentView: View {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
/// Pushes the current best-in-radius station into the Live Activity.
|
||||
@@ -623,7 +773,10 @@ struct ContentView: View {
|
||||
stationLimit: stationLimit,
|
||||
distanceUnit: distanceUnit,
|
||||
monitoredCount: monitor.monitoredStationIDs.count,
|
||||
lastAlert: monitor.lastAlert
|
||||
lastAlert: monitor.lastAlert,
|
||||
onCheckFavouriteDropNow: {
|
||||
Task { await refresh(force: true) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -631,6 +784,7 @@ struct ContentView: View {
|
||||
/// within the compiler's type-check budget.
|
||||
private var settingsTab: some View {
|
||||
SettingsView(
|
||||
tipStore: tipStore,
|
||||
distanceUnit: $distanceUnit,
|
||||
priceDisplayStyle: $priceDisplayStyle,
|
||||
alertsFuel: alertsFuel,
|
||||
@@ -786,14 +940,14 @@ struct StationRow: View {
|
||||
.padding(5)
|
||||
} else {
|
||||
Image(systemName: "fuelpump.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.white)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(Color.black.opacity(0.82))
|
||||
}
|
||||
}
|
||||
.frame(width: 46, height: 46)
|
||||
.background(Circle().fill(.white))
|
||||
.background(Circle().fill(Color.white))
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Color.primary.opacity(0.08), lineWidth: 1))
|
||||
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
|
||||
|
||||
// MIDDLE — name, full address, distance
|
||||
@@ -818,7 +972,7 @@ struct StationRow: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if let location {
|
||||
Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
||||
Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct FuelBoardApp: App {
|
||||
init() {
|
||||
#if DEBUG
|
||||
// QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a
|
||||
// Live Activity with a long station name so the Lock Screen / island
|
||||
// layout can be rendered in the Simulator for visual QA.
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
if let idx = args.firstIndex(of: "-qaLiveActivity"),
|
||||
args.indices.contains(idx + 1),
|
||||
let fuel = FuelType(rawValue: args[idx + 1]) {
|
||||
startQALiveActivity(fuel: fuel)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
@@ -9,6 +24,34 @@ struct FuelBoardApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func startQALiveActivity(fuel: FuelType) {
|
||||
let state = FuelBoardLiveActivityAttributes.ContentState(
|
||||
fuel: fuel,
|
||||
stationID: "qa-phoenix",
|
||||
stationName: "Phoenix Filling Stations",
|
||||
brand: "Phoenix",
|
||||
pricePence: 1499,
|
||||
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
|
||||
distanceKM: 8.0,
|
||||
lat: 51.5,
|
||||
lng: -0.12,
|
||||
updatedAt: Date()
|
||||
)
|
||||
let attrs = FuelBoardLiveActivityAttributes()
|
||||
do {
|
||||
let activity = try Activity.request(
|
||||
attributes: attrs,
|
||||
content: .init(state: state, staleDate: nil),
|
||||
pushType: nil
|
||||
)
|
||||
print("QA-LIVE-ACTIVITY STARTED id=\(activity.id)")
|
||||
} catch {
|
||||
print("QA-LIVE-ACTIVITY FAILED: \(error)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Handles deep links that end up in the app. Widget taps arrive here in
|
||||
/// two cases:
|
||||
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
|
||||
|
||||
@@ -57,7 +57,7 @@ enum LiveActivityManager {
|
||||
brand: best.brand,
|
||||
pricePence: price,
|
||||
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
||||
distanceKM: best.distanceKM(to: location.lat, lng2: location.lng),
|
||||
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
|
||||
lat: best.lat,
|
||||
lng: best.lng,
|
||||
updatedAt: Date()
|
||||
|
||||
@@ -85,7 +85,7 @@ struct OnboardingView: View {
|
||||
private var welcomePage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
AnimatedOnboardingIcon(tint: .accentColor) {
|
||||
// The app icon itself, with the standard iOS rounded-corner
|
||||
// treatment (~22% of the size), so onboarding matches the
|
||||
// Home Screen.
|
||||
@@ -122,8 +122,7 @@ struct OnboardingView: View {
|
||||
private var locationPage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
|
||||
AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 40, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
@@ -154,8 +153,7 @@ struct OnboardingView: View {
|
||||
private var notificationsPage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
|
||||
AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
|
||||
Image(systemName: "bell.badge.fill")
|
||||
.font(.system(size: 40, weight: .semibold))
|
||||
.foregroundStyle(.orange)
|
||||
@@ -186,8 +184,7 @@ struct OnboardingView: View {
|
||||
private var dataPage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
|
||||
AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.system(size: 40, weight: .semibold))
|
||||
.foregroundStyle(.teal)
|
||||
@@ -248,19 +245,27 @@ struct OnboardingView: View {
|
||||
) {
|
||||
if prompter.locationDenied {
|
||||
openSettings()
|
||||
} else if prompter.locationGranted {
|
||||
page = 2 // already decided — just move on
|
||||
} else {
|
||||
// The prompt fires on LEAVING this page (onChange(of:
|
||||
// page), forward advance only) — the description is on
|
||||
// screen until the user moves on.
|
||||
page = 2
|
||||
// Fire the system prompt NOW, while this page is still on
|
||||
// screen (after it's been read) and BEFORE advancing — so
|
||||
// the prompt never covers the next page's animation. Auto-
|
||||
// advance on grant moves us on once the user responds;
|
||||
// denying leaves the page's "Open Settings" path.
|
||||
prompter.requestLocation()
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
primaryButton(
|
||||
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
||||
) {
|
||||
// Prompt fires on leaving this page (same rule as Location).
|
||||
page = 3
|
||||
if prompter.notificationsDenied || prompter.notificationsGranted {
|
||||
page = 3 // decided either way — move on
|
||||
} else {
|
||||
// Same rule as Location: prompt now, before advancing.
|
||||
prompter.requestNotifications()
|
||||
}
|
||||
}
|
||||
case 3:
|
||||
// No permission on this page — prices need none. Straight on.
|
||||
@@ -326,6 +331,99 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct AnimatedOnboardingIcon<Content: View>: View {
|
||||
enum Motion {
|
||||
case gentle
|
||||
case locationArrow
|
||||
case alarmBell
|
||||
case downloadArrow
|
||||
}
|
||||
|
||||
let tint: Color
|
||||
var motion: Motion = .gentle
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@State private var settled = false
|
||||
@State private var iconRotation: Double = 0
|
||||
@State private var iconOffsetY: CGFloat = 0
|
||||
@State private var iconScale: CGFloat = 0.92
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(tint.opacity(0.10))
|
||||
.frame(width: 108, height: 108)
|
||||
.scaleEffect(settled ? 1.0 : 0.94)
|
||||
|
||||
Circle()
|
||||
.stroke(tint.opacity(0.18), lineWidth: 1)
|
||||
.frame(width: 118, height: 118)
|
||||
.scaleEffect(settled ? 1.03 : 0.97)
|
||||
.opacity(settled ? 1.0 : 0.55)
|
||||
|
||||
content
|
||||
.scaleEffect(iconScale)
|
||||
.rotationEffect(.degrees(iconRotation))
|
||||
.offset(y: iconOffsetY)
|
||||
}
|
||||
.shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2)
|
||||
.animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled)
|
||||
.onAppear {
|
||||
playAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private func playAnimation() {
|
||||
settled = false
|
||||
iconRotation = 0
|
||||
iconOffsetY = 4
|
||||
iconScale = 0.92
|
||||
|
||||
switch motion {
|
||||
case .gentle:
|
||||
withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
|
||||
settled = true
|
||||
iconOffsetY = 0
|
||||
iconScale = 1.0
|
||||
}
|
||||
|
||||
case .locationArrow:
|
||||
iconRotation = -120
|
||||
withAnimation(.linear(duration: 0.45)) {
|
||||
settled = true
|
||||
iconRotation = 24
|
||||
iconOffsetY = 0
|
||||
iconScale = 1.02
|
||||
}
|
||||
withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) {
|
||||
iconRotation = 0
|
||||
iconScale = 1.0
|
||||
}
|
||||
|
||||
case .alarmBell:
|
||||
settled = true
|
||||
iconOffsetY = 0
|
||||
iconScale = 1.0
|
||||
withAnimation(.linear(duration: 0.10)) { iconRotation = -14 }
|
||||
withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 }
|
||||
withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 }
|
||||
withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 }
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 }
|
||||
|
||||
case .downloadArrow:
|
||||
iconOffsetY = -22
|
||||
withAnimation(.easeIn(duration: 0.22)) {
|
||||
settled = true
|
||||
iconScale = 1.0
|
||||
iconOffsetY = 8
|
||||
}
|
||||
withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) {
|
||||
iconOffsetY = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the system permission requests during onboarding and publishes
|
||||
/// their outcomes so the pages can reflect them live.
|
||||
@MainActor
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// RoadDistanceService.swift — computes Apple-Maps-matched ROAD distances for
|
||||
// nearby stations and caches them (keychain) so widgets + Live Activity can
|
||||
// show real driving distance instead of straight-line haversine.
|
||||
//
|
||||
// Runs only in the APP: MKDirections is network-bound and the widget extension
|
||||
// has a tiny execution budget + a ~40-70/day refresh budget, so routing belongs
|
||||
// here, not in the widget. The widget/Live Activity just read the cache.
|
||||
//
|
||||
// Throttling: recompute at most every `throttleMinutes`, or when the user has
|
||||
// moved `moveThresholdMeters` from where the cache was built. Bounded to the
|
||||
// `candidatesPerPass` nearest stations so a pass stays a handful of route calls.
|
||||
|
||||
import Foundation
|
||||
import MapKit
|
||||
import WidgetKit
|
||||
|
||||
enum RoadDistanceService {
|
||||
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
|
||||
/// route calls. Raised from 12 so stations past the old nearest-12 cutoff
|
||||
/// still get real road distances instead of a straight-line fallback.
|
||||
static let candidatesPerPass = 40
|
||||
/// Only route stations within this straight-line radius (km). Covers the
|
||||
/// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so
|
||||
/// every station a widget/Live Activity/list can actually show gets routed.
|
||||
static let maxRadiusKM: Double = 25
|
||||
/// Don't route again more often than this (minutes).
|
||||
static let throttleMinutes: Double = 10
|
||||
/// Recompute when the user moves more than this (metres) from the last
|
||||
/// source location.
|
||||
static let moveThresholdMeters: Double = 400
|
||||
|
||||
/// Refreshes the cached road distances for the in-radius stations around
|
||||
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
||||
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
|
||||
guard !stations.isEmpty else { return }
|
||||
|
||||
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
||||
if let cache = FuelStore.loadRoadDistances() {
|
||||
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
||||
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
||||
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate stations: the nearest-by-straight-line subset that the UI
|
||||
// could actually display, capped so a pass stays bounded.
|
||||
let nearest = stations
|
||||
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
||||
.prefix(candidatesPerPass)
|
||||
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
||||
|
||||
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||
var entries: [String: CachedRoadDistance] = [:]
|
||||
for station in nearest {
|
||||
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||
if let meters = await roadMeters(from: origin, to: dest) {
|
||||
// Store the exact pin that was routed so the display layer can
|
||||
// refuse to serve this value if the station later appears with
|
||||
// a different coordinate (corrected pin / other data source).
|
||||
entries[station.id] = CachedRoadDistance(meters: meters,
|
||||
lat: station.lat,
|
||||
lng: station.lng)
|
||||
}
|
||||
}
|
||||
guard !entries.isEmpty else { return }
|
||||
|
||||
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
|
||||
// Wake the widgets so the new road distances surface immediately.
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
||||
let request = MKDirections.Request()
|
||||
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
|
||||
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
|
||||
request.transportType = .automobile
|
||||
request.requestsAlternateRoutes = false
|
||||
do {
|
||||
let response = try await MKDirections(request: request).calculate()
|
||||
return response.routes.first?.distance
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Straight-line haversine distance between two coordinates, in metres.
|
||||
private static func haversineMeters(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
|
||||
let r = 6371000.0
|
||||
let dLat = (lat2 - lat1) * .pi / 180
|
||||
let dLng = (lng2 - lng1) * .pi / 180
|
||||
let a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
|
||||
sin(dLng / 2) * sin(dLng / 2)
|
||||
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import WidgetKit
|
||||
/// freshness) is debug-only too — moved here so release builds don't expose
|
||||
/// relay plumbing.
|
||||
struct SettingsView: View {
|
||||
/// Shared tip-purchase store — owned by ContentView so its outcome can
|
||||
/// ride the app-wide status banner (above the tabs), not a local card.
|
||||
@ObservedObject var tipStore: TipStore
|
||||
@Binding var distanceUnit: DistanceUnit
|
||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||
@@ -53,9 +56,6 @@ struct SettingsView: View {
|
||||
/// Clears the armed fence (monitor.clearDebugFence).
|
||||
var onClearDebugFence: () -> Void = {}
|
||||
|
||||
@StateObject private var tipStore = TipStore()
|
||||
@State private var showTipAlert = false
|
||||
@State private var tipAlertMessage = ""
|
||||
@State private var testAlertMessage: String?
|
||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||
@@ -359,13 +359,6 @@ struct SettingsView: View {
|
||||
Text("About")
|
||||
}
|
||||
|
||||
if let message = tipStore.message {
|
||||
Section {
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.onAppear {
|
||||
@@ -610,9 +603,19 @@ final class TipStore: ObservableObject {
|
||||
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
||||
]
|
||||
|
||||
/// A banner outcome: the exact copy to show plus its icon + tint.
|
||||
struct TipOutcome: Hashable {
|
||||
let message: String
|
||||
let icon: String
|
||||
let tint: Color
|
||||
}
|
||||
|
||||
@Published private(set) var products: [String: Product] = [:]
|
||||
@Published private(set) var purchaseInProgress = false
|
||||
@Published private(set) var 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()
|
||||
/// call — Ask to Buy approvals, payments finished on another device
|
||||
@@ -630,6 +633,7 @@ final class TipStore: ObservableObject {
|
||||
|
||||
deinit {
|
||||
updatesTask?.cancel()
|
||||
dismissTask?.cancel()
|
||||
}
|
||||
|
||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||
@@ -638,7 +642,30 @@ final class TipStore: ObservableObject {
|
||||
// Only acknowledge our own consumables (future products get their own).
|
||||
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
||||
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 {
|
||||
@@ -667,7 +694,11 @@ final class TipStore: ObservableObject {
|
||||
// Connect yet), still allow the attempt so the user sees a clear
|
||||
// outcome rather than a dead button.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -680,19 +711,35 @@ final class TipStore: ObservableObject {
|
||||
// Consume the consumable — otherwise StoreKit re-delivers
|
||||
// it through Transaction.updates on every launch.
|
||||
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:
|
||||
message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
tint: .orange
|
||||
)
|
||||
}
|
||||
case .userCancelled:
|
||||
message = nil // silent — the user just closed the sheet
|
||||
dismissOutcome() // silent — the user just closed the sheet
|
||||
case .pending:
|
||||
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
|
||||
icon: "hourglass",
|
||||
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
|
||||
)
|
||||
@unknown default:
|
||||
message = nil
|
||||
dismissOutcome()
|
||||
}
|
||||
} catch {
|
||||
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
|
||||
showOutcome(
|
||||
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
||||
icon: "xmark.circle.fill",
|
||||
tint: .red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,19 +489,14 @@ struct FuelMessageSnippetIntent: SnippetIntent {
|
||||
|
||||
struct FuelBoardShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
// Generic parameterized shortcut — matches whatever fuel word Siri
|
||||
// resolves. Parameter resolution is flaky on-device ("cheapest diesel"
|
||||
// matched, "cheapest unleaded" didn't), which is why the fixed-fuel
|
||||
// entries below carry the fuel word as LITERAL phrase text.
|
||||
AppShortcut(
|
||||
intent: CheapestFuelIntent(),
|
||||
phrases: [
|
||||
"Ask \(.applicationName) what's the cheapest \(\.$fuel) near me",
|
||||
],
|
||||
shortTitle: "Cheapest Fuel",
|
||||
systemImageName: "fuelpump"
|
||||
)
|
||||
|
||||
// NOTE (2026-08-20): the generic parameterized shortcut ("cheapest
|
||||
// ${fuel}") was REMOVED. It competed with the fixed-fuel literal-word
|
||||
// shortcuts for the same intent (parameter resolution was already flaky
|
||||
// on-device), which degraded Siri's NLU ranking and caused the
|
||||
// hit-and-miss "can't do that, searching in app" fallback. Now Siri
|
||||
// routes on literal fuel words only, and the freed slot keeps us under
|
||||
// the 10-shortcut cap. Every phrase carries .applicationName (iOS 26
|
||||
// metadata-processor requirement) and mirrors how people actually ask.
|
||||
AppShortcut(
|
||||
intent: CheapestFuelIntent(fuel: .e10),
|
||||
phrases: [
|
||||
@@ -509,6 +504,10 @@ struct FuelBoardShortcuts: AppShortcutsProvider {
|
||||
"Ask \(.applicationName) for the cheapest petrol near me",
|
||||
"Find the cheapest petrol near me \(.applicationName)",
|
||||
"Find the cheapest unleaded near me \(.applicationName)",
|
||||
"What's the cheapest unleaded near me \(.applicationName)",
|
||||
"What's the cheapest petrol near me \(.applicationName)",
|
||||
"Cheapest unleaded near me \(.applicationName)",
|
||||
"Where's the cheapest petrol \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Cheapest Unleaded",
|
||||
systemImageName: "fuelpump"
|
||||
|
||||
@@ -317,17 +317,6 @@ extension FuelType {
|
||||
case .diesel: return "Diesel"
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
|
||||
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Used for the
|
||||
/// fuel-type tab icons and the title icon.
|
||||
var tintColor: Color {
|
||||
switch self {
|
||||
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
||||
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
||||
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuel-type selector styled like a segmented control, with a coloured pump
|
||||
@@ -342,20 +331,26 @@ struct FuelTypeSegmentedPicker: View {
|
||||
var fuels: [FuelType] = FuelType.allCases
|
||||
var onSelect: (FuelType) -> Void = { _ in }
|
||||
|
||||
@Namespace private var selectionSlider
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(fuels) { fuel in
|
||||
let isSelected = fuel == selection
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
|
||||
selection = fuel
|
||||
}
|
||||
onSelect(fuel)
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "fuelpump.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(fuel.tintColor)
|
||||
.scaleEffect(isSelected ? 1.08 : 0.94)
|
||||
Text(fuel.shortName)
|
||||
.font(.subheadline.weight(isSelected ? .semibold : .regular))
|
||||
.contentTransition(.opacity)
|
||||
}
|
||||
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -364,14 +359,29 @@ struct FuelTypeSegmentedPicker: View {
|
||||
if isSelected {
|
||||
Capsule()
|
||||
.fill(Color(UIColor.systemBackground))
|
||||
.shadow(color: .black.opacity(0.08), radius: 1, y: 0.5)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(fuel.tintColor.opacity(0.28), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: fuel.tintColor.opacity(0.16), radius: 6, y: 2)
|
||||
.matchedGeometryEffect(id: "fuelSelectionSlider", in: selectionSlider)
|
||||
}
|
||||
}
|
||||
.scaleEffect(isSelected ? 1.02 : 0.98)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.background(Capsule().fill(Color(.secondarySystemBackground)))
|
||||
.animation(.spring(response: 0.42, dampingFraction: 0.78), value: selection)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Fuel Picker") {
|
||||
@Previewable @State var selectedFuel: FuelType = .e10
|
||||
FuelTypeSegmentedPicker(selection: $selectedFuel)
|
||||
.padding()
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ struct TrendsView: View {
|
||||
var onHistoryRecovered: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@State private var fuel: FuelType = .e10
|
||||
@State private var rangeDays: Int = 30
|
||||
@@ -34,6 +35,13 @@ struct TrendsView: View {
|
||||
@State private var loadFailed = false
|
||||
@State private var firstSnapshot: String?
|
||||
|
||||
/// Per-series draw-in waterline: maps stationID → how many leading points
|
||||
/// are revealed, so each line traces left→right on first appearance.
|
||||
/// Series reveal in a short staggered cascade so the draw is clearly
|
||||
/// visible even with only 2 points per line. Stays full after the first
|
||||
/// reveal so range/mode switches morph instead of re-tracing.
|
||||
@State private var revealed: [String: Int] = [:]
|
||||
|
||||
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
|
||||
/// opens where the user was — same pattern as FavouritesView.
|
||||
init(favourites: [FavouriteEntry],
|
||||
@@ -157,7 +165,13 @@ struct TrendsView: View {
|
||||
// pointer probe already done above.
|
||||
loadFailed = firstSnapshot == nil
|
||||
}
|
||||
// Always redraw the lines on a selection change. `.task(id:)` fires on
|
||||
// the initial appear and on every fuel/range switch (there's no
|
||||
// periodic refetch in this sheet), so a staggered per-series draw-in
|
||||
// replays exactly when the user picks 7/30/90 (or switches fuel) while
|
||||
// a width-stable morph keeps the x-axis/y-range from jumping abruptly.
|
||||
series = fetched
|
||||
revealSeries(fetched)
|
||||
// A failure with no data IS a connection problem — raise the global
|
||||
// banner so the user isn't stuck with a silent retry state. Success
|
||||
// clears it (only if the banner is the connection banner).
|
||||
@@ -168,6 +182,32 @@ struct TrendsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays the staggered per-series draw-in for the given histories:
|
||||
/// resets the waterline, then cascades each station's line left→right,
|
||||
/// 0.18s apart, on every range/fuel selection. Reduce Motion jump-cuts
|
||||
/// straight to the full state.
|
||||
private func revealSeries(_ histories: [StationHistory]) {
|
||||
let hasPoints = histories.contains { !$0.points.isEmpty }
|
||||
guard hasPoints else { return }
|
||||
if reduceMotion {
|
||||
revealed = histories.reduce(into: [:]) { $0[$1.stationID] = $1.points.count }
|
||||
return
|
||||
}
|
||||
revealed = [:]
|
||||
let cascadeNS = UInt64(0.18 * 1_000_000_000)
|
||||
for (i, h) in histories.enumerated() {
|
||||
let sid = h.stationID
|
||||
let total = h.points.count
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: UInt64(i) * cascadeNS)
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.easeOut(duration: 0.5)) {
|
||||
revealed[sid] = total
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func yLabel(_ pence: Double) -> String {
|
||||
switch mode {
|
||||
case .price:
|
||||
@@ -330,6 +370,9 @@ struct TrendsView: View {
|
||||
.font(.system(size: 30, weight: .bold, design: .default))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
// Roll the digits to the new figure on range/mode change
|
||||
// (fires inside the animated transaction above).
|
||||
.contentTransition(.numericText(value: headAvg))
|
||||
if let delta = headlineDelta, delta != 0 {
|
||||
Label(
|
||||
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
|
||||
@@ -389,13 +432,13 @@ struct TrendsView: View {
|
||||
// type-checker's budget.)
|
||||
Chart {
|
||||
ForEach(displaySeries) { history in
|
||||
ForEach(history.points) { point in
|
||||
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
|
||||
areaMark(point, series: history.name,
|
||||
color: seriesColor(index(of: history.stationID)))
|
||||
}
|
||||
}
|
||||
ForEach(displaySeries) { history in
|
||||
ForEach(history.points) { point in
|
||||
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
|
||||
lineMark(point, series: history.name,
|
||||
color: seriesColor(index(of: history.stationID)))
|
||||
}
|
||||
@@ -424,6 +467,9 @@ struct TrendsView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 190)
|
||||
// Price ↔ vs-cheapest is a pure view toggle (no network): animate the
|
||||
// lines + axis gliding to the rebased series.
|
||||
.animation(.easeInOut(duration: 0.35), value: mode)
|
||||
}
|
||||
|
||||
/// One gradient-filled area band under a single point of a series.
|
||||
|
||||
@@ -233,11 +233,25 @@ final class BrandTests: XCTestCase {
|
||||
XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet")
|
||||
XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco")
|
||||
XCTAssertEqual(station(brand: "APPLEGREEN").brandImageName, "brand_applegreen")
|
||||
XCTAssertEqual(station(brand: "VALERO").brandImageName, "brand_valero")
|
||||
XCTAssertEqual(station(brand: "WELCOME BREAK").brandImageName, "brand_welcome_break")
|
||||
XCTAssertEqual(station(brand: "THE CO-OPERATIVE").brandImageName, "brand_the_co_operative")
|
||||
XCTAssertEqual(station(brand: "CO-OP").brandImageName, "brand_the_co_operative")
|
||||
XCTAssertEqual(station(brand: "MURCO").brandImageName, "brand_murco")
|
||||
XCTAssertEqual(station(brand: "GLEANER").brandImageName, "brand_gleaner")
|
||||
XCTAssertEqual(station(brand: "HIGHLAND FUELS").brandImageName, "brand_highland_fuels")
|
||||
XCTAssertEqual(station(brand: "CIRCLE K").brandImageName, "brand_circle_k")
|
||||
XCTAssertEqual(station(brand: "MAXOL").brandImageName, "brand_maxol")
|
||||
XCTAssertEqual(station(brand: "SPAR").brandImageName, "brand_spar")
|
||||
XCTAssertEqual(station(brand: "ESSAR").brandImageName, "brand_essar")
|
||||
XCTAssertEqual(station(brand: "EG ON THE MOVE").brandImageName, "brand_esso")
|
||||
XCTAssertEqual(station(brand: "GO FORECOURT").brandImageName, "brand_gulf")
|
||||
}
|
||||
|
||||
func testUnknownBrand() {
|
||||
XCTAssertNil(station(brand: "WELCOME BREAK").brandImageName)
|
||||
XCTAssertNil(station(brand: "VALERO").brandImageName)
|
||||
func testUnknownBrandUsesGenericFuelPumpFallbackInUI() {
|
||||
XCTAssertNil(station(brand: "TOTAL HARVEST ENERGY").brandImageName)
|
||||
XCTAssertNil(station(brand: "SOLO").brandImageName)
|
||||
XCTAssertNil(station(brand: "PACE").brandImageName)
|
||||
XCTAssertNil(station(brand: "").brandImageName)
|
||||
}
|
||||
}
|
||||
@@ -606,3 +620,82 @@ final class OfflineDataLabelTests: XCTestCase {
|
||||
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Road distance cache
|
||||
|
||||
final class RoadDistanceCacheTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Keychain persists across invocations, so a cache left by an earlier
|
||||
// test or run would pollute these. Overwrite with an empty, far-away
|
||||
// cache (source at (0,0)) so every test starts from a clean slate.
|
||||
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
|
||||
}
|
||||
|
||||
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
|
||||
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
|
||||
lat: lat, lng: lng, prices: [:], priceUpdated: nil)
|
||||
}
|
||||
|
||||
func testDisplayDistanceFallsBackToStraightLineWhenNoCache() {
|
||||
// London user, station ~ London -> no cache -> straight-line haversine.
|
||||
let s = station("a", 51.5074, -0.1278)
|
||||
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testRoadDistanceUsedWhenCachedNear() {
|
||||
let s = station("a", 51.5074, -0.1278)
|
||||
// Cache a road distance of 3.2 km for this station from the user's fix.
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||
XCTAssertEqual(km, 3.2, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testRoadDistanceNilWhenOriginFar() {
|
||||
let s = station("a", 51.5074, -0.1278)
|
||||
// Cache built in London, but the user is now ~200 km away -> stale.
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||
let meters = FuelStore.roadDistanceMeters(for: s, userLat: 53.4808, userLng: -2.2426)
|
||||
XCTAssertNil(meters)
|
||||
// And display falls back to straight-line.
|
||||
let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426)
|
||||
XCTAssertEqual(km, s.distanceKM(to: 53.4808, lng2: -2.2426), accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testRoadDistanceUsedForOtherStationNotFound() {
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||
// A station that isn't in the cache falls back to straight-line.
|
||||
let s = station("z", 51.51, -0.13)
|
||||
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testRoadDistanceNotServedWhenStationPinDiffers() {
|
||||
// Route a road distance to station "a" at pin P1.
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||
// The SAME station id appears with a moved pin (corrected coordinate /
|
||||
// different embedded vs live source): the cached route to P1 must NOT
|
||||
// be served — it belongs to a different location.
|
||||
let moved = station("a", 51.5400, -0.1600)
|
||||
let km = FuelStore.displayDistanceKM(station: moved, userLat: 51.6, userLng: -0.1)
|
||||
XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
|
||||
"road value routed to the old pin leaked onto a different coordinate")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Install identity
|
||||
|
||||
final class InstallIdentityTests: XCTestCase {
|
||||
func testFreshInstallIsStableAfterFirstCall() {
|
||||
// The first call seeds the common install id (local == keychain), so
|
||||
// any subsequent call in the same process must report NOT-fresh. This
|
||||
// holds regardless of persisted keychain/defaults state from prior runs.
|
||||
_ = FuelStore.isFreshInstall()
|
||||
XCTAssertFalse(FuelStore.isFreshInstall())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,27 +4,25 @@
|
||||
// Shows the cheapest station for the pinned fuel within the app's chosen
|
||||
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
||||
//
|
||||
// ADAPTIVE LAYOUT: 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.
|
||||
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
||||
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
||||
// declared (see note on the config), so iPhone/iPad always render the full
|
||||
// three-column card (`richBody`) with proper text sizes. `compactBody` is kept
|
||||
// as a safety fallback should any surface ever hand this view a narrow width.
|
||||
//
|
||||
// IMPORTANT: `.supplementalActivityFamilies([.small])` is deliberately absent —
|
||||
// it made iOS render the squeezed `.small` card on the iPhone Lock Screen.
|
||||
// CarPlay's small form comes from the Dynamic Island compact closures.
|
||||
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// The Live Activity itself — registered in the widget bundle alongside the
|
||||
/// regular price widget. No CarPlay entitlement involved: this renders on the
|
||||
/// Lock Screen, Dynamic Island, and the car display (CarPlay Ultra, iOS 26+).
|
||||
/// `.supplementalActivityFamilies([.small])` makes it eligible for the car's
|
||||
/// small Live Activity slot — display-only there (FuelBoard is not a
|
||||
/// CarPlay-enabled app, so car-side taps can't launch anything).
|
||||
/// regular price widget. No CarPlay entitlement involved: renders on the
|
||||
/// Lock Screen (full-width card on iPhone/iPad) and the Dynamic Island
|
||||
/// (incl. the island's compact form used in the car, display-only — FuelBoard
|
||||
/// is not a CarPlay-enabled app, so car-side taps can't launch anything).
|
||||
struct FuelBoardLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
|
||||
@@ -43,7 +41,7 @@ struct FuelBoardLiveActivity: Widget {
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "fuelpump.fill")
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(context.state.fuel.tintColor)
|
||||
} compactTrailing: {
|
||||
FuelBoardLiveActivityPriceView(context: context)
|
||||
} minimal: {
|
||||
@@ -52,6 +50,14 @@ struct FuelBoardLiveActivity: Widget {
|
||||
}
|
||||
}
|
||||
.supplementalActivityFamilies([.small])
|
||||
// Why `.small` is kept: it lets the SHARED body render a compact form
|
||||
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
|
||||
// instead of falling back to the Dynamic Island compact closure —
|
||||
// which could NOT show the station distance the user wants on CarPlay.
|
||||
// The full-width iPhone/iPad card is protected by the `richMinWidth`
|
||||
// gate on `richBody` + its flexible, truncating middle column, so
|
||||
// iPhone/iPad still get the full card; only truly small space picks
|
||||
// the compact strip below.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,27 +65,59 @@ struct FuelBoardLiveActivity: Widget {
|
||||
private struct FuelBoardLiveActivityView: View {
|
||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||
|
||||
/// Below this ACTUAL proposed width we show the compact strip (CarPlay
|
||||
/// small / Watch smart stack); at/above it we show the full card. The
|
||||
/// decision is made from the real width the system hands the body, read
|
||||
/// via a background GeometryReader — NOT ViewThatFits ideal-width
|
||||
/// measurement (that's broken for truncating text: a long station name
|
||||
/// inflated the "ideal" width past the iPhone Lock Screen and collapsed
|
||||
/// the full card).
|
||||
private let compactWidthThreshold: CGFloat = 280
|
||||
|
||||
/// Measured slot width (drives the rich-vs-compact branch). Measured in a
|
||||
/// background GeometryReader so it does NOT act as the layout container:
|
||||
/// a GeometryReader root pins content top-left, and forcing a
|
||||
/// maxHeight:.infinity frame on it over-claims the whole proposed height,
|
||||
/// centring the content below true vertical centre (bigger gap above) on
|
||||
/// the Lock Screen. Measuring behind the scenes keeps the content
|
||||
/// intrinsic-sized so the system vertically centres it itself.
|
||||
@State private var slotWidth: CGFloat = 400
|
||||
|
||||
var body: some View {
|
||||
Link(destination: context.state.mapsURL) {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
// rich first — wins on full-width Lock Screen / banner
|
||||
Group {
|
||||
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
||||
// full Lock Screen width (>= threshold) → rich card, no matter
|
||||
// how long the station name is. Truly small slots (CarPlay /
|
||||
// Watch) offer much less → compact strip.
|
||||
if slotWidth >= compactWidthThreshold {
|
||||
richBody
|
||||
// CarPlay / Watch small slot is far narrower than this,
|
||||
// so ViewThatFits reliably falls through to compactBody.
|
||||
.frame(minWidth: 280)
|
||||
// compact fallback — the car's small supplemental slot
|
||||
} else {
|
||||
compactBody
|
||||
}
|
||||
}
|
||||
// Fill the card width so the background measure reads the real
|
||||
// slot, not the intrinsic content width.
|
||||
.frame(maxWidth: .infinity)
|
||||
// Side-channel width measurement — never the layout container.
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.onAppear { slotWidth = geo.size.width }
|
||||
.onChange(of: geo.size.width) { _, w in slotWidth = w }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
|
||||
private var richBody: some View {
|
||||
HStack(spacing: 12) {
|
||||
// LEFT — station brand glyph
|
||||
Image(systemName: "fuelpump.circle.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.green, .white)
|
||||
// LEFT — station brand glyph: the fuel-tinted pump on its own. No
|
||||
// background circle behind it (user request).
|
||||
Image(systemName: "fuelpump.fill")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(context.state.fuel.tintColor)
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
// MIDDLE — fuel + station
|
||||
@@ -90,6 +128,8 @@ private struct FuelBoardLiveActivityView: View {
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -106,16 +146,15 @@ private struct FuelBoardLiveActivityView: View {
|
||||
.padding()
|
||||
}
|
||||
|
||||
/// Minimal strip for the small CarPlay / Watch Smart Stack slot:
|
||||
/// glyph + fuel left, bold price right, truncated station below.
|
||||
/// Minimal strip for small space (CarPlay small / Watch smart stack):
|
||||
/// fuel type + bold price on one line, station · distance below.
|
||||
/// Deliberately no app name and no "Tap for directions" — CarPlay is
|
||||
/// display-only, and the user's asks here are just fuel + price + distance.
|
||||
private var compactBody: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "fuelpump.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
Text(context.state.fuel.displayName)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.font(.caption.bold())
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||
@@ -123,10 +162,12 @@ private struct FuelBoardLiveActivityView: View {
|
||||
size: 15, weight: .bold)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(context.state.stationName)
|
||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
@@ -216,10 +216,18 @@ enum FuelHistoryStore {
|
||||
// MARK: Network
|
||||
|
||||
/// The mirror pointer — used for the empty-state hint ("first snapshot
|
||||
/// landed …"). Non-fatal: nil just means no hint.
|
||||
static func fetchLatest(base: URL = mirrorBase,
|
||||
/// landed …"). Non-fatal: nil just means no hint. Deliberately bypasses
|
||||
/// the HTTP cache (reloadIgnoringLocalCacheData) so an OFFLINE fetch
|
||||
/// really fails instead of silently re-serving a cached pointer as a
|
||||
/// success — otherwise "no network" would look like fresh data and the
|
||||
/// offline banner would never fire. A short timeout surfaces the failure
|
||||
/// (and the banner) quickly on a connected-but-dead network.
|
||||
static func fetchLatest(base: URL = mirrorBase, timeout: TimeInterval = 10,
|
||||
session: URLSession = .shared) async -> MirrorLatest? {
|
||||
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,
|
||||
let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else {
|
||||
return nil
|
||||
|
||||
@@ -89,7 +89,7 @@ struct FuelPriceWidgetContent: View {
|
||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||
}
|
||||
if let location = entry.location {
|
||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
|
||||
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
@@ -148,7 +148,7 @@ struct FuelPriceWidgetContent: View {
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
if let location = entry.location {
|
||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
||||
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// keychain → app-group defaults → fallback.
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Security
|
||||
#if canImport(AppIntents)
|
||||
import AppIntents
|
||||
@@ -99,6 +100,20 @@ enum FuelType: String, Codable, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
|
||||
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Lives here in Shared
|
||||
/// so the app, widget, and Live Activity all tint the pump/fuel glyphs from one
|
||||
/// definition.
|
||||
extension FuelType {
|
||||
var tintColor: Color {
|
||||
switch self {
|
||||
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
||||
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
||||
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(AppIntents)
|
||||
extension FuelType: AppEnum {}
|
||||
#endif
|
||||
@@ -242,21 +257,37 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
||||
var brandImageName: String? {
|
||||
let raw = brand.uppercased()
|
||||
let known: [(String, String)] = [
|
||||
("CIRCLE K", "brand_circle_k"),
|
||||
("MAXOL", "brand_maxol"),
|
||||
("SPAR", "brand_spar"),
|
||||
("SHELL", "brand_shell"),
|
||||
("SAINSBURY", "brand_sainsburys"),
|
||||
("MORRISONS", "brand_morrisons"),
|
||||
("APPLEGREEN", "brand_applegreen"),
|
||||
("TESCO", "brand_tesco"),
|
||||
("TEXACO", "brand_texaco"),
|
||||
("ESSAR", "brand_essar"),
|
||||
("ESSO", "brand_esso"),
|
||||
("ASDA", "brand_asda"),
|
||||
("GULF", "brand_gulf"),
|
||||
("VALERO", "brand_valero"),
|
||||
("WELCOME BREAK", "brand_welcome_break"),
|
||||
("THE CO-OPERATIVE", "brand_the_co_operative"),
|
||||
("CO-OPERATIVE", "brand_the_co_operative"),
|
||||
("CO OPERATIVE", "brand_the_co_operative"),
|
||||
("CO-OP", "brand_the_co_operative"),
|
||||
("CO OP", "brand_the_co_operative"),
|
||||
("MURCO", "brand_murco"),
|
||||
("GLEANER", "brand_gleaner"),
|
||||
("HIGHLAND FUELS", "brand_highland_fuels"),
|
||||
("BP", "brand_bp"),
|
||||
("JET", "brand_jet"),
|
||||
]
|
||||
for (needle, asset) in known where raw.contains(needle) {
|
||||
return asset
|
||||
}
|
||||
if raw.contains("EG ON THE MOVE") { return "brand_esso" }
|
||||
if raw == "GO" || raw.contains("GO FORECOURT") || raw.contains("GO FUEL") { return "brand_gulf" }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -903,15 +934,127 @@ struct FuelStore {
|
||||
}
|
||||
|
||||
// MARK: Onboarding — the app shows the intro screen on first launch only
|
||||
// (a test button in the Alerts tab re-opens it). Stored in the app group
|
||||
// so the widget can see it too if ever needed.
|
||||
// (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST
|
||||
// (with an app-group mirror) for the same reason as favourites/distance
|
||||
// unit: free SideStore accounts don't provision the app-group container,
|
||||
// so an app-group-only flag silently fails to save AND reloads as false,
|
||||
// making onboarding re-appear on every launch. Keychain survives reinstall
|
||||
// and is shared with the extension.
|
||||
|
||||
static func loadHasCompletedOnboarding() -> Bool {
|
||||
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false
|
||||
(loadString(service: onboardingCompletedKey) ?? "0") == "1"
|
||||
}
|
||||
|
||||
static func saveHasCompletedOnboarding(_ completed: Bool) {
|
||||
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
|
||||
saveString(completed ? "1" : "0", service: onboardingCompletedKey)
|
||||
}
|
||||
|
||||
// MARK: Install identity — distinguish a genuinely fresh install (where
|
||||
// onboarding should replay) from later launches of the same install.
|
||||
|
||||
/// Keychain copy of the install id (survives reinstall).
|
||||
private static let installIDKey = "fuelboard.installID"
|
||||
/// Local (app-own container) copy — wiped on reinstall, persists across
|
||||
/// normal launches. Free SideStore accounts have no app-group container,
|
||||
/// so this app-own defaults domain is the reliable "same install" signal.
|
||||
private static let localInstallIDKey = "fuelboard.installID.local"
|
||||
|
||||
/// True when this is a first-ever install OR the app was just reinstalled
|
||||
/// (local install id missing/different from the keychain id). Seeds a fresh
|
||||
/// id into both stores so the next launch within the same install is not a
|
||||
/// "fresh install" any more.
|
||||
static func isFreshInstall() -> Bool {
|
||||
let local = UserDefaults.standard.string(forKey: localInstallIDKey)
|
||||
let remote = loadString(service: installIDKey)
|
||||
if let local, let remote, local == remote { return false }
|
||||
// Fresh / mismatched install (or first launch). Pattern a common id so
|
||||
// subsequent launches of this install are recognised as the same one.
|
||||
let id = UUID().uuidString
|
||||
UserDefaults.standard.set(id, forKey: localInstallIDKey)
|
||||
saveString(id, service: installIDKey)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Onboarding should present when it hasn't been completed in this install
|
||||
/// OR this is a freshly-installed app (so the walkthrough replays on a new
|
||||
/// install, e.g. after a SideStore reinstall, without re-showing on every
|
||||
/// ordinary launch). The install check always runs (it seeds the id) rather
|
||||
/// than short-circuiting, so a brand-new install is recorded before the
|
||||
/// user ever fills in onboarding.
|
||||
static func shouldShowOnboarding() -> Bool {
|
||||
let fresh = isFreshInstall()
|
||||
return !loadHasCompletedOnboarding() || fresh
|
||||
}
|
||||
|
||||
// MARK: Road distances (Apple-Maps-matched, computed by the app)
|
||||
|
||||
/// Cached road/routed distances (metres) keyed by station ID, computed by
|
||||
/// the app via MapKit `MKDirections`. Stored in KEYCHAIN (survives on free
|
||||
/// SideStore accounts where the app-group container isn't provisioned) so
|
||||
/// the widget extension can read it too. Widget + Live Activity prefer
|
||||
/// these over straight-line haversine for the displayed distance.
|
||||
static let roadDistancesKey = "fuelboard.roadDistances"
|
||||
|
||||
/// How far (metres) the cache's source location may be from the current
|
||||
/// user position before a cached road distance is treated as stale.
|
||||
static let roadDistanceOriginToleranceMeters: Double = 600
|
||||
|
||||
/// How far (degrees) a station's stored coordinate may drift from the pin
|
||||
/// a road distance was actually routed to before that cached value is
|
||||
/// treated as belonging to a different station. Guards against one data
|
||||
/// source (live fetch, bundled offline dump, or a corrected pin) serving a
|
||||
/// road distance that was computed for a different coordinate under the
|
||||
/// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but
|
||||
/// catches any real pin change.
|
||||
static let roadDistancePinToleranceDegrees: Double = 1e-4
|
||||
|
||||
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) {
|
||||
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
|
||||
updatedAt: Date().timeIntervalSince1970, entries: entries)
|
||||
if let data = try? JSONEncoder().encode(cache) {
|
||||
saveString(data.base64EncodedString(), service: roadDistancesKey)
|
||||
}
|
||||
}
|
||||
|
||||
static func loadRoadDistances() -> RoadDistanceCache? {
|
||||
guard let raw = loadString(service: roadDistancesKey),
|
||||
let data = Data(base64Encoded: raw),
|
||||
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
|
||||
else { return nil }
|
||||
return cache
|
||||
}
|
||||
|
||||
/// Cached road distance (metres) to a station from the user's location, or
|
||||
/// nil when not cached / the cache was built too far from where the user
|
||||
/// is now / the station's own pin doesn't match the coordinate that was
|
||||
/// routed.
|
||||
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
||||
guard let cache = loadRoadDistances(),
|
||||
let entry = cache.entries[station.id] else { return nil }
|
||||
// The cache is only valid near the location it was built from.
|
||||
let dLat = (userLat - cache.sourceLat) * .pi / 180
|
||||
let dLng = (userLng - cache.sourceLng) * .pi / 180
|
||||
let r = 6371000.0
|
||||
let a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(cache.sourceLat * .pi / 180) * cos(userLat * .pi / 180) *
|
||||
sin(dLng / 2) * sin(dLng / 2)
|
||||
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
|
||||
// Pin fingerprint: never serve a routed value for a coordinate we
|
||||
// didn't actually route to. This is the guard that keeps embedded /
|
||||
// live / cached station sets from injecting each other's road metres.
|
||||
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
|
||||
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
|
||||
return entry.meters
|
||||
}
|
||||
|
||||
/// Distance (km) to display for a station: cached ROAD distance when
|
||||
/// available (matches Apple Maps), else straight-line haversine.
|
||||
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
|
||||
if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
|
||||
return meters / 1000.0
|
||||
}
|
||||
return station.distanceKM(to: userLat, lng2: userLng)
|
||||
}
|
||||
|
||||
// MARK: Low-level keychain helpers
|
||||
@@ -973,3 +1116,23 @@ struct FuelStore {
|
||||
loadString(service: "widget.diag.\(intentType)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached Apple-Maps road distances for nearby stations (see
|
||||
/// A single cached road distance plus the station pin it was routed to. Keeping
|
||||
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
|
||||
/// *different* coordinate under the same ID — the guard that stops embedded /
|
||||
/// live / cached station sets cross-contaminating the distance display.
|
||||
struct CachedRoadDistance: Codable {
|
||||
let meters: Double
|
||||
let lat: Double
|
||||
let lng: Double
|
||||
}
|
||||
|
||||
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
|
||||
/// the routed pin.
|
||||
struct RoadDistanceCache: Codable {
|
||||
let sourceLat: Double
|
||||
let sourceLng: Double
|
||||
let updatedAt: TimeInterval
|
||||
let entries: [String: CachedRoadDistance]
|
||||
}
|
||||
|
||||
@@ -96,9 +96,13 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
||||
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
|
||||
data = cached
|
||||
} else {
|
||||
let (fetched, response) = try await URLSession.shared.data(
|
||||
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL)
|
||||
)
|
||||
// Bypass the HTTP cache + short timeout: an offline refresh must
|
||||
// FAIL (→ offline banner), never re-serve a stale cached dump as
|
||||
// a "successful" live fetch.
|
||||
var req = URLRequest(url: FuelHistoryStore.historyFileURL(day: day, base: baseURL))
|
||||
req.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
req.timeoutInterval = 10
|
||||
let (fetched, response) = try await URLSession.shared.data(for: req)
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||
throw FuelProviderError.mirrorUnavailable
|
||||
}
|
||||
|
||||