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)
}
@@ -306,3 +306,50 @@ final class AlertsFuelTests: XCTestCase {
XCTAssertEqual(FuelStore.loadAlertsFuel(), .e10)
}
}
final class AlertRadiusFollowTests: XCTestCase {
func testAlertRadiusOptionsAreMileFriendly() {
// 12 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
View File
@@ -261,6 +261,8 @@ struct FuelStore {
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
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 liveActivityKey = "fuelboard.liveActivity" // Bool Live Activity toggle
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
/// on selection). Expanded beyond the Live Activity's 5/10/15 so approach
/// alerts can trigger close (13) or wide (up to 20).
static let alertRadiusOptions = [1, 2, 3, 5, 8, 10, 15, 20]
/// on selection). Mile-friendly approach distances: 12 city, 3 town,
/// 5 default (matches the Stations-tab default), 8 motorway/long approach.
/// 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 {
if let raw = loadString(service: alertsRadiusKey), let value = Double(raw), value >= 1, value <= 33 {
return value
if let raw = loadString(service: alertsRadiusKey), let value = Double(raw), value >= 1 {
// Clamp legacy values (old options went to 20 km) to the new cap.
return min(value, alertFollowCapKM)
}
return 3.0
}
@@ -498,6 +516,16 @@ struct FuelStore {
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
static func loadLiveActivityEnabled() -> Bool {
@@ -534,6 +562,17 @@ struct FuelStore {
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
// twice a day (pull-to-refresh is the manual override).