Alerts: mile-friendly radius 1/2/3/5/8 + Follow search (capped 8 mi); Live Activity Follow search; legacy radii clamped
This commit is contained in:
@@ -10,6 +10,8 @@ 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
|
@Binding var fuel: FuelType // the fuel alerts monitor for
|
||||||
|
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
||||||
|
@Binding var followsSearch: Bool
|
||||||
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
|
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
|
||||||
/// / Dynamic Island. Tracks its OWN fuel + radius (below) — independent of
|
/// / Dynamic Island. Tracks its OWN fuel + radius (below) — independent of
|
||||||
/// the Stations tab.
|
/// the Stations tab.
|
||||||
@@ -18,23 +20,72 @@ struct AlertsView: View {
|
|||||||
@Binding var liveActivityFuel: FuelType
|
@Binding var liveActivityFuel: FuelType
|
||||||
/// Search radius (miles, 5/10/15) the Live Activity uses.
|
/// Search radius (miles, 5/10/15) the Live Activity uses.
|
||||||
@Binding var liveActivityRadiusMiles: Int
|
@Binding var liveActivityRadiusMiles: Int
|
||||||
|
/// Whether the Live Activity mirrors the Stations-tab search distance.
|
||||||
|
@Binding var liveActivityFollowsSearch: Bool
|
||||||
|
/// Current Stations-tab search distance (miles) — the value "Follow search"
|
||||||
|
/// mirrors.
|
||||||
|
let stationLimit: Int
|
||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
let monitoredCount: Int
|
let monitoredCount: Int
|
||||||
let lastAlert: String?
|
let lastAlert: String?
|
||||||
|
|
||||||
|
/// The Stations-tab search distance shown in the user's unit, e.g.
|
||||||
|
/// "5 miles" or "8 km" — for the "Follow search" rows.
|
||||||
|
private var searchInUnitLabel: String {
|
||||||
|
let km = Double(stationLimit) * 1.60934 // stationLimit is always miles
|
||||||
|
return "\(Int(distanceUnit.fromKM(km).rounded())) \(distanceUnit.label)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AlertRadiusChoice: Hashable {
|
||||||
|
case followSearch
|
||||||
|
case fixed(Int)
|
||||||
|
}
|
||||||
|
|
||||||
/// The radius picker works in the user's chosen unit; the stored value
|
/// The radius picker works in the user's chosen unit; the stored value
|
||||||
/// stays km so ProximityMonitor and persistence never change. Options are
|
/// stays km so ProximityMonitor and persistence never change. Options are
|
||||||
/// snapped to the nearest picker value so any stored radius still selects.
|
/// snapped to the nearest picker value so any stored radius still selects.
|
||||||
private var radiusOption: Binding<Int> {
|
/// "Follow search" mirrors the Stations-tab distance (capped at 8 miles).
|
||||||
|
private var alertChoice: Binding<AlertRadiusChoice> {
|
||||||
Binding(
|
Binding(
|
||||||
get: {
|
get: {
|
||||||
|
if followsSearch { return .followSearch }
|
||||||
let inUnit = distanceUnit.fromKM(radius)
|
let inUnit = distanceUnit.fromKM(radius)
|
||||||
return FuelStore.alertRadiusOptions
|
let snapped = FuelStore.alertRadiusOptions
|
||||||
.min(by: { abs(Double($0) - inUnit) < abs(Double($1) - inUnit) }) ?? 3
|
.min(by: { abs(Double($0) - inUnit) < abs(Double($1) - inUnit) }) ?? 3
|
||||||
|
return .fixed(snapped)
|
||||||
},
|
},
|
||||||
set: { value in
|
set: { choice in
|
||||||
|
switch choice {
|
||||||
|
case .followSearch:
|
||||||
|
followsSearch = true
|
||||||
|
case .fixed(let value):
|
||||||
|
followsSearch = false
|
||||||
radius = distanceUnit.toKM(Double(value))
|
radius = distanceUnit.toKM(Double(value))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum LADistanceChoice: Hashable {
|
||||||
|
case followSearch
|
||||||
|
case fixed(Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var laChoice: Binding<LADistanceChoice> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
if liveActivityFollowsSearch { return .followSearch }
|
||||||
|
return .fixed(liveActivityRadiusMiles)
|
||||||
|
},
|
||||||
|
set: { choice in
|
||||||
|
switch choice {
|
||||||
|
case .followSearch:
|
||||||
|
liveActivityFollowsSearch = true
|
||||||
|
case .fixed(let miles):
|
||||||
|
liveActivityFollowsSearch = false
|
||||||
|
liveActivityRadiusMiles = miles
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,15 +99,16 @@ struct AlertsView: View {
|
|||||||
Text(fuel.displayName).tag(fuel)
|
Text(fuel.displayName).tag(fuel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Picker("Radius", selection: radiusOption) {
|
Picker("Radius", selection: alertChoice) {
|
||||||
|
Text("Follow search (\(searchInUnitLabel))").tag(AlertRadiusChoice.followSearch)
|
||||||
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
|
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
|
||||||
Text("\(value) \(distanceUnit.label)").tag(value)
|
Text("\\(value) \\(distanceUnit.label)").tag(AlertRadiusChoice.fixed(value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Cheapest-station alerts")
|
Text("Cheapest-station alerts")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Alerts watch for the cheapest \(fuel.displayName.lowercased()) station within the radius.")
|
Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Alerts watch for the cheapest \\(fuel.displayName.lowercased()) station within the radius. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -91,15 +143,16 @@ struct AlertsView: View {
|
|||||||
Text(fuel.displayName).tag(fuel)
|
Text(fuel.displayName).tag(fuel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Picker("Distance", selection: $liveActivityRadiusMiles) {
|
Picker("Distance", selection: laChoice) {
|
||||||
|
Text("Follow search (\(searchInUnitLabel))").tag(LADistanceChoice.followSearch)
|
||||||
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
||||||
Text("\(miles) \(distanceUnit.label)").tag(miles)
|
Text("\\(miles) \\(distanceUnit.label)").tag(LADistanceChoice.fixed(miles))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Live Activity")
|
Text("Live Activity")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab. Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.")
|
Text("Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Alerts")
|
.navigationTitle("Alerts")
|
||||||
|
|||||||
+50
-11
@@ -14,13 +14,21 @@ struct ContentView: View {
|
|||||||
@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 alertsFuel: FuelType = FuelStore.loadAlertsFuel()
|
||||||
|
@State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch()
|
||||||
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
|
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
|
||||||
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
|
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
|
||||||
@State private var liveActivityRadiusMiles: Int = FuelStore.loadLiveActivityRadiusMiles()
|
@State private var liveActivityRadiusMiles: Int = FuelStore.loadLiveActivityRadiusMiles()
|
||||||
|
@State private var liveActivityFollowsSearch: Bool = FuelStore.loadLiveActivityFollowsSearch()
|
||||||
@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
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// The radius the geofence actually uses: the manual alert radius, or —
|
||||||
|
/// when "Follow search" is on — the Stations-tab distance capped at 8 mi.
|
||||||
|
private var effectiveAlertsRadiusKM: Double {
|
||||||
|
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
||||||
|
}
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var statusMessage = ""
|
@State private var statusMessage = ""
|
||||||
@State private var showOnboarding = false
|
@State private var showOnboarding = false
|
||||||
@@ -159,9 +167,12 @@ struct ContentView: View {
|
|||||||
enabled: $alertsEnabled,
|
enabled: $alertsEnabled,
|
||||||
radius: $alertsRadius,
|
radius: $alertsRadius,
|
||||||
fuel: $alertsFuel,
|
fuel: $alertsFuel,
|
||||||
|
followsSearch: $alertsFollowsSearch,
|
||||||
liveActivityEnabled: $liveActivityEnabled,
|
liveActivityEnabled: $liveActivityEnabled,
|
||||||
liveActivityFuel: $liveActivityFuel,
|
liveActivityFuel: $liveActivityFuel,
|
||||||
liveActivityRadiusMiles: $liveActivityRadiusMiles,
|
liveActivityRadiusMiles: $liveActivityRadiusMiles,
|
||||||
|
liveActivityFollowsSearch: $liveActivityFollowsSearch,
|
||||||
|
stationLimit: stationLimit,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
monitoredCount: monitor.monitoredStationIDs.count,
|
monitoredCount: monitor.monitoredStationIDs.count,
|
||||||
lastAlert: monitor.lastAlert
|
lastAlert: monitor.lastAlert
|
||||||
@@ -193,14 +204,14 @@ struct ContentView: View {
|
|||||||
// frozen around the last foreground fix.
|
// frozen around the last foreground fix.
|
||||||
locationManager.onLocationUpdate = { [weak monitor] in
|
locationManager.onLocationUpdate = { [weak monitor] in
|
||||||
monitor?.update(stations: stations, favourites: refreshedFavourites,
|
monitor?.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
// The Live Activity follows the same wake-ups — this hook
|
// The Live Activity follows the same wake-ups — this hook
|
||||||
// fires on every fix incl. background significant-change
|
// fires on every fix incl. background significant-change
|
||||||
// wake-ups, so the Lock Screen pill stays live while driving.
|
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
}
|
}
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
monitor.setEnabled(alertsEnabled)
|
monitor.setEnabled(alertsEnabled)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
// Refresh only when the cache is stale (twice-a-day policy).
|
// Refresh only when the cache is stale (twice-a-day policy).
|
||||||
@@ -220,7 +231,7 @@ struct ContentView: View {
|
|||||||
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
Task { await refresh(force: true) }
|
Task { await refresh(force: true) }
|
||||||
}
|
}
|
||||||
@@ -234,7 +245,7 @@ struct ContentView: View {
|
|||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
}
|
}
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
// No network fetch on foreground — pull-to-refresh is the override.
|
// No network fetch on foreground — pull-to-refresh is the override.
|
||||||
} else {
|
} else {
|
||||||
@@ -249,7 +260,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: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,12 +275,18 @@ struct ContentView: View {
|
|||||||
// next render.
|
// next render.
|
||||||
FuelStore.saveStationLimit(newValue)
|
FuelStore.saveStationLimit(newValue)
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
// Follow-search surfaces mirror this distance — re-target.
|
||||||
|
if alertsFollowsSearch {
|
||||||
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
|
}
|
||||||
|
updateLiveActivity()
|
||||||
}
|
}
|
||||||
.onChange(of: alertsEnabled) { _, newValue in
|
.onChange(of: alertsEnabled) { _, newValue in
|
||||||
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: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
if newValue {
|
if newValue {
|
||||||
locationManager.startBackgroundTracking()
|
locationManager.startBackgroundTracking()
|
||||||
}
|
}
|
||||||
@@ -277,14 +294,21 @@ 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: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
}
|
}
|
||||||
.onChange(of: alertsFuel) { _, newValue in
|
.onChange(of: alertsFuel) { _, newValue in
|
||||||
// Alerts fuel is independent of the Stations-tab selection —
|
// Alerts fuel is independent of the Stations-tab selection —
|
||||||
// changing it re-targets geofences to stations selling that fuel.
|
// changing it re-targets geofences to stations selling that fuel.
|
||||||
FuelStore.saveAlertsFuel(newValue)
|
FuelStore.saveAlertsFuel(newValue)
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: newValue, radiusKM: alertsRadius)
|
fuel: newValue, radiusKM: effectiveAlertsRadiusKM)
|
||||||
|
}
|
||||||
|
.onChange(of: alertsFollowsSearch) { _, newValue in
|
||||||
|
// Follow search = the geofence mirrors the Stations-tab distance
|
||||||
|
// (capped at 8 mi); picking any fixed radius clears it.
|
||||||
|
FuelStore.saveAlertsFollowsSearch(newValue)
|
||||||
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
}
|
}
|
||||||
.onChange(of: liveActivityEnabled) { _, newValue in
|
.onChange(of: liveActivityEnabled) { _, newValue in
|
||||||
// Toggling the Live Activity on starts it with the current best
|
// Toggling the Live Activity on starts it with the current best
|
||||||
@@ -302,10 +326,20 @@ struct ContentView: View {
|
|||||||
FuelStore.saveLiveActivityRadiusMiles(newValue)
|
FuelStore.saveLiveActivityRadiusMiles(newValue)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
}
|
}
|
||||||
|
.onChange(of: liveActivityFollowsSearch) { _, newValue in
|
||||||
|
// The pill mirrors the Stations-tab distance instead of its own.
|
||||||
|
FuelStore.saveLiveActivityFollowsSearch(newValue)
|
||||||
|
updateLiveActivity()
|
||||||
|
}
|
||||||
.onChange(of: distanceUnit) { _, _ in
|
.onChange(of: distanceUnit) { _, _ in
|
||||||
// Distance unit changes the radius — mirror the new radius in the
|
// Distance unit changes the radius — mirror the new radius in the
|
||||||
// Live Activity immediately.
|
// Live Activity immediately.
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
|
// Follow-search alerts also move with the unit.
|
||||||
|
if alertsFollowsSearch {
|
||||||
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,10 +348,15 @@ struct ContentView: View {
|
|||||||
/// location/data yet. Uses the Live Activity's OWN fuel + radius (set in
|
/// location/data yet. Uses the Live Activity's OWN fuel + radius (set in
|
||||||
/// Settings) — independent of the Stations-tab fuel/distance.
|
/// Settings) — independent of the Stations-tab fuel/distance.
|
||||||
private func updateLiveActivity() {
|
private func updateLiveActivity() {
|
||||||
|
// "Follow search" mirrors the Stations-tab distance exactly (no cap —
|
||||||
|
// the pill only displays, it doesn't geofence).
|
||||||
|
let radiusKM = liveActivityFollowsSearch
|
||||||
|
? distanceUnit.toKM(Double(stationLimit))
|
||||||
|
: distanceUnit.toKM(Double(liveActivityRadiusMiles))
|
||||||
LiveActivityManager.update(
|
LiveActivityManager.update(
|
||||||
stations: stations,
|
stations: stations,
|
||||||
fuel: liveActivityFuel,
|
fuel: liveActivityFuel,
|
||||||
radiusKM: distanceUnit.toKM(Double(liveActivityRadiusMiles)),
|
radiusKM: radiusKM,
|
||||||
location: location,
|
location: location,
|
||||||
enabled: liveActivityEnabled
|
enabled: liveActivityEnabled
|
||||||
)
|
)
|
||||||
@@ -352,7 +391,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: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches fresh prices, but only when the cache is stale — unless
|
/// Fetches fresh prices, but only when the cache is stale — unless
|
||||||
@@ -390,7 +429,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: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
// Fresh prices → refresh the Live Activity pill too.
|
// Fresh prices → refresh the Live Activity pill too.
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,12 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
// even before the view fully appears.
|
// even before the view fully appears.
|
||||||
enabled = FuelStore.loadAlertsEnabled()
|
enabled = FuelStore.loadAlertsEnabled()
|
||||||
fuel = FuelStore.loadAlertsFuel()
|
fuel = FuelStore.loadAlertsFuel()
|
||||||
radiusKM = FuelStore.loadAlertsRadius()
|
// Effective radius honours "Follow search" even on a cold launch /
|
||||||
|
// background region-event wake (the raw manual radius would otherwise
|
||||||
|
// be used until the first foreground update re-targets geofences).
|
||||||
|
radiusKM = FuelStore.effectiveAlertsRadiusKM(
|
||||||
|
followsSearch: FuelStore.loadAlertsFollowsSearch(),
|
||||||
|
manualKM: 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,3 +306,50 @@ final class AlertsFuelTests: XCTestCase {
|
|||||||
XCTAssertEqual(FuelStore.loadAlertsFuel(), .e10)
|
XCTAssertEqual(FuelStore.loadAlertsFuel(), .e10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class AlertRadiusFollowTests: XCTestCase {
|
||||||
|
func testAlertRadiusOptionsAreMileFriendly() {
|
||||||
|
// 1–2 city, 3 town, 5 default, 8 motorway — the geofence ceiling.
|
||||||
|
XCTAssertEqual(FuelStore.alertRadiusOptions, [1, 2, 3, 5, 8])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLegacyRadiusClampsToCap() {
|
||||||
|
// Old options went to 20 km; stored values must not exceed the 8-mi cap.
|
||||||
|
FuelStore.saveAlertsRadius(30)
|
||||||
|
XCTAssertEqual(FuelStore.loadAlertsRadius(), FuelStore.alertFollowCapKM, accuracy: 0.001)
|
||||||
|
FuelStore.saveAlertsRadius(5) // still inside the cap — untouched
|
||||||
|
XCTAssertEqual(FuelStore.loadAlertsRadius(), 5, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFollowSearchRoundTrips() {
|
||||||
|
FuelStore.saveAlertsFollowsSearch(true)
|
||||||
|
XCTAssertTrue(FuelStore.loadAlertsFollowsSearch())
|
||||||
|
FuelStore.saveAlertsFollowsSearch(false)
|
||||||
|
XCTAssertFalse(FuelStore.loadAlertsFollowsSearch())
|
||||||
|
FuelStore.saveLiveActivityFollowsSearch(true)
|
||||||
|
XCTAssertTrue(FuelStore.loadLiveActivityFollowsSearch())
|
||||||
|
FuelStore.saveLiveActivityFollowsSearch(false)
|
||||||
|
XCTAssertFalse(FuelStore.loadLiveActivityFollowsSearch())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEffectiveRadiusManualWhenNotFollowing() {
|
||||||
|
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: false, manualKM: 3)
|
||||||
|
XCTAssertEqual(eff, 3, "manual radius passes through when not following")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEffectiveRadiusFollowsSearchCapped() {
|
||||||
|
// 15-mile search must cap at the 8-mile geofence ceiling.
|
||||||
|
FuelStore.saveDistanceUnit(.miles)
|
||||||
|
FuelStore.saveStationLimit(15)
|
||||||
|
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: true, manualKM: 3)
|
||||||
|
XCTAssertEqual(eff, FuelStore.alertFollowCapKM, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEffectiveRadiusFollowsSmallSearch() {
|
||||||
|
// 5-mile search follows exactly (8.05 km), under the cap.
|
||||||
|
FuelStore.saveDistanceUnit(.miles)
|
||||||
|
FuelStore.saveStationLimit(5)
|
||||||
|
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: true, manualKM: 3)
|
||||||
|
XCTAssertEqual(eff, 5 * 1.60934, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+44
-5
@@ -261,6 +261,8 @@ struct FuelStore {
|
|||||||
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 alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
|
||||||
|
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance
|
||||||
|
static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance
|
||||||
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
||||||
static let liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle
|
static let liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle
|
||||||
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
|
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
|
||||||
@@ -483,13 +485,29 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Alert trigger-radius options (in the user's display unit, mapped to km
|
/// Alert trigger-radius options (in the user's display unit, mapped to km
|
||||||
/// on selection). Expanded beyond the Live Activity's 5/10/15 so approach
|
/// on selection). Mile-friendly approach distances: 1–2 city, 3 town,
|
||||||
/// alerts can trigger close (1–3) or wide (up to 20).
|
/// 5 default (matches the Stations-tab default), 8 motorway/long approach.
|
||||||
static let alertRadiusOptions = [1, 2, 3, 5, 8, 10, 15, 20]
|
/// The geofence caps here — wider circles register poorly on iOS
|
||||||
|
/// (kCLErrorRegionMonitoringFailure, entry latency, battery) and the
|
||||||
|
/// "approach" signal dissolves beyond ~8 miles.
|
||||||
|
static let alertRadiusOptions = [1, 2, 3, 5, 8]
|
||||||
|
|
||||||
|
/// "Follow search" caps the geofence at 8 miles so a 10/15-mile Stations
|
||||||
|
/// search never creates huge region-monitoring circles.
|
||||||
|
static let alertFollowCapKM: Double = 8 * 1.60934
|
||||||
|
|
||||||
|
/// The effective alert radius: the manual radius, or — when alerts follow
|
||||||
|
/// the Stations-tab search — that distance (miles, converted via the
|
||||||
|
/// user's unit) capped at 8 miles.
|
||||||
|
static func effectiveAlertsRadiusKM(followsSearch: Bool, manualKM: Double) -> Double {
|
||||||
|
guard followsSearch else { return manualKM }
|
||||||
|
return min(loadDistanceUnit().toKM(Double(loadStationLimit())), alertFollowCapKM)
|
||||||
|
}
|
||||||
|
|
||||||
static func loadAlertsRadius() -> Double {
|
static func loadAlertsRadius() -> Double {
|
||||||
if let raw = loadString(service: alertsRadiusKey), let value = Double(raw), value >= 1, value <= 33 {
|
if let raw = loadString(service: alertsRadiusKey), let value = Double(raw), value >= 1 {
|
||||||
return value
|
// Clamp legacy values (old options went to 20 km) to the new cap.
|
||||||
|
return min(value, alertFollowCapKM)
|
||||||
}
|
}
|
||||||
return 3.0
|
return 3.0
|
||||||
}
|
}
|
||||||
@@ -498,6 +516,16 @@ struct FuelStore {
|
|||||||
saveString(String(radius), service: alertsRadiusKey)
|
saveString(String(radius), service: alertsRadiusKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether alerts mirror the Stations-tab search distance instead of the
|
||||||
|
/// manual radius. Defaults to OFF so existing behaviour is unchanged.
|
||||||
|
static func loadAlertsFollowsSearch() -> Bool {
|
||||||
|
loadString(service: alertsFollowSearchKey) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAlertsFollowsSearch(_ enabled: Bool) {
|
||||||
|
saveString(enabled ? "1" : "0", service: alertsFollowSearchKey)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Live Activity
|
// MARK: Live Activity
|
||||||
|
|
||||||
static func loadLiveActivityEnabled() -> Bool {
|
static func loadLiveActivityEnabled() -> Bool {
|
||||||
@@ -534,6 +562,17 @@ struct FuelStore {
|
|||||||
saveString(String(miles), service: liveActivityRadiusKey)
|
saveString(String(miles), service: liveActivityRadiusKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the Live Activity mirrors the Stations-tab search distance
|
||||||
|
/// instead of its own saved radius. Defaults to OFF so existing
|
||||||
|
/// behaviour is unchanged.
|
||||||
|
static func loadLiveActivityFollowsSearch() -> Bool {
|
||||||
|
loadString(service: liveActivityFollowSearchKey) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveLiveActivityFollowsSearch(_ enabled: Bool) {
|
||||||
|
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Refresh policy — data is cached; the app only auto-refreshes
|
// MARK: Refresh policy — data is cached; the app only auto-refreshes
|
||||||
// twice a day (pull-to-refresh is the manual override).
|
// twice a day (pull-to-refresh is the manual override).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user