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
+63 -10
View File
@@ -10,6 +10,8 @@ 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
/// 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
/// / Dynamic Island. Tracks its OWN fuel + radius (below) independent of
/// the Stations tab.
@@ -18,22 +20,71 @@ struct AlertsView: View {
@Binding var liveActivityFuel: FuelType
/// Search radius (miles, 5/10/15) the Live Activity uses.
@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 monitoredCount: Int
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
/// stays km so ProximityMonitor and persistence never change. Options are
/// 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(
get: {
if followsSearch { return .followSearch }
let inUnit = distanceUnit.fromKM(radius)
return FuelStore.alertRadiusOptions
let snapped = FuelStore.alertRadiusOptions
.min(by: { abs(Double($0) - inUnit) < abs(Double($1) - inUnit) }) ?? 3
return .fixed(snapped)
},
set: { value in
radius = distanceUnit.toKM(Double(value))
set: { choice in
switch choice {
case .followSearch:
followsSearch = true
case .fixed(let value):
followsSearch = false
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)
}
}
Picker("Radius", selection: radiusOption) {
Picker("Radius", selection: alertChoice) {
Text("Follow search (\(searchInUnitLabel))").tag(AlertRadiusChoice.followSearch)
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
Text("\(value) \(distanceUnit.label)").tag(value)
Text("\\(value) \\(distanceUnit.label)").tag(AlertRadiusChoice.fixed(value))
}
}
} header: {
Text("Cheapest-station alerts")
} 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 {
@@ -91,15 +143,16 @@ struct AlertsView: View {
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
Text("\(miles) \(distanceUnit.label)").tag(miles)
Text("\\(miles) \\(distanceUnit.label)").tag(LADistanceChoice.fixed(miles))
}
}
} header: {
Text("Live Activity")
} 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")
+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()
}
+6 -1
View File
@@ -93,7 +93,12 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
// even before the view fully appears.
enabled = FuelStore.loadAlertsEnabled()
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()
favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
}