- Debug section now shows the last device fix (lat/lng to 5dp) and its age. - In-range indicator uses the SAME criteria as live alerts: stations selling the monitored fuel within the alert radius of the fix. Green dot + count when in range, red when none, grey while waiting for a fix. - Shows the cheapest in-range station (name, distance, price). - ProximityMonitor recomputes the snapshot on every location/stations change and on Debug-section appear; ContentView passes the live status. - SettingsView extracted to its own property to keep TabView body within the compiler type-check budget. 35 tests pass.
498 lines
21 KiB
Swift
498 lines
21 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 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 location: Coordinate? = {
|
|
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
|
|
return nil
|
|
}()
|
|
@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
|
|
}
|
|
|
|
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 {
|
|
StationsView(
|
|
stations: displayedStations,
|
|
totalCount: sortedStations.count,
|
|
isLoading: isLoading,
|
|
selectedFuel: $selectedFuel,
|
|
sortMode: $sortMode,
|
|
stationLimit: $stationLimit,
|
|
distanceUnit: distanceUnit,
|
|
baselinePrice: baselinePrice,
|
|
topStationID: topStationID,
|
|
location: location,
|
|
favouriteIDs: favouriteIDs,
|
|
onToggleFavourite: toggleFavourite,
|
|
onRefresh: { await refresh(force: true) }
|
|
)
|
|
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
|
|
|
FavouritesView(
|
|
favourites: refreshedFavourites,
|
|
selectedFuel: selectedFuel,
|
|
location: location,
|
|
distanceUnit: distanceUnit,
|
|
onToggleFavourite: toggleFavourite
|
|
)
|
|
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
|
|
|
AlertsView(
|
|
enabled: $alertsEnabled,
|
|
radius: $alertsRadius,
|
|
fuel: $alertsFuel,
|
|
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()
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: alertsRadius)
|
|
monitor.setEnabled(alertsEnabled)
|
|
// 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: alertsRadius)
|
|
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: alertsRadius)
|
|
// 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: alertsRadius)
|
|
}
|
|
}
|
|
.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()
|
|
}
|
|
.onChange(of: alertsEnabled) { _, newValue in
|
|
FuelStore.saveAlertsEnabled(newValue)
|
|
monitor.setEnabled(newValue)
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: alertsRadius)
|
|
if newValue {
|
|
locationManager.startBackgroundTracking()
|
|
}
|
|
}
|
|
.onChange(of: alertsRadius) { _, newValue in
|
|
FuelStore.saveAlertsRadius(newValue)
|
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
|
fuel: alertsFuel, radiusKM: alertsRadius)
|
|
}
|
|
.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: alertsRadius)
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
alertsFuel: alertsFuel,
|
|
alertsRadiusKM: alertsRadius,
|
|
testAlertResult: monitor.lastTestResult,
|
|
onTestAlert: { monitor.sendTestNotification() },
|
|
onPlainTestAlert: { monitor.sendPlainTestNotification() },
|
|
onRefreshDebugStatus: { monitor.refreshDebugStatus() },
|
|
onShowOnboarding: { showOnboarding = true },
|
|
debugStatus: monitor.debugStatus
|
|
)
|
|
.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: alertsRadius)
|
|
}
|
|
|
|
/// 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()
|
|
// 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: alertsRadius)
|
|
}
|
|
}
|
|
|
|
// MARK: - Station row (shared by Stations + Favourites tabs)
|
|
|
|
struct StationRow: View {
|
|
let station: FuelStation
|
|
let fuel: FuelType
|
|
let location: Coordinate?
|
|
let distanceUnit: DistanceUnit
|
|
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 "best" }
|
|
// 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)
|
|
Text(String(format: "£%.3f", price / 100))
|
|
.font(.title3.bold().monospaced())
|
|
.monospacedDigit()
|
|
}
|
|
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?
|
|
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)
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
// Ignore — the app still works price-sorted without location.
|
|
}
|
|
}
|