From 6f0fc43085742c6aa1fad1ca801243524254243c Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Mon, 17 Aug 2026 16:33:46 +0100 Subject: [PATCH] Add cheapest-favourite price-drop alerts --- FuelBoard/AlertsView.swift | 15 +++ FuelBoard/ContentView.swift | 186 ++++++++++++++++++++----------- FuelBoard/ProximityMonitor.swift | 66 +++++++++++ Shared/FuelStore.swift | 73 ++++++++++++ 4 files changed, 276 insertions(+), 64 deletions(-) diff --git a/FuelBoard/AlertsView.swift b/FuelBoard/AlertsView.swift index cfb1d40..5d51681 100644 --- a/FuelBoard/AlertsView.swift +++ b/FuelBoard/AlertsView.swift @@ -12,6 +12,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 + @Binding var favouriteDropEnabled: Bool + @Binding var favouriteDropFuel: FuelType /// 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 @@ -157,6 +159,19 @@ struct AlertsView: View { Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling \(fuel.displayName.lowercased()) are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.") } + Section { + Toggle("Cheapest-favourite price-drop alerts", isOn: $favouriteDropEnabled) + Picker("Fuel", selection: $favouriteDropFuel) { + ForEach(FuelType.allCases) { fuel in + Text(fuel.displayName).tag(fuel) + } + } + } header: { + Text("Favourite price-drop alerts") + } footer: { + Text("Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.") + } + if enabled, let lastAlert { Section("Last alert") { Label(lastAlert, systemImage: "bell.badge.fill") diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 5b7e010..1d6b61d 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -15,6 +15,8 @@ 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 favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled() + @State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel() @State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch() @State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled() @State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel() @@ -152,71 +154,34 @@ struct ContentView: View { } var body: some View { - VStack(spacing: 0) { - switch dataStatus { - case .offlineDump(let date): - let title = offlineTitle(date: date) - statusBanner( - icon: "wifi.slash", - tint: .orange, - title: title, - subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""), - accessibilityLabel: date.isEmpty - ? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "") - : String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date) - ) - case .connectionProblem: - statusBanner( - icon: "wifi.exclamationmark", - tint: .red, - title: NSLocalizedString("Check your internet connection", comment: ""), - subtitle: NSLocalizedString("Tap to try again", comment: ""), - accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "") - ) - case .live: - EmptyView() - } - TabView(selection: $selectedTab) { - stationsTab - .tabItem { Label("Stations", systemImage: "fuelpump.fill") } - .tag(0) + configuredContent + } - favouritesTab - .tabItem { Label("Favourites", systemImage: "star.fill") } - .tag(1) + private var configuredContent: AnyView { + interactionObservedContent + } - AlertsView( - 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 - ) - .tabItem { Label("Alerts", systemImage: "bell.fill") } - .tag(2) + private var presentedContent: AnyView { + AnyView( + mainContent + .fullScreenCover(isPresented: $showOnboarding) { + OnboardingView { + showOnboarding = false + } + } + .fullScreenCover(isPresented: $showWidgetMock) { + WidgetMockScreen() + } + .sheet(item: $monitor.pendingStationMap) { request in + StationMapView(request: request) + } + ) + } - settingsTab - .tag(3) - } - .fullScreenCover(isPresented: $showOnboarding) { - OnboardingView { - showOnboarding = false - } - } - .fullScreenCover(isPresented: $showWidgetMock) { - WidgetMockScreen() - } - .sheet(item: $monitor.pendingStationMap) { request in - StationMapView(request: request) - } - .onAppear { + private var lifecycleObservedContent: AnyView { + AnyView( + presentedContent + .onAppear { // Launch-arg hooks for the screenshot/UI-test harness (same // pattern as StationsView's `-showKeySheet`): `-tab ` // opens the given tab; `-skipOnboarding` skips onboarding @@ -379,7 +344,13 @@ struct ContentView: View { updateLiveActivity() } } - .onChange(of: selectedFuel) { _, _ in + ) + } + + private var interactionObservedContent: AnyView { + AnyView( + lifecycleObservedContent + .onChange(of: selectedFuel) { _, _ in // No re-fetch needed — one response carries E5/E10/DIESEL prices. WidgetCenter.shared.reloadAllTimelines() } @@ -425,6 +396,12 @@ struct ContentView: View { monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) } + .onChange(of: favouriteDropAlertsEnabled) { _, newValue in + FuelStore.saveFavouriteDropAlertsEnabled(newValue) + } + .onChange(of: favouriteDropAlertsFuel) { _, newValue in + FuelStore.saveFavouriteDropAlertsFuel(newValue) + } .onChange(of: liveActivityEnabled) { _, newValue in // Toggling the Live Activity on starts it with the current best // station; toggling off ends any running activity. @@ -464,10 +441,63 @@ struct ContentView: View { // so reading storage here could push the OLD style. updateLiveActivity(priceDisplayStyleOverride: newValue) } - } // VStack: status banner + TabView + ) + } + + private var mainContent: some View { + VStack(spacing: 0) { + statusBannerView + rootTabView + } .animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus) } + @ViewBuilder + private var statusBannerView: some View { + switch dataStatus { + case .offlineDump(let date): + let title = offlineTitle(date: date) + statusBanner( + icon: "wifi.slash", + tint: .orange, + title: title, + subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""), + accessibilityLabel: date.isEmpty + ? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "") + : String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date) + ) + case .connectionProblem: + statusBanner( + icon: "wifi.exclamationmark", + tint: .red, + title: NSLocalizedString("Check your internet connection", comment: ""), + subtitle: NSLocalizedString("Tap to try again", comment: ""), + accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "") + ) + case .live: + EmptyView() + } + } + + private var rootTabView: some View { + TabView(selection: $selectedTab) { + stationsTab + .tabItem { Label("Stations", systemImage: "fuelpump.fill") } + .tag(0) + + favouritesTab + .tabItem { Label("Favourites", systemImage: "star.fill") } + .tag(1) + + alertsTab + .tabItem { Label("Alerts", systemImage: "bell.fill") } + .tag(2) + + settingsTab + .tag(3) + } + } + /// The banner title for the bundled-snapshot case: date when the stamp /// parsed, plain "Offline data" otherwise. private func offlineTitle(date: String) -> String { @@ -576,6 +606,27 @@ struct ContentView: View { ) } + /// The Alerts tab, extracted from `body` so the TabView expression stays + /// within the compiler's type-check budget. + private var alertsTab: some View { + AlertsView( + enabled: $alertsEnabled, + radius: $alertsRadius, + fuel: $alertsFuel, + favouriteDropEnabled: $favouriteDropAlertsEnabled, + favouriteDropFuel: $favouriteDropAlertsFuel, + followsSearch: $alertsFollowsSearch, + liveActivityEnabled: $liveActivityEnabled, + liveActivityFuel: $liveActivityFuel, + liveActivityRadiusMiles: $liveActivityRadiusMiles, + liveActivityFollowsSearch: $liveActivityFollowsSearch, + stationLimit: stationLimit, + distanceUnit: distanceUnit, + monitoredCount: monitor.monitoredStationIDs.count, + lastAlert: monitor.lastAlert + ) + } + /// The Settings tab, extracted from `body` so the TabView expression stays /// within the compiler's type-check budget. private var settingsTab: some View { @@ -635,6 +686,7 @@ struct ContentView: View { isLoading = true defer { isLoading = false } do { + let previousFavouriteSnapshots = FuelStore.loadFavouriteAlertSnapshots() let fetched = try await FuelPriceProvider.active.fetchStations( near: location?.lat, lng: location?.lng, fuel: selectedFuel, @@ -654,6 +706,12 @@ struct ContentView: View { // channel shared on SideStore free), so stale star-time snapshots // would otherwise show old prices. FuelStore.saveFavourites(refreshedFavourites) + monitor.evaluateFavouritePriceDropAlert( + favourites: refreshedFavourites, + previousSnapshots: previousFavouriteSnapshots, + monitoredFuel: favouriteDropAlertsFuel, + enabled: favouriteDropAlertsEnabled + ) WidgetCenter.shared.reloadAllTimelines() statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))" // Live data restored — any status banner no longer applies. diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index f1a8dd2..39fa330 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -79,6 +79,7 @@ struct TieChoice { /// entering the winner's circle — not at the forecourt. @MainActor final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate { + private static let favouriteDropThresholdPence: Double = 1.0 @Published var monitoredStationIDs: [String] = [] @Published var lastAlert: String? @Published private(set) var lastTestResult: String? @@ -218,6 +219,46 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca var isEnabled: Bool { enabled } + /// Compares the latest refreshed favourites against the persisted cheapest- + /// favourite baseline for one fuel and schedules a notification when the + /// winner changes or the current winner drops by a meaningful amount. + func evaluateFavouritePriceDropAlert( + favourites: [FavouriteEntry], + previousSnapshots: [FuelType: FavouriteAlertSnapshot], + monitoredFuel: FuelType, + enabled: Bool + ) { + var snapshots = previousSnapshots + guard let current = FuelStore.cheapestFavourite(in: favourites, fuel: monitoredFuel), + let currentPrice = current.station.prices[monitoredFuel] else { + snapshots.removeValue(forKey: monitoredFuel) + FuelStore.saveFavouriteAlertSnapshots(snapshots) + return + } + + let currentSnapshot = FavouriteAlertSnapshot( + fuel: monitoredFuel, + stationID: current.station.id, + stationName: current.station.name, + price: currentPrice + ) + + defer { + snapshots[monitoredFuel] = currentSnapshot + FuelStore.saveFavouriteAlertSnapshots(snapshots) + } + + guard enabled else { return } + guard let previous = previousSnapshots[monitoredFuel] else { return } + + let winnerChanged = previous.stationID != currentSnapshot.stationID + let priceDropped = previous.stationID == currentSnapshot.stationID + && currentSnapshot.price <= previous.price - Self.favouriteDropThresholdPence + + guard winnerChanged || priceDropped else { return } + fireFavouriteDropAlert(current: current, previous: previous, price: currentPrice, winnerChanged: winnerChanged) + } + /// Records a stage in the live alert trace, newest first, capped at 8. private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) { alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0) @@ -452,6 +493,31 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away" } + private func fireFavouriteDropAlert( + current: FavouriteEntry, + previous: FavouriteAlertSnapshot, + price: Double, + winnerChanged: Bool + ) { + let content = UNMutableNotificationContent() + content.sound = .default + let fuel = current.fuel + let previousPriceText = String(format: "%.1fp", previous.price) + let currentPriceText = String(format: "%.1fp", price) + + if winnerChanged { + content.title = "New cheapest \(fuel.displayName.lowercased()) favourite" + content.body = "\(current.station.name) is now your cheapest favourite at \(currentPriceText), ahead of \(previous.stationName)." + } else { + content.title = "Cheapest favourite just dropped" + content.body = "\(current.station.name) fell from \(previousPriceText) to \(currentPriceText) for \(fuel.displayName.lowercased())." + } + + addAlertRequest(content: content, station: current.station) + logAlert(.fired, "favourite alert scheduled — \(current.station.name) · \(currentPriceText)") + lastAlert = "Favourite · \(current.station.name) · \(currentPriceText)" + } + /// Adds a notification whose tap opens directions to `station`. The /// station's coordinates ride in `userInfo` so the tap handler can route /// to Apple Maps (or an in-app fallback), and a map snapshot with a pin is diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 21ab8d7..95056c3 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -31,6 +31,16 @@ struct FavouriteEntry: Identifiable, Codable, Equatable { var id: String { "\(fuel.rawValue)|\(station.id)" } } +/// Persisted baseline for the per-fuel cheapest-favourite price-drop alert. +/// Stored separately from the favourites list so refresh-time comparison can +/// survive app relaunches without immediately alerting on first fetch. +struct FavouriteAlertSnapshot: Codable, Equatable { + let fuel: FuelType + let stationID: String + let stationName: String + let price: Double +} + enum SortMode: String, Codable, CaseIterable, Identifiable { case cheapest case closest @@ -318,6 +328,9 @@ struct FuelStore { 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 favouriteDropAlertsEnabledKey = "fuelboard.favouriteDropAlertsEnabled" // Bool + static let favouriteDropAlertsFuelKey = "fuelboard.favouriteDropAlertsFuel" // FuelType raw value + static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag static let relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback @@ -627,6 +640,24 @@ struct FuelStore { } } + /// The cheapest favourite for one fuel, preserving the user's stored order + /// on price ties. Used by the favourite price-drop alert so a tie does not + /// flap between equally-priced favourites. + static func cheapestFavourite(in favourites: [FavouriteEntry], fuel: FuelType) -> FavouriteEntry? { + var best: FavouriteEntry? + for entry in favourites where entry.fuel == fuel { + guard let price = entry.station.prices[fuel] else { continue } + guard let current = best, let currentPrice = current.station.prices[fuel] else { + best = entry + continue + } + if price < currentPrice { + best = entry + } + } + return best + } + /// Reorders ONE fuel's favourites within the global array (drag-and-drop in /// the Favourites tab). The moved fuel's block stays at its original /// position in the array; other fuels keep their relative order. The array @@ -710,6 +741,48 @@ struct FuelStore { saveString(enabled ? "1" : "0", service: alertsFollowSearchKey) } + // MARK: Favourite price-drop alerts + + static func loadFavouriteDropAlertsEnabled() -> Bool { + loadString(service: favouriteDropAlertsEnabledKey) == "1" + } + + static func saveFavouriteDropAlertsEnabled(_ enabled: Bool) { + saveString(enabled ? "1" : "0", service: favouriteDropAlertsEnabledKey) + } + + static func loadFavouriteDropAlertsFuel() -> FuelType { + if let raw = loadString(service: favouriteDropAlertsFuelKey), let fuel = FuelType(rawValue: raw) { + return fuel + } + return .e10 + } + + static func saveFavouriteDropAlertsFuel(_ fuel: FuelType) { + saveString(fuel.rawValue, service: favouriteDropAlertsFuelKey) + } + + static func loadFavouriteAlertSnapshots() -> [FuelType: FavouriteAlertSnapshot] { + if let data = keychainData(service: favouriteAlertSnapshotsKey), + let stored = try? JSONDecoder().decode([String: FavouriteAlertSnapshot].self, from: data) { + var result: [FuelType: FavouriteAlertSnapshot] = [:] + for (key, value) in stored { + if let fuel = FuelType(rawValue: key) { + result[fuel] = value + } + } + return result + } + return [:] + } + + static func saveFavouriteAlertSnapshots(_ snapshots: [FuelType: FavouriteAlertSnapshot]) { + let keyed = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.key.rawValue, $0.value) }) + if let data = try? JSONEncoder().encode(keyed) { + writeKeychain(data: data, service: favouriteAlertSnapshotsKey) + } + } + // MARK: Live Activity static func loadLiveActivityEnabled() -> Bool {