From 950844878c0246bc331972475d1731d2c6f280ef Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 12 Aug 2026 09:34:17 +0100 Subject: [PATCH] Test alert uses real data: actual cheapest station within radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first Settings test button no longer sends a hardcoded placeholder — it runs the same criteria as a live alert (monitored fuel, alert radius, current location) against the loaded stations and sends the REAL station name, brand and price. Result line shown under the button; graceful message when no stations or no sellers within radius. --- FuelBoard/ContentView.swift | 2 ++ FuelBoard/ProximityMonitor.swift | 53 +++++++++++++++++++++++++------- FuelBoard/SettingsView.swift | 26 +++++++++++----- 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 60ffb89..25788ae 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -148,6 +148,8 @@ struct ContentView: View { distanceUnit: $distanceUnit, alertsFuel: alertsFuel, alertsRadiusKM: alertsRadius, + testAlertResult: monitor.lastTestResult, + onTestAlert: { monitor.sendTestNotification() }, onShowOnboarding: { showOnboarding = true } ) .tabItem { Label("Settings", systemImage: "gearshape.fill") } diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index cbc5a06..2ea3b18 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -11,6 +11,7 @@ import SwiftUI final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate { @Published var monitoredStationIDs: [String] = [] @Published var lastAlert: String? + @Published private(set) var lastTestResult: String? private let manager = CLLocationManager() private var stations: [FuelStation] = [] @@ -176,19 +177,49 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(unit.format(radiusKM)) radius" } - /// Fires a sample local notification shaped exactly like a real price - /// alert, for the Settings "test" button. Uses a placeholder station so - /// the user can see the alert format without a real entry event. Tapping - /// it does what tapping any FuelBoard alert does — opens the app. - static func sendTestNotification(fuel: FuelType, radiusKM: Double) { + /// Fires a test alert built from REAL data: the cheapest station selling + /// the monitored fuel within the configured alert radius of the current + /// location — the same criteria a live geofence entry would apply, minus + /// the geofence itself. Uses the monitor's live state (fuel, radius, + /// stations) so the notification reflects the Alerts-tab configuration. + func sendTestNotification() { let unit = FuelStore.loadDistanceUnit() - let content = UNMutableNotificationContent() - content.title = "Cheapest \(fuel.displayName) nearby: Sample Filling Station" - content.body = "Sample Filling Station is the cheapest within \(unit.format(radiusKM)) at 132.9p. Tap to open." - content.sound = .default + let location = FuelStore.loadLocation() + let sellers = stations.filter { $0.prices[fuel] != nil } - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) + // Cheapest within radius, judged around the current location — + // identical to handleEntry's withinRadius logic. + let inRadius = sellers.filter { station in + guard let location else { return true } + return station.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM + } + let candidate = inRadius.min { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) } + + if let candidate, let price = candidate.prices[fuel] { + let brand = candidate.brand.isEmpty ? candidate.name : candidate.brand + let content = UNMutableNotificationContent() + content.title = "Cheapest \(fuel.displayName) nearby: \(brand)" + content.body = "\(candidate.name) is the cheapest within \(unit.format(radiusKM)) at \(String(format: "%.1fp", price)). Tap to open." + content.sound = .default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + lastTestResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · radius \(unit.format(radiusKM))" + } else { + // No real candidate (no stations loaded yet, or none sell the + // monitored fuel) — still fire so delivery is testable, but say + // why the real criteria found nothing. + let content = UNMutableNotificationContent() + content.title = "FuelBoard test alert" + content.body = sellers.isEmpty + ? "No station data loaded yet — open the Stations tab first, then retry." + : "No \(fuel.displayName.lowercased()) station within \(unit.format(radiusKM)) of you." + content.sound = .default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + lastTestResult = sellers.isEmpty + ? "No stations loaded — open the Stations tab first" + : "No \(fuel.displayName.lowercased()) seller within \(unit.format(radiusKM))" + } } /// Fires a notification with NO criteria checks at all — no geofence, no diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 353adce..625d7a5 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -3,14 +3,21 @@ import StoreKit import UserNotifications import WidgetKit -/// Settings tab — distance units, onboarding replay, a tip jar, and a -/// test-alert button that previews what a real price notification looks like. +/// Settings tab — distance units, onboarding replay, a tip jar, and +/// test-alert buttons. The first test button uses REAL data (the actual +/// cheapest station for the monitored fuel within the radius, exactly like a +/// live alert) via `onTestAlert`; the second fires with no criteria at all. struct SettingsView: View { @Binding var distanceUnit: DistanceUnit /// The fuel + radius currently configured for alerts (mirrors the Alerts /// tab) so the test notification matches what real alerts will say. var alertsFuel: FuelType = .e10 var alertsRadiusKM: Double = 3.0 + /// Result line from the last real-data test alert (what it picked). + var testAlertResult: String? + /// Drives the real-data test — ContentView routes this to the live + /// ProximityMonitor so the test uses its actual stations/fuel/radius. + var onTestAlert: () -> Void = {} var onShowOnboarding: () -> Void = {} @StateObject private var tipStore = TipStore() @@ -52,13 +59,18 @@ struct SettingsView: View { Button { sendTestAlert() } label: { - Label("Test alert notification", systemImage: "bell.badge.fill") + Label("Test alert notification (real data)", systemImage: "bell.badge.fill") } if let testAlertMessage { Text(testAlertMessage) .font(.footnote) .foregroundStyle(.secondary) } + if let testAlertResult { + Label(testAlertResult, systemImage: "checkmark.circle.fill") + .font(.footnote) + .foregroundStyle(.green) + } Button { ProximityMonitor.sendPlainTestNotification() } label: { @@ -67,7 +79,7 @@ struct SettingsView: View { } header: { Text("Alerts") } footer: { - Text("The first button sends a notification shaped like a real price alert for \(alertsFuel.displayName.lowercased()) within \(distanceUnit.format(alertsRadiusKM)). The second fires with no criteria at all — no geofence, no cheapest check — just to verify a notification appears and tapping it opens FuelBoard.") + Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name and price. The second fires with no criteria at all, just to verify a notification appears and tapping it opens FuelBoard.") } Section { @@ -110,8 +122,7 @@ struct SettingsView: View { Task { @MainActor in switch settings.authorizationStatus { case .authorized, .provisional, .ephemeral: - ProximityMonitor.sendTestNotification(fuel: alertsFuel, radiusKM: alertsRadiusKM) - testAlertMessage = "Test alert sent — check your notification centre." + onTestAlert() case .denied: testAlertMessage = "Notifications are turned off for FuelBoard. Enable them in Settings → Notifications → FuelBoard, then try again." default: @@ -119,8 +130,7 @@ struct SettingsView: View { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in Task { @MainActor in if granted { - ProximityMonitor.sendTestNotification(fuel: alertsFuel, radiusKM: alertsRadiusKM) - testAlertMessage = "Test alert sent — check your notification centre." + onTestAlert() } else { testAlertMessage = "Notifications weren't allowed, so no test alert was sent." }