Small widgetURL and medium list Links route through StationTapAction via FuelStation.tapDestinationURL: Open map keeps the Maps URL, More info uses a fuelboard://station deep link (stationID plus coordinate/name fallback). Deep link carries fallback coords; ContentView presents the detail sheet when the station is cached, else the map sheet. Live Activity detailURL upgraded to the same rich link.
1163 lines
53 KiB
Swift
1163 lines
53 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 stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
|
@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)
|
|
}
|
|
.sheet(item: $monitor.pendingDetailStation) { station in
|
|
StationDetailView(
|
|
station: station,
|
|
location: location,
|
|
distanceUnit: distanceUnit
|
|
)
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
|
|
let info = note.userInfo ?? [:]
|
|
if let id = info["stationID"] as? String,
|
|
let station = stations.first(where: { $0.id == id }) {
|
|
monitor.pendingDetailStation = station
|
|
return
|
|
}
|
|
// Station left the cache — fall back to the map sheet so
|
|
// the tap still shows something.
|
|
if let lat = info["stationLat"] as? Double,
|
|
let lng = info["stationLng"] as? Double {
|
|
let name = info["stationName"] as? String ?? "Station"
|
|
monitor.pendingStationMap = StationMapRequest(
|
|
name: name, latitude: lat, longitude: lng
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
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)
|
|
WatchSyncManager.shared.pushSnapshot()
|
|
}
|
|
}
|
|
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()
|
|
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()
|
|
}
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
updateLiveActivity()
|
|
refreshRoadDistancesIfNeeded()
|
|
if FuelStore.hasPendingWatchRefreshRequest {
|
|
Task { await handlePendingWatchRefreshRequest() }
|
|
}
|
|
// No network fetch on foreground unless the watch explicitly requests one.
|
|
} else {
|
|
locationManager.stopForegroundTracking()
|
|
}
|
|
}
|
|
.onChange(of: locationManager.current) { _, newLocation in
|
|
if let newLocation {
|
|
location = newLocation
|
|
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
|
|
WatchSyncManager.shared.pushSnapshot()
|
|
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()
|
|
WatchSyncManager.shared.pushSnapshot()
|
|
}
|
|
.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,
|
|
stationTapAction: stationTapAction,
|
|
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,
|
|
stationTapAction: stationTapAction,
|
|
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,
|
|
stationTapAction: $stationTapAction,
|
|
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)
|
|
WatchSyncManager.shared.pushSnapshot()
|
|
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)
|
|
WatchSyncManager.shared.pushSnapshot()
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
}
|
|
|
|
private func handlePendingWatchRefreshRequest() async {
|
|
guard FuelStore.hasPendingWatchRefreshRequest else { return }
|
|
await refresh(force: true)
|
|
}
|
|
|
|
/// 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()
|
|
if force, FuelStore.hasPendingWatchRefreshRequest {
|
|
FuelStore.markWatchRefreshHandled()
|
|
}
|
|
WatchSyncManager.shared.pushSnapshot()
|
|
// 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 {
|
|
if force, FuelStore.hasPendingWatchRefreshRequest {
|
|
FuelStore.markWatchRefreshHandled()
|
|
}
|
|
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 tapAction: StationTapAction = .openMap
|
|
var onToggleFavourite: () -> Void = {}
|
|
var onShowDetails: (FuelStation) -> Void = { _ in }
|
|
|
|
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 {
|
|
switch tapAction {
|
|
case .openMap:
|
|
if let url = station.mapsDirectionsURL {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
case .showDetails:
|
|
onShowDetails(station)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
}
|
|
}
|