Files
fuelboard/FuelBoard/ContentView.swift
T

1111 lines
50 KiB
Swift

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
@State private var stations: [FuelStation] = FuelStore.loadStations()
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
@State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@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 liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
@State private var liveActivityRadiusMiles: Int = FuelStore.loadLiveActivityRadiusMiles()
@State private var liveActivityFollowsSearch: Bool = FuelStore.loadLiveActivityFollowsSearch()
@State private var location: Coordinate? = {
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
return nil
}()
/// The radius the geofence actually uses: the manual alert radius, or —
/// when "Follow search" is on — the Stations-tab distance capped at 8 mi.
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
/// above the tabs:
/// - `.live`: fetched or cached data — nothing to say.
/// - `.offlineDump(date)`: serving the BUNDLED no-network snapshot —
/// the banner labels it honestly with the snapshot's own date.
/// - `.connectionProblem`: fetch failed but a saved cache is showing —
/// the banner says to check connectivity (tap = retry).
enum DataSourceStatus: Equatable {
case live
case offlineDump(date: String)
case connectionProblem
}
@State private var dataStatus: DataSourceStatus = .live
@State private var showOnboarding = false
@State private var showWidgetMock = false
@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
/// redundant — the whole country sorted nearest-first, because "nearest"
/// must never answer with an empty state. STRICT: no fallback to
/// out-of-radius stations in Cheapest mode.
private var poolStations: [FuelStation] {
let selling = stations.filter { $0.prices[selectedFuel] != nil }
guard let location else { return selling }
if sortMode == .closest { return selling } // radius disabled in Closest
let radiusKM = distanceUnit.toKM(Double(stationLimit)) // chosen units → km
return selling.filter {
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
}
}
/// The price reference every row's delta + RAG compares against — the
/// "best" of the current pool. In BOTH modes that is the CHEAPEST station
/// within the chosen miles radius (the stations you'd actually consider).
/// Cheapest mode's pool already is the radius; Closest mode lists the
/// whole country nearest-first (so it never empties), but best value is
/// still judged against what's practically reachable, so deltas stay local.
/// Fallback: if the radius has no stations, use the pool minimum.
private var baselinePrice: Double? {
let pool = poolStations
if sortMode == .closest, let location {
let radiusKM = distanceUnit.toKM(Double(stationLimit)) // chosen units → km
let within = pool.filter {
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
}
if let localMin = within.compactMap({ $0.prices[selectedFuel] }).min() {
return localMin
}
}
return pool.compactMap { $0.prices[selectedFuel] }.min()
}
/// The "best" station — the cheapest in the pool — gets the TOP badge.
/// First occurrence in display order wins on price ties.
private var topStationID: String? {
guard let baselinePrice else { return nil }
return displayedStations.first { $0.prices[selectedFuel] == baselinePrice }?.id
}
/// The app's own "cheapest in range" — the TOP-badge station — mirrored
/// into the Settings → Debug section so its "Cheapest in range" row
/// matches the app list (and the widget when the widget's fuel + distance
/// match the app's). The debug section previously showed the ALERT
/// prediction (alertsFuel + alert radius), which is a different pool by
/// design — that mismatch is what this fixes.
private var appCheapestStatus: DebugAppCheapest? {
guard let topID = topStationID,
let station = stations.first(where: { $0.id == topID }),
let location else { return nil }
return DebugAppCheapest(
station: station,
fuel: selectedFuel,
radiusKM: distanceUnit.toKM(Double(stationLimit)),
distanceKM: station.distanceKM(to: location.lat, lng2: location.lng)
)
}
private var displayedStations: [FuelStation] {
sortedStations
}
private var sortedStations: [FuelStation] {
let pool = poolStations
switch sortMode {
case .closest:
guard let location else { return pool.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } }
return pool.sorted { lhs, rhs in
// Closest first; price only breaks ties.
let lDist = lhs.distanceKM(to: location.lat, lng2: location.lng)
let rDist = rhs.distanceKM(to: location.lat, lng2: location.lng)
if lDist != rDist { return lDist < rDist }
return lhs.prices[selectedFuel]! < rhs.prices[selectedFuel]!
}
case .cheapest:
return pool.sorted { lhs, rhs in
// Cheapest first; distance only breaks ties.
let lPrice = lhs.prices[selectedFuel]!
let rPrice = rhs.prices[selectedFuel]!
if lPrice != rPrice { return lPrice < rPrice }
guard let location else { return false }
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
rhs.distanceKM(to: location.lat, lng2: location.lng)
}
}
}
/// Station IDs favourited for the CURRENTLY selected fuel — the star on a
/// Stations-tab row reflects the fuel being viewed (fuel-scoped favourites).
private var favouriteIDs: Set<String> {
Set(favourites.filter { $0.fuel == selectedFuel }.map(\.station.id))
}
private var refreshedFavourites: [FavouriteEntry] {
FuelStore.refreshedFavourites(favourites, from: stations)
}
var body: some View {
configuredContent
}
private var configuredContent: AnyView {
interactionObservedContent
}
private var presentedContent: AnyView {
AnyView(
mainContent
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView {
showOnboarding = false
}
}
.fullScreenCover(isPresented: $showWidgetMock) {
WidgetMockScreen()
}
.sheet(item: $monitor.pendingStationMap) { request in
StationMapView(request: request)
}
)
}
private var lifecycleObservedContent: AnyView {
AnyView(
presentedContent
.onAppear {
// Launch-arg hooks for the screenshot/UI-test harness (same
// pattern as StationsView's `-showKeySheet`): `-tab <name>`
// opens the given tab; `-skipOnboarding` skips onboarding
// without touching the stored flag.
let args = ProcessInfo.processInfo.arguments
#if DEBUG
showWidgetMock = args.contains("-widgets")
// `-seedFavourite <substring>` pins the first matching station
// for Unleaded via the normal save path (keychain + app group)
// so screenshot captures can show a populated Favourites tab.
// Repeatable: each occurrence adds another favourite (QA temp).
// Optional ":fuel" suffix (e10|e5|diesel) picks the
// fuel, so the fuel-aware Trends open can be verified (QA temp).
let seedQueries = args.enumerated()
.filter { $0.element == "-seedFavourite" }
.compactMap { i, _ in
i + 1 < args.count ? args[i + 1] : nil
}
if !seedQueries.isEmpty {
let stations = FuelStore.loadStations()
var favs = seedQueries.compactMap { query in
let parts = query.split(separator: ":", maxSplits: 1)
let name = String(parts[0])
let fuel = parts.count > 1
? FuelType(rawValue: String(parts[1])) ?? .e10
: FuelType.e10
return stations.first { $0.name.localizedCaseInsensitiveContains(name) }
.map { FavouriteEntry(station: $0, fuel: fuel) }
}
if favs.isEmpty, let q = seedQueries.first,
let s = stations.first(where: { $0.name.localizedCaseInsensitiveContains(q.split(separator: ":").first.map(String.init) ?? q) }) {
favs = [FavouriteEntry(station: s, fuel: .e10)]
}
if !favs.isEmpty {
FuelStore.saveFavourites(favs)
}
}
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
switch args[i + 1] {
case "favourites": selectedTab = 1
case "alerts": selectedTab = 2
case "settings": selectedTab = 3
default: selectedTab = 0
}
}
// QA temp: `-fuel <e10|e5|diesel>` sets the app-wide selected
// fuel so the fuel-aware Trends open can be verified (simulates
// the user having switched to the diesel tab).
if let i = args.firstIndex(of: "-fuel"), i + 1 < args.count,
let f = FuelType(rawValue: args[i + 1]) {
selectedFuel = f
FuelStore.saveSelectedFuel(f)
}
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
// failure legs for the screenshot harness. The auto-refresh below
// is skipped so the banner stays up (a live fetch would clear it).
if args.contains("-forceOfflineDump") {
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
}
if args.contains("-forceConnectionProblem") {
stations = FuelStore.loadStations().isEmpty
? (BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations)
: FuelStore.loadStations()
dataStatus = .connectionProblem
}
#else
showWidgetMock = false
#endif
// Onboarding runs first on a fresh install — it owns the initial
// permission prompts (location, notifications, and the data/local
// network probe on the Data page). Location tracking and the first
// network fetch start once it's done.
// Launch-arg hook (UI-testing/screenshot harness, same pattern as
// StationsView's `-showKeySheet`): skip onboarding without
// touching the stored flag.
#if DEBUG
let shouldSkipOnboarding = args.contains("-skipOnboarding")
#else
let shouldSkipOnboarding = false
#endif
if !FuelStore.shouldShowOnboarding()
|| shouldSkipOnboarding {
locationManager.startForegroundTracking()
// 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
let shouldSkipInitialRefresh = args.contains("-forceOfflineDump")
|| args.contains("-forceConnectionProblem")
|| args.contains("-forceHistoryFailure")
#else
let shouldSkipInitialRefresh = false
#endif
if !shouldSkipInitialRefresh {
Task { await refresh() }
}
} else {
showOnboarding = true
}
}
.onChange(of: showOnboarding) { _, showing in
// After onboarding finishes (or the test re-run is dismissed),
// begin foreground location tracking if permission allows, and
// run the FIRST data fetch. This must be a forced refresh: a
// reinstall may leave a recent `lastRefresh` in the app-group
// defaults (which survive app deletion), which would make the
// cache-gated refresh skip the fetch and leave the station list
// empty on a brand-new install.
if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
installLocationUpdateHook()
SmartDataRefreshScheduler.scheduleNextIfNeeded()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
Task { await refresh(force: true) }
}
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
// Never start location tracking while onboarding is on screen —
// onboarding owns the initial permission prompts. Once it's
// completed, normal foreground tracking resumes.
if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
}
SmartDataRefreshScheduler.scheduleNextIfNeeded()
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()
}
}
.onChange(of: locationManager.current) { _, newLocation in
if let newLocation {
location = newLocation
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
WidgetCenter.shared.reloadAllTimelines()
// Geofences follow the user's position, but the station list is
// NOT re-fetched on every movement (cached, twice-a-day policy).
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
refreshRoadDistancesIfNeeded()
}
}
)
}
private var interactionObservedContent: AnyView {
AnyView(
lifecycleObservedContent
.onChange(of: selectedFuel) { _, _ in
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
WidgetCenter.shared.reloadAllTimelines()
}
.onChange(of: stationLimit) { _, newValue in
// Distance filter is LOCAL math now — the cache holds the full-UK
// dump, so changing 5/10/15 (miles or km) never needs a network
// fetch. sortedStations/radiusScopedStations recompute on the
// next render.
FuelStore.saveStationLimit(newValue)
WidgetCenter.shared.reloadAllTimelines()
// Follow-search surfaces mirror this distance — re-target.
if alertsFollowsSearch {
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
}
updateLiveActivity()
}
.onChange(of: alertsEnabled) { _, newValue in
FuelStore.saveAlertsEnabled(newValue)
monitor.setEnabled(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
ensureBackgroundMotionTrackingIfNeeded()
}
.onChange(of: alertsRadius) { _, newValue in
FuelStore.saveAlertsRadius(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
}
.onChange(of: alertsFuel) { _, newValue in
// Alerts fuel is independent of the Stations-tab selection —
// changing it re-targets geofences to stations selling that fuel.
FuelStore.saveAlertsFuel(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: newValue, radiusKM: effectiveAlertsRadiusKM)
}
.onChange(of: alertsFollowsSearch) { _, newValue in
// Follow search = the geofence mirrors the Stations-tab distance
// (capped at 8 mi); picking any fixed radius clears it.
FuelStore.saveAlertsFollowsSearch(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
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
// 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
// The pill tracks its OWN fuel — independent of the Stations tab.
FuelStore.saveLiveActivityFuel(newValue)
updateLiveActivity()
}
.onChange(of: liveActivityRadiusMiles) { _, newValue in
// Same for its own radius (miles, converted at use).
FuelStore.saveLiveActivityRadiusMiles(newValue)
updateLiveActivity()
}
.onChange(of: liveActivityFollowsSearch) { _, newValue in
// The pill mirrors the Stations-tab distance instead of its own.
FuelStore.saveLiveActivityFollowsSearch(newValue)
updateLiveActivity()
}
.onChange(of: distanceUnit) { _, _ in
// Distance unit changes the radius — mirror the new radius in the
// Live Activity immediately.
updateLiveActivity()
// Follow-search alerts also move with the unit.
if alertsFollowsSearch {
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
}
}
.onChange(of: priceDisplayStyle) { _, newValue in
// The pill carries its own display style in content state — push
// an update so the running activity re-renders in the new style
// immediately. Pass the style explicitly: SettingsView saves it
// in its own onChange, whose ordering vs this one isn't guaranteed,
// so reading storage here could push the OLD style.
updateLiveActivity(priceDisplayStyleOverride: newValue)
}
)
}
/// The one banner to show right now. Network/offline and tip outcomes all
/// funnel here and render through the SAME floating chrome. A transient
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
/// network state for its ~3.5 s.
private var activeBanner: AppBanner? {
if let outcome = tipStore.outcome { return .tip(outcome) }
switch dataStatus {
case .live: return nil
case .offlineDump(let date): return .offlineDump(date: date)
case .connectionProblem: return .connectionProblem
}
}
private var mainContent: some View {
GeometryReader { geo in
ZStack(alignment: .top) {
rootTabView
if let banner = currentBanner {
// Floating near the top of the screen, over the nav area —
// overlays content (never pushes it) and sits above the
// main content so it doesn't cover or block the list/pill
// beneath it. Opacity/offset are driven explicitly by
// reflectBanner so the fade-out reliably animates.
floatingBanner(banner)
.padding(.top, geo.safeAreaInsets.top + 10)
.opacity(bannerOpacity)
.offset(y: bannerOffset)
}
}
}
.task(id: activeBanner) {
// Fires on launch with the current banner AND whenever it changes —
// unlike onChange(computed) which can miss the first non-nil value
// when dataStatus is set slightly after the view appears.
reflectBanner(activeBanner)
}
}
/// Explicitly animates the banner in/out — a robust replacement for the
/// SwiftUI removal transition (which was silently not firing). Handles:
/// - first appearance → fade + settle down from slightly above
/// - any content change (tip→network, new tip) → fade the old out, then
/// fade the new in — so a tip ALWAYS visibly fades away
/// - dismissal → fade out + slide up, then clear after the fade
private func reflectBanner(_ newBanner: AppBanner?) {
guard newBanner != currentBanner else { return }
bannerClearTask?.cancel()
if let newBanner {
if currentBanner == nil {
present(newBanner)
} else {
// Crossfade: slide+fade the current out, then present the new.
withAnimation(.easeOut(duration: 0.2)) {
bannerOpacity = 0
bannerOffset = -28
}
bannerClearTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 250_000_000)
guard !Task.isCancelled else { return }
present(newBanner)
}
}
} else {
withAnimation(.easeOut(duration: 0.35)) {
bannerOpacity = 0
bannerOffset = -28
}
bannerClearTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled else { return }
currentBanner = nil
bannerOpacity = 0
bannerOffset = 0
}
}
}
/// Fade a banner in from slightly above and settle it into place.
private func present(_ banner: AppBanner) {
currentBanner = banner
bannerOpacity = 0
bannerOffset = -16
withAnimation(.easeOut(duration: 0.3)) {
bannerOpacity = 1
bannerOffset = 0
}
}
private var rootTabView: some View {
TabView(selection: $selectedTab) {
stationsTab
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
.tag(0)
favouritesTab
.tabItem { Label("Favourites", systemImage: "star.fill") }
.tag(1)
alertsTab
.tabItem { Label("Alerts", systemImage: "bell.fill") }
.tag(2)
settingsTab
.tag(3)
}
}
/// The banner title for the bundled-snapshot case: date when the stamp
/// parsed, plain "Offline data" otherwise.
private func offlineTitle(date: String) -> String {
date.isEmpty
? NSLocalizedString("Offline data", comment: "")
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
}
/// ONE floating banner style for all notifications. Network/offline and
/// tip outcomes share the same chrome; only the tap action differs —
/// network → retry the fetch, tip → dismiss (TipStore auto-dismisses too).
@ViewBuilder
private func floatingBanner(_ banner: AppBanner) -> some View {
switch banner {
case .offlineDump(let date):
let title = offlineTitle(date: date)
statusBannerCard(
icon: "wifi.slash", tint: .orange, title: title,
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
trailingIcon: "arrow.clockwise",
accessibilityLabel: date.isEmpty
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
) {
Task { await refresh(force: true) }
}
case .connectionProblem:
statusBannerCard(
icon: "wifi.exclamationmark", tint: .red,
title: NSLocalizedString("Check your internet connection", comment: ""),
subtitle: NSLocalizedString("Tap to try again", comment: ""),
trailingIcon: "arrow.clockwise",
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
) {
Task { await refresh(force: true) }
}
case .tip(let outcome):
statusBannerCard(
icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
trailingIcon: "xmark", accessibilityLabel: outcome.message
) {
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
}
}
}
/// The single floating-banner chrome shared by every notification
/// (network/offline + tip). Overlaid — never in flow — so it never moves
/// the content beneath it.
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
trailingIcon: String, accessibilityLabel: String,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: trailingIcon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.secondarySystemGroupedBackground))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(tint.opacity(0.35), lineWidth: 1)
)
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
)
.padding(.horizontal, 12)
}
.contentShape(Rectangle())
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
}
/// Pushes the current best-in-radius station into the Live Activity.
/// No-op (or ends the activity) when the toggle is off or there's no
/// location/data yet. Uses the Live Activity's OWN fuel + radius (set in
/// Settings) — independent of the Stations-tab fuel/distance.
private func updateLiveActivity(priceDisplayStyleOverride: PriceDisplayStyle? = nil) {
// "Follow search" mirrors the Stations-tab distance exactly (no cap —
// the pill only displays, it doesn't geofence).
let radiusKM = liveActivityFollowsSearch
? distanceUnit.toKM(Double(stationLimit))
: distanceUnit.toKM(Double(liveActivityRadiusMiles))
LiveActivityManager.update(
stations: stations,
fuel: liveActivityFuel,
radiusKM: radiusKM,
location: location,
enabled: liveActivityEnabled,
priceDisplayStyle: priceDisplayStyleOverride
)
}
/// The Stations tab, extracted from `body` so the TabView expression stays
/// within the compiler's type-check budget.
private var stationsTab: some View {
StationsView(
stations: displayedStations,
totalCount: sortedStations.count,
isLoading: isLoading,
selectedFuel: $selectedFuel,
sortMode: $sortMode,
stationLimit: $stationLimit,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice,
topStationID: topStationID,
location: location,
favouriteIDs: favouriteIDs,
onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) }
)
}
/// The Favourites tab, extracted from `body` for the same type-check
/// budget reason.
private var favouritesTab: some View {
FavouritesView(
favourites: refreshedFavourites,
selectedFuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites,
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
onHistoryRecovered: { if dataStatus == .connectionProblem { dataStatus = .live } }
)
}
/// The Alerts tab, extracted from `body` so the TabView expression stays
/// within the compiler's type-check budget.
private var alertsTab: some View {
AlertsView(
enabled: $alertsEnabled,
radius: $alertsRadius,
fuel: $alertsFuel,
favouriteDropEnabled: $favouriteDropAlertsEnabled,
favouriteDropFuel: $favouriteDropAlertsFuel,
followsSearch: $alertsFollowsSearch,
liveActivityEnabled: $liveActivityEnabled,
liveActivityFuel: $liveActivityFuel,
liveActivityRadiusMiles: $liveActivityRadiusMiles,
liveActivityFollowsSearch: $liveActivityFollowsSearch,
stationLimit: stationLimit,
distanceUnit: distanceUnit,
monitoredCount: monitor.monitoredStationIDs.count,
lastAlert: monitor.lastAlert,
onCheckFavouriteDropNow: {
Task { await refresh(force: true) }
}
)
}
/// The Settings tab, extracted from `body` so the TabView expression stays
/// within the compiler's type-check budget.
private var settingsTab: some View {
SettingsView(
tipStore: tipStore,
distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle,
dataRefreshMode: $dataRefreshMode,
alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult,
onTestAlert: { monitor.sendTestNotification() },
onPlainTestAlert: { monitor.sendPlainTestNotification() },
onRefreshDebugStatus: { monitor.refreshDebugStatus() },
onShowOnboarding: { showOnboarding = true },
debugStatus: monitor.debugStatus,
appCheapest: appCheapestStatus,
regionError: monitor.lastRegionError,
monitoredCount: monitor.monitoredStationIDs.count,
alertLog: monitor.alertLog,
regionEventCount: monitor.regionEventCount,
debugFenceIdentifier: monitor.debugFenceIdentifier,
onDebugFence: { monitor.registerDebugFenceAroundMe() },
onClearDebugFence: { monitor.clearDebugFence() }
)
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
}
private func toggleFavourite(_ station: FuelStation, fuel: FuelType) {
let key = FavouriteEntry(station: station, fuel: fuel)
if favourites.contains(where: { $0.id == key.id }) {
favourites.removeAll { $0.id == key.id }
} else {
favourites.append(key)
}
FuelStore.saveFavourites(favourites)
WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
}
/// Drag-and-drop reorder from the Favourites tab — persists the new order
/// and refreshes every surface that consumes it (widgets + geofence
/// priority, which follows the stored order).
private func reorderFavourites(_ newOrder: [FavouriteEntry]) {
favourites = newOrder
FuelStore.saveFavourites(favourites)
WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
}
/// Fetches fresh prices, but only when the cache is stale — unless
/// `force` is true (pull-to-refresh is the manual override).
private func refresh(force: Bool = false) async {
if !force, FuelStore.isCacheFresh {
return // data already fresh — skip network entirely
}
isLoading = true
defer { isLoading = false }
do {
let previousFavouriteSnapshots = FuelStore.loadFavouriteAlertSnapshots()
let fetched = try await FuelPriceProvider.active.fetchStations(
near: location?.lat, lng: location?.lng,
fuel: selectedFuel,
radiusKM: nil // full-UK dump; device filters by miles radius
)
stations = fetched
FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh()
FuelStore.saveLastSmartProbe()
// Persist envelope metadata (source, station count, GOV.UK
// dataset update time) for the Settings → About section — the
// live chain records whichever leg served the fetch.
if let meta = LiveChainProvider.latestMeta {
FuelStore.saveRelayMeta(meta)
}
// Keep the keychain favourites fresh with the new prices — the
// widget's Favourites mode reads them from keychain (the only
// channel shared on SideStore free), so stale star-time snapshots
// would otherwise show old prices.
FuelStore.saveFavourites(refreshedFavourites)
monitor.evaluateFavouritePriceDropAlert(
favourites: refreshedFavourites,
previousSnapshots: previousFavouriteSnapshots,
monitoredFuel: favouriteDropAlertsFuel,
enabled: favouriteDropAlertsEnabled
)
WidgetCenter.shared.reloadAllTimelines()
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
// Live data restored — any status banner no longer applies.
dataStatus = .live
} catch {
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
if FuelStore.loadStations().isEmpty {
// No cached prices — last resort is the bundled REAL dump
// (stale but genuine), then the demo sample set. The banner
// labels the bundled snapshot honestly with its own date.
if let dump = BundledDumpProvider.stations {
stations = dump
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
} else {
stations = SampleFuelProvider.sampleStations
dataStatus = .live
}
} else {
// Saved prices are still on screen — but the fetch failed, so
// say so: a stale cache must not look like a live app.
stations = FuelStore.loadStations()
dataStatus = .connectionProblem
}
}
// Keep monitor geofences in sync with the freshest data.
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
// Fresh prices → refresh the Live Activity pill too.
updateLiveActivity()
}
}
// MARK: - Station row (shared by Stations + Favourites tabs)
struct StationRow: View {
let station: FuelStation
let fuel: FuelType
let location: Coordinate?
let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let baselinePrice: Double?
let isTopResult: Bool
let isFavourite: Bool
var onToggleFavourite: () -> Void = {}
private var ragColor: Color {
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
switch RAGRating.rating(price: price, cheapest: baselinePrice) {
case .green: return .green
case .amber: return .orange
case .red: return .red
}
}
private var deltaText: String? {
guard let price = station.prices[fuel], let baselinePrice else { return nil }
let delta = price - baselinePrice
if abs(delta) <= 0.05 { return NSLocalizedString("best", comment: "") }
// Signed: +X.Xp pricier than baseline, -X.Xp cheaper (Closest mode);
// in Cheapest mode the baseline is the cheapest so deltas are ≥ 0.
return String(format: "%+.1fp", delta)
}
var body: some View {
HStack(spacing: 12) {
// LEFT — round brand logo (or generic fuel pump fallback)
Group {
if let asset = station.brandImageName {
Image(asset)
.resizable()
.scaledToFit()
.padding(5)
} else {
Image(systemName: "fuelpump.fill")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.black.opacity(0.82))
}
}
.frame(width: 46, height: 46)
.background(Circle().fill(Color.white))
.clipShape(Circle())
.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
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(station.name)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
if isTopResult {
Text("TOP")
.font(.caption2.bold())
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Capsule().fill(.blue.opacity(0.15)))
.foregroundStyle(.blue)
}
}
Text("\(station.address), \(station.postcode)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
if let location {
Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// RIGHT — price in £ (no p) + difference vs cheapest
VStack(alignment: .trailing, spacing: 2) {
if let price = station.prices[fuel] {
HStack(spacing: 5) {
Circle()
.fill(ragColor)
.frame(width: 8, height: 8)
FuelStore.priceTextAttributed(price, style: priceDisplayStyle, size: 20, weight: .bold)
}
if let deltaText {
Text(deltaText)
.font(.caption2.bold().monospaced())
.foregroundStyle(ragColor)
}
}
}
// FAR RIGHT — favourite star
Button {
onToggleFavourite()
} label: {
Image(systemName: isFavourite ? "star.fill" : "star")
.foregroundStyle(isFavourite ? .yellow : .secondary)
}
.buttonStyle(.borderless)
}
.contentShape(Rectangle())
.onTapGesture {
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
}
}
}
// MARK: - Location manager
@MainActor
final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
@Published var current: Coordinate?
/// Called after every location fix (foreground AND background wake-ups).
/// The app uses it to re-register geofences around the new position —
/// SwiftUI's `.onChange` never runs in the background, so this delegate
/// hook is the only path that keeps the 18-region window following the
/// user while driving with the app suspended.
var onLocationUpdate: (() -> Void)?
private let manager = CLLocationManager()
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
manager.distanceFilter = 250 // metres — re-fires while driving
manager.pausesLocationUpdatesAutomatically = true
}
/// Continuous tracking while the app is in the foreground, so the list
/// re-fetches around the user's new position as they drive.
func startForegroundTracking() {
switch manager.authorizationStatus {
case .notDetermined:
manager.requestWhenInUseAuthorization()
manager.requestLocation()
case .authorizedWhenInUse, .authorizedAlways:
manager.startUpdatingLocation()
default:
break
}
}
/// One-shot fix (first launch / after permission grant).
func requestUpdate() {
manager.requestLocation()
}
/// Background wake-ups on significant movement. Needs Always permission;
/// called once the user enables alerts so geofences + prices follow them.
func startBackgroundTracking() {
guard manager.authorizationStatus == .authorizedAlways else { return }
manager.startMonitoringSignificantLocationChanges()
}
func stopForegroundTracking() {
// Significant-change monitoring keeps running in the background.
manager.stopUpdatingLocation()
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .authorizedAlways:
manager.startMonitoringSignificantLocationChanges()
manager.startUpdatingLocation()
case .authorizedWhenInUse:
manager.startUpdatingLocation()
default:
break
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let loc = locations.last else { return }
current = Coordinate(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude)
// Drive the widget: every significant move re-orders the widget list
// around the new position immediately, instead of waiting for the
// widget's own 5-minute timeline tick. Throttled — WidgetKit ignores
// reload spam and the relay has a rate limit, so 60 s is plenty.
let now = Date()
if now.timeIntervalSince(lastWidgetReload) >= 60 {
lastWidgetReload = now
WidgetCenter.shared.reloadAllTimelines()
}
// Re-register geofences around the new position. Also fires on
// background significant-change wake-ups, which SwiftUI onChange
// never sees — this is what keeps alerts working while driving.
onLocationUpdate?()
}
private var lastWidgetReload = Date.distantPast
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Ignore — the app still works price-sorted without location.
}
}