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)
This commit is contained in:
FuelBoard Contributor
2026-08-12 08:51:31 +01:00
parent 35e0adaf0b
commit 46d5d45c93
5 changed files with 67 additions and 11 deletions
+19 -3
View File
@@ -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)
}
+16 -7
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
@@ -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)
}
}
+16
View File
@@ -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 {