Files
fuelboard/FuelBoard/ContentView.swift
T

393 lines
16 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 favourites: [FuelStation] = FuelStore.loadFavourites()
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@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 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 = Double(stationLimit) * 1.60934 // chosen miles → km
return selling.filter {
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
}
}
private var cheapestPrice: Double? {
poolStations.compactMap { $0.prices[selectedFuel] }.min()
}
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)
}
}
}
private var favouriteIDs: Set<String> {
Set(favourites.map(\.id))
}
private var refreshedFavourites: [FuelStation] {
FuelStore.refreshedFavourites(favourites, from: stations)
}
var body: some View {
TabView {
StationsView(
stations: displayedStations,
totalCount: sortedStations.count,
isLoading: isLoading,
selectedFuel: $selectedFuel,
sortMode: $sortMode,
stationLimit: $stationLimit,
cheapestPrice: cheapestPrice,
location: location,
favouriteIDs: favouriteIDs,
onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) }
)
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
FavouritesView(
favourites: refreshedFavourites,
selectedFuel: selectedFuel,
location: location,
favouriteIDs: favouriteIDs,
onToggleFavourite: toggleFavourite
)
.tabItem { Label("Favourites", systemImage: "star.fill") }
AlertsView(
enabled: $alertsEnabled,
radius: $alertsRadius,
monitoredCount: monitor.monitoredStationIDs.count,
lastAlert: monitor.lastAlert
)
.tabItem { Label("Alerts", systemImage: "bell.fill") }
}
.onAppear {
locationManager.startForegroundTracking()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: selectedFuel, radiusKM: alertsRadius)
monitor.setEnabled(alertsEnabled)
// Refresh only when the cache is stale (twice-a-day policy).
Task { await refresh() }
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
locationManager.startForegroundTracking()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: selectedFuel, 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: selectedFuel, 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 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: selectedFuel, radiusKM: alertsRadius)
if newValue {
locationManager.startBackgroundTracking()
}
}
.onChange(of: alertsRadius) { _, newValue in
FuelStore.saveAlertsRadius(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: selectedFuel, radiusKM: alertsRadius)
}
}
private func toggleFavourite(_ station: FuelStation) {
if favouriteIDs.contains(station.id) {
favourites.removeAll { $0.id == station.id }
} else {
favourites.append(station)
}
FuelStore.saveFavourites(favourites)
WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: selectedFuel, 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()
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: selectedFuel, radiusKM: alertsRadius)
}
}
// MARK: - Station row (shared by Stations + Favourites tabs)
struct StationRow: View {
let station: FuelStation
let fuel: FuelType
let location: Coordinate?
let cheapestPrice: Double?
let isTopResult: Bool
let isFavourite: Bool
var onToggleFavourite: () -> Void = {}
private var ragColor: Color {
guard let price = station.prices[fuel], let cheapestPrice else { return .gray }
switch RAGRating.rating(price: price, cheapest: cheapestPrice) {
case .green: return .green
case .amber: return .orange
case .red: return .red
}
}
private var deltaText: String? {
guard let price = station.prices[fuel], let cheapestPrice else { return nil }
let delta = price - cheapestPrice
if delta <= 0.05 { return "best" }
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(String(format: "%.1f mi", station.distanceKM(to: location.lat, lng2: location.lng) * 0.621371))
.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.
}
}