From 46d5d45c933c67151e7e7cd43db85f7f6e0f5606 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 12 Aug 2026 08:51:31 +0100 Subject: [PATCH] Alerts choose their own fuel, independent of the Stations tab The proximity monitor was tied to the Stations-tab fuel selection, so switching the list to another fuel silently re-targeted geofences. Now the Alerts tab has its own 'Fuel to monitor' picker (persisted as fuelboard.alertsFuel): - Monitor reads loadAlertsFuel() on init/background relaunch - Changing the alerts fuel re-registers geofences around stations selling that fuel only - Favourites priority slots stay scoped to the monitored fuel - Tests: alerts fuel round-trip + default (33/33 passing) --- FuelBoard/AlertsView.swift | 22 +++++++++++++++--- FuelBoard/ContentView.swift | 23 +++++++++++++------ FuelBoard/ProximityMonitor.swift | 2 +- .../FuelBoardSharedTests/FuelBoardTests.swift | 15 ++++++++++++ Shared/FuelStore.swift | 16 +++++++++++++ 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/FuelBoard/AlertsView.swift b/FuelBoard/AlertsView.swift index 01a9741..d4e0a32 100644 --- a/FuelBoard/AlertsView.swift +++ b/FuelBoard/AlertsView.swift @@ -1,10 +1,13 @@ import SwiftUI /// Alerts tab — enables the cheapest-station proximity alerts and shows what's -/// being monitored. +/// being monitored. The fuel being monitored is chosen HERE, independently of +/// the Stations-tab selection: users can browse any fuel without re-targeting +/// their alerts (and vice versa). struct AlertsView: View { @Binding var enabled: Bool @Binding var radius: Double // stored in km (monitor + storage) + @Binding var fuel: FuelType // the fuel alerts monitor for let distanceUnit: DistanceUnit let monitoredCount: Int let lastAlert: String? @@ -23,6 +26,19 @@ struct AlertsView: View { } if enabled { + Section { + Picker("Fuel", selection: $fuel) { + ForEach(FuelType.allCases) { fuel in + Text(fuel.shortName).tag(fuel) + } + } + .pickerStyle(.segmented) + } header: { + Text("Fuel to monitor") + } footer: { + Text("Alerts watch for the cheapest \(fuel.displayName.lowercased()) station within the radius. Browsing a different fuel in the Stations tab does not change this.") + } + Section("Trigger radius") { VStack(alignment: .leading, spacing: 8) { HStack { @@ -47,10 +63,10 @@ struct AlertsView: View { .foregroundStyle(.secondary) } else { LabeledContent("Geofenced stations", value: "\(monitoredCount)") - Text("Your favourites get priority, then the closest stations fill the rest (18 max, iOS region limit).") + Text("Your favourites for \(fuel.displayName.lowercased()) get priority, then the closest stations selling that fuel fill the rest (18 max, iOS region limit).") .font(.caption) .foregroundStyle(.secondary) - Text("Alerts are checked against the cheapest station within the trigger radius for the selected fuel. Each station alerts at most once per hour.") + Text("Alerts are checked against the cheapest station within the trigger radius for the fuel chosen above. Each station alerts at most once per hour.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index a3c89e2..f50a042 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -13,6 +13,7 @@ struct ContentView: View { @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 @@ -136,6 +137,7 @@ struct ContentView: View { AlertsView( enabled: $alertsEnabled, radius: $alertsRadius, + fuel: $alertsFuel, distanceUnit: distanceUnit, monitoredCount: monitor.monitoredStationIDs.count, lastAlert: monitor.lastAlert @@ -162,7 +164,7 @@ struct ContentView: View { showOnboarding = true } monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: selectedFuel, radiusKM: alertsRadius) + fuel: alertsFuel, radiusKM: alertsRadius) monitor.setEnabled(alertsEnabled) // Refresh only when the cache is stale (twice-a-day policy). Task { await refresh() } @@ -178,7 +180,7 @@ struct ContentView: View { if newPhase == .active { locationManager.startForegroundTracking() monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: selectedFuel, radiusKM: alertsRadius) + fuel: alertsFuel, radiusKM: alertsRadius) // No network fetch on foreground — pull-to-refresh is the override. } else { locationManager.stopForegroundTracking() @@ -192,7 +194,7 @@ struct ContentView: View { // 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) + fuel: alertsFuel, radiusKM: alertsRadius) } } .onChange(of: selectedFuel) { _, _ in @@ -211,7 +213,7 @@ struct ContentView: View { FuelStore.saveAlertsEnabled(newValue) monitor.setEnabled(newValue) monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: selectedFuel, radiusKM: alertsRadius) + fuel: alertsFuel, radiusKM: alertsRadius) if newValue { locationManager.startBackgroundTracking() } @@ -219,7 +221,14 @@ struct ContentView: View { .onChange(of: alertsRadius) { _, newValue in FuelStore.saveAlertsRadius(newValue) monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: selectedFuel, radiusKM: alertsRadius) + 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) } } @@ -233,7 +242,7 @@ struct ContentView: View { FuelStore.saveFavourites(favourites) WidgetCenter.shared.reloadAllTimelines() monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: selectedFuel, radiusKM: alertsRadius) + fuel: alertsFuel, radiusKM: alertsRadius) } /// Fetches fresh prices, but only when the cache is stale — unless @@ -261,7 +270,7 @@ struct ContentView: View { } // Keep monitor geofences in sync with the freshest data. monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: selectedFuel, radiusKM: alertsRadius) + fuel: alertsFuel, radiusKM: alertsRadius) } } diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index 10f1779..4c3bf61 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -28,7 +28,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca // Restore persisted state on background relaunch so region events work // even before the view fully appears. enabled = FuelStore.loadAlertsEnabled() - fuel = FuelStore.loadSelectedFuel() + fuel = FuelStore.loadAlertsFuel() radiusKM = FuelStore.loadAlertsRadius() stations = FuelStore.loadStations() favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station) diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index c22fffa..b7662ec 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -243,3 +243,18 @@ final class FavouriteRefreshTests: XCTestCase { XCTAssertTrue(diesel.id.hasPrefix("diesel|")) } } + +final class AlertsFuelTests: XCTestCase { + func testAlertsFuelRoundTrips() { + FuelStore.saveAlertsFuel(.diesel) + XCTAssertEqual(FuelStore.loadAlertsFuel(), .diesel, "alerts fuel persists independently") + } + + func testAlertsFuelDefaultsToUnleaded() { + // Fresh state (test isolation) defaults to Unleaded like the app's + // other fuel selections. + FuelStore.saveAlertsFuel(.e5) + FuelStore.saveAlertsFuel(.e10) + XCTAssertEqual(FuelStore.loadAlertsFuel(), .e10) + } +} diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index d37df42..d3efea1 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -247,6 +247,7 @@ struct FuelStore { static let favouritesKey = "fuelboard.favourites" // [FavouriteEntry] JSON static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km + static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) @@ -312,6 +313,21 @@ struct FuelStore { saveString(fuel.rawValue, service: fuelKey) } + // MARK: Alerts fuel + + /// The fuel proximity alerts monitor — independent of the Stations-tab + /// selection so users can browse any fuel without re-targeting alerts. + static func loadAlertsFuel() -> FuelType { + if let raw = loadString(service: alertsFuelKey), let fuel = FuelType(rawValue: raw) { + return fuel + } + return .e10 + } + + static func saveAlertsFuel(_ fuel: FuelType) { + saveString(fuel.rawValue, service: alertsFuelKey) + } + // MARK: Sort mode static func loadSortMode() -> SortMode {