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:
FuelBoard Contributor
2026-08-13 15:00:53 +01:00
parent bb48995fac
commit 490c343fd5
5 changed files with 210 additions and 27 deletions
+50 -11
View File
@@ -14,13 +14,21 @@ struct ContentView: View {
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
@State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch()
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
@State private var liveActivityRadiusMiles: Int = FuelStore.loadLiveActivityRadiusMiles()
@State private var liveActivityFollowsSearch: Bool = FuelStore.loadLiveActivityFollowsSearch()
@State private var location: Coordinate? = {
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
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 statusMessage = ""
@State private var showOnboarding = false
@@ -159,9 +167,12 @@ struct ContentView: View {
enabled: $alertsEnabled,
radius: $alertsRadius,
fuel: $alertsFuel,
followsSearch: $alertsFollowsSearch,
liveActivityEnabled: $liveActivityEnabled,
liveActivityFuel: $liveActivityFuel,
liveActivityRadiusMiles: $liveActivityRadiusMiles,
liveActivityFollowsSearch: $liveActivityFollowsSearch,
stationLimit: stationLimit,
distanceUnit: distanceUnit,
monitoredCount: monitor.monitoredStationIDs.count,
lastAlert: monitor.lastAlert
@@ -193,14 +204,14 @@ struct ContentView: View {
// frozen around the last foreground fix.
locationManager.onLocationUpdate = { [weak monitor] in
monitor?.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
// The Live Activity follows the same wake-ups this hook
// fires on every fix incl. background significant-change
// wake-ups, so the Lock Screen pill stays live while driving.
updateLiveActivity()
}
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
monitor.setEnabled(alertsEnabled)
updateLiveActivity()
// Refresh only when the cache is stale (twice-a-day policy).
@@ -220,7 +231,7 @@ struct ContentView: View {
if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
Task { await refresh(force: true) }
}
@@ -234,7 +245,7 @@ struct ContentView: View {
locationManager.startForegroundTracking()
}
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
// No network fetch on foreground pull-to-refresh is the override.
} else {
@@ -249,7 +260,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: alertsFuel, radiusKM: alertsRadius)
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
}
}
@@ -264,12 +275,18 @@ struct ContentView: View {
// next render.
FuelStore.saveStationLimit(newValue)
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
FuelStore.saveAlertsEnabled(newValue)
monitor.setEnabled(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
if newValue {
locationManager.startBackgroundTracking()
}
@@ -277,14 +294,21 @@ struct ContentView: View {
.onChange(of: alertsRadius) { _, newValue in
FuelStore.saveAlertsRadius(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
}
.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)
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
// Toggling the Live Activity on starts it with the current best
@@ -302,10 +326,20 @@ struct ContentView: View {
FuelStore.saveLiveActivityRadiusMiles(newValue)
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
// Distance unit changes the radius mirror the new radius in the
// Live Activity immediately.
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
/// Settings) independent of the Stations-tab fuel/distance.
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(
stations: stations,
fuel: liveActivityFuel,
radiusKM: distanceUnit.toKM(Double(liveActivityRadiusMiles)),
radiusKM: radiusKM,
location: location,
enabled: liveActivityEnabled
)
@@ -352,7 +391,7 @@ struct ContentView: View {
FuelStore.saveFavourites(favourites)
WidgetCenter.shared.reloadAllTimelines()
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
@@ -390,7 +429,7 @@ struct ContentView: View {
}
// Keep monitor geofences in sync with the freshest data.
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
// Fresh prices refresh the Live Activity pill too.
updateLiveActivity()
}