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:
@@ -1,10 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Alerts tab — enables the cheapest-station proximity alerts and shows what's
|
/// 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 {
|
struct AlertsView: View {
|
||||||
@Binding var enabled: Bool
|
@Binding var enabled: Bool
|
||||||
@Binding var radius: Double // stored in km (monitor + storage)
|
@Binding var radius: Double // stored in km (monitor + storage)
|
||||||
|
@Binding var fuel: FuelType // the fuel alerts monitor for
|
||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
let monitoredCount: Int
|
let monitoredCount: Int
|
||||||
let lastAlert: String?
|
let lastAlert: String?
|
||||||
@@ -23,6 +26,19 @@ struct AlertsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
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") {
|
Section("Trigger radius") {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -47,10 +63,10 @@ struct AlertsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
LabeledContent("Geofenced stations", value: "\(monitoredCount)")
|
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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct ContentView: View {
|
|||||||
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
||||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||||
|
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
|
||||||
@State private var location: Coordinate? = {
|
@State private var location: Coordinate? = {
|
||||||
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
|
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
|
||||||
return nil
|
return nil
|
||||||
@@ -136,6 +137,7 @@ struct ContentView: View {
|
|||||||
AlertsView(
|
AlertsView(
|
||||||
enabled: $alertsEnabled,
|
enabled: $alertsEnabled,
|
||||||
radius: $alertsRadius,
|
radius: $alertsRadius,
|
||||||
|
fuel: $alertsFuel,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
monitoredCount: monitor.monitoredStationIDs.count,
|
monitoredCount: monitor.monitoredStationIDs.count,
|
||||||
lastAlert: monitor.lastAlert
|
lastAlert: monitor.lastAlert
|
||||||
@@ -162,7 +164,7 @@ struct ContentView: View {
|
|||||||
showOnboarding = true
|
showOnboarding = true
|
||||||
}
|
}
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: selectedFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: alertsRadius)
|
||||||
monitor.setEnabled(alertsEnabled)
|
monitor.setEnabled(alertsEnabled)
|
||||||
// Refresh only when the cache is stale (twice-a-day policy).
|
// Refresh only when the cache is stale (twice-a-day policy).
|
||||||
Task { await refresh() }
|
Task { await refresh() }
|
||||||
@@ -178,7 +180,7 @@ struct ContentView: View {
|
|||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
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.
|
// No network fetch on foreground — pull-to-refresh is the override.
|
||||||
} else {
|
} else {
|
||||||
locationManager.stopForegroundTracking()
|
locationManager.stopForegroundTracking()
|
||||||
@@ -192,7 +194,7 @@ struct ContentView: View {
|
|||||||
// Geofences follow the user's position, but the station list is
|
// Geofences follow the user's position, but the station list is
|
||||||
// NOT re-fetched on every movement (cached, twice-a-day policy).
|
// NOT re-fetched on every movement (cached, twice-a-day policy).
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: selectedFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: alertsRadius)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedFuel) { _, _ in
|
.onChange(of: selectedFuel) { _, _ in
|
||||||
@@ -211,7 +213,7 @@ struct ContentView: View {
|
|||||||
FuelStore.saveAlertsEnabled(newValue)
|
FuelStore.saveAlertsEnabled(newValue)
|
||||||
monitor.setEnabled(newValue)
|
monitor.setEnabled(newValue)
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: selectedFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: alertsRadius)
|
||||||
if newValue {
|
if newValue {
|
||||||
locationManager.startBackgroundTracking()
|
locationManager.startBackgroundTracking()
|
||||||
}
|
}
|
||||||
@@ -219,7 +221,14 @@ struct ContentView: View {
|
|||||||
.onChange(of: alertsRadius) { _, newValue in
|
.onChange(of: alertsRadius) { _, newValue in
|
||||||
FuelStore.saveAlertsRadius(newValue)
|
FuelStore.saveAlertsRadius(newValue)
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
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)
|
FuelStore.saveFavourites(favourites)
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
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
|
/// 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.
|
// Keep monitor geofences in sync with the freshest data.
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: selectedFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: alertsRadius)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
// Restore persisted state on background relaunch so region events work
|
// Restore persisted state on background relaunch so region events work
|
||||||
// even before the view fully appears.
|
// even before the view fully appears.
|
||||||
enabled = FuelStore.loadAlertsEnabled()
|
enabled = FuelStore.loadAlertsEnabled()
|
||||||
fuel = FuelStore.loadSelectedFuel()
|
fuel = FuelStore.loadAlertsFuel()
|
||||||
radiusKM = FuelStore.loadAlertsRadius()
|
radiusKM = FuelStore.loadAlertsRadius()
|
||||||
stations = FuelStore.loadStations()
|
stations = FuelStore.loadStations()
|
||||||
favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
|
favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
|
||||||
|
|||||||
@@ -243,3 +243,18 @@ final class FavouriteRefreshTests: XCTestCase {
|
|||||||
XCTAssertTrue(diesel.id.hasPrefix("diesel|"))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ struct FuelStore {
|
|||||||
static let favouritesKey = "fuelboard.favourites" // [FavouriteEntry] JSON
|
static let favouritesKey = "fuelboard.favourites" // [FavouriteEntry] JSON
|
||||||
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
||||||
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
||||||
|
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
|
||||||
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
||||||
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
||||||
|
|
||||||
@@ -312,6 +313,21 @@ struct FuelStore {
|
|||||||
saveString(fuel.rawValue, service: fuelKey)
|
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
|
// MARK: Sort mode
|
||||||
|
|
||||||
static func loadSortMode() -> SortMode {
|
static func loadSortMode() -> SortMode {
|
||||||
|
|||||||
Reference in New Issue
Block a user