Settings → Debug → 'Debug fence (delivery test)': registers a 100 m
CLCircularRegion at the current fix that exists solely to prove iOS
delivers region events on this install. Entering it ticks 'Region events
this session', logs 'debug fence entered' in the Alert trace, and fires a
plain notification — zero alert gates. Cleared via button; survives the
station re-registration loop (update() now skips debug-fence regions);
cleared when alerts are disabled.
The Test alert / Plain test buttons now also write to the Alert trace
('test alert scheduled — …'), so the trace reflects every probe, not just
live geofence entries. 83 tests, Release build green.
686 lines
31 KiB
Swift
686 lines
31 KiB
Swift
import SwiftUI
|
|
import CoreLocation
|
|
import WidgetKit
|
|
|
|
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 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 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)
|
|
}
|
|
@State private var isLoading = false
|
|
@State private var statusMessage = ""
|
|
@State private var showOnboarding = false
|
|
@State private var locationManager = LocationManager()
|
|
@StateObject private var monitor = ProximityMonitor()
|
|
|
|
/// 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 {
|
|
TabView {
|
|
stationsTab
|
|
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
|
|
|
favouritesTab
|
|
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
|
|
|
AlertsView(
|
|
enabled: $alertsEnabled,
|
|
radius: $alertsRadius,
|
|
fuel: $alertsFuel,
|
|
followsSearch: $alertsFollowsSearch,
|
|
liveActivityEnabled: $liveActivityEnabled,
|
|
liveActivityFuel: $liveActivityFuel,
|
|
liveActivityRadiusMiles: $liveActivityRadiusMiles,
|
|
liveActivityFollowsSearch: $liveActivityFollowsSearch,
|
|
stationLimit: stationLimit,
|
|
distanceUnit: distanceUnit,
|
|
monitoredCount: monitor.monitoredStationIDs.count,
|
|
lastAlert: monitor.lastAlert
|
|
)
|
|
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
|
|
|
settingsTab
|
|
}
|
|
.fullScreenCover(isPresented: $showOnboarding) {
|
|
OnboardingView {
|
|
showOnboarding = false
|
|
}
|
|
}
|
|
.sheet(item: $monitor.pendingStationMap) { request in
|
|
StationMapView(request: request)
|
|
}
|
|
.onAppear {
|
|
// 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.
|
|
if FuelStore.loadHasCompletedOnboarding() {
|
|
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()
|
|
}
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
|
monitor.setEnabled(alertsEnabled)
|
|
updateLiveActivity()
|
|
// Refresh only when the cache is stale (twice-a-day policy).
|
|
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()
|
|
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()
|
|
// 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()
|
|
}
|
|
}
|
|
.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)
|
|
if newValue {
|
|
locationManager.startBackgroundTracking()
|
|
}
|
|
}
|
|
.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: liveActivityEnabled) { _, newValue in
|
|
// Toggling the Live Activity on starts it with the current best
|
|
// station; toggling off ends any running activity.
|
|
FuelStore.saveLiveActivityEnabled(newValue)
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
)
|
|
}
|
|
|
|
/// The Settings tab, extracted from `body` so the TabView expression stays
|
|
/// within the compiler's type-check budget.
|
|
private var settingsTab: some View {
|
|
SettingsView(
|
|
distanceUnit: $distanceUnit,
|
|
priceDisplayStyle: $priceDisplayStyle,
|
|
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 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()
|
|
// Persist relay envelope metadata (source, station count, GOV.UK
|
|
// dataset update time) for the Settings → About section.
|
|
if let meta = RelayFuelProvider.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)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
|
} catch {
|
|
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached/sample data."
|
|
stations = FuelStore.loadStations().isEmpty ? SampleFuelProvider.sampleStations : FuelStore.loadStations()
|
|
}
|
|
// 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))
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
.frame(width: 46, height: 46)
|
|
.background(Circle().fill(.white))
|
|
.clipShape(Circle())
|
|
.overlay(Circle().stroke(Color.primary.opacity(0.08), 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(station.distanceKM(to: location.lat, lng2: 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.
|
|
}
|
|
}
|