diff --git a/FuelBoard/AlertsView.swift b/FuelBoard/AlertsView.swift index a492745..1291fa4 100644 --- a/FuelBoard/AlertsView.swift +++ b/FuelBoard/AlertsView.swift @@ -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 { + /// "Follow search" mirrors the Stations-tab distance (capped at 8 miles). + private var alertChoice: Binding { 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 { + 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") diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 524cab8..af9b442 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -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() } diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index 37ff638..b78cf46 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -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) } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 38e9a25..1fdac14 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -306,3 +306,50 @@ final class AlertsFuelTests: XCTestCase { 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) + } +} diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 72ba9e4..79e5b8d 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -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 (1–3) or wide (up to 20). - static let alertRadiusOptions = [1, 2, 3, 5, 8, 10, 15, 20] + /// on selection). Mile-friendly approach distances: 1–2 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).