diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 0a95cf0..859bd11 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -144,16 +144,7 @@ struct ContentView: View { ) .tabItem { Label("Alerts", systemImage: "bell.fill") } - SettingsView( - distanceUnit: $distanceUnit, - alertsFuel: alertsFuel, - alertsRadiusKM: alertsRadius, - testAlertResult: monitor.lastTestResult, - onTestAlert: { monitor.sendTestNotification() }, - onPlainTestAlert: { monitor.sendPlainTestNotification() }, - onShowOnboarding: { showOnboarding = true } - ) - .tabItem { Label("Settings", systemImage: "gearshape.fill") } + settingsTab } .fullScreenCover(isPresented: $showOnboarding) { OnboardingView { @@ -255,6 +246,23 @@ struct ContentView: View { } } + /// The Settings tab, extracted from `body` so the TabView expression stays + /// within the compiler's type-check budget. + private var settingsTab: some View { + SettingsView( + distanceUnit: $distanceUnit, + alertsFuel: alertsFuel, + alertsRadiusKM: alertsRadius, + testAlertResult: monitor.lastTestResult, + onTestAlert: { monitor.sendTestNotification() }, + onPlainTestAlert: { monitor.sendPlainTestNotification() }, + onRefreshDebugStatus: { monitor.refreshDebugStatus() }, + onShowOnboarding: { showOnboarding = true }, + debugStatus: monitor.debugStatus + ) + .tabItem { Label("Settings", systemImage: "gearshape.fill") } + } + private func toggleFavourite(_ station: FuelStation, fuel: FuelType) { let key = FavouriteEntry(station: station, fuel: fuel) if favourites.contains(where: { $0.id == key.id }) { diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index c298ff7..859eed7 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -4,6 +4,20 @@ import SwiftUI import MapKit import UIKit +/// Debug-only location snapshot shown in Settings → Debug. Lets the +/// developer verify location services while testing: the last fix (with age) +/// plus how many stations selling the monitored fuel are within the alert +/// radius of that fix — the exact criteria live alerts use. +struct DebugLocationStatus: Equatable { + var coordinate: Coordinate? + var fixAge: TimeInterval? + var fuel: FuelType + var radiusKM: Double + var inRangeCount: Int + var cheapestInRange: FuelStation? + var cheapestDistanceKM: Double? +} + /// A station the user wants to see on a map — set when a notification tap /// can't hand off to Apple Maps (e.g. inside LiveContainer), so the app shows /// an in-app map with directions instead. @@ -33,6 +47,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca @Published var lastAlert: String? @Published private(set) var lastTestResult: String? @Published var pendingStationMap: StationMapRequest? + /// Debug-only snapshot for the Settings → Debug section. Recomputed on + /// every location/stations change via `refreshDebugStatus()`. + @Published private(set) var debugStatus: DebugLocationStatus? private let manager = CLLocationManager() private var stations: [FuelStation] = [] @@ -76,6 +93,8 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca self.fuel = fuel self.radiusKM = radiusKM + refreshDebugStatus() + for region in manager.monitoredRegions { manager.stopMonitoring(for: region) } @@ -128,6 +147,33 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca var isEnabled: Bool { enabled } + /// Recomputes the Settings → Debug location snapshot from current state. + /// "In range" uses the SAME criteria as live alerts: stations selling the + /// monitored fuel within the alert radius of the last known location. + func refreshDebugStatus() { + guard let fix = FuelStore.loadLocationWithDate() else { + debugStatus = DebugLocationStatus( + coordinate: nil, fixAge: nil, + fuel: fuel, radiusKM: radiusKM, + inRangeCount: 0, cheapestInRange: nil, cheapestDistanceKM: nil) + return + } + let sellers = stations.filter { $0.prices[fuel] != nil } + let inRange = sellers.filter { + $0.distanceKM(to: fix.coordinate.lat, lng2: fix.coordinate.lng) <= radiusKM + } + let cheapest = inRange.min { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) } + let cheapestDistance = cheapest.map { + $0.distanceKM(to: fix.coordinate.lat, lng2: fix.coordinate.lng) + } + debugStatus = DebugLocationStatus( + coordinate: fix.coordinate, + fixAge: Date().timeIntervalSince(fix.date), + fuel: fuel, radiusKM: radiusKM, + inRangeCount: inRange.count, + cheapestInRange: cheapest, cheapestDistanceKM: cheapestDistance) + } + private func requestPermissions() { // Region monitoring needs Always location for background delivery. switch manager.authorizationStatus { diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 2cf2745..a60f541 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -24,7 +24,11 @@ struct SettingsView: View { /// Drives the plain test (no criteria) — routed to the live monitor so it /// can carry a real station's name, price and coordinates. var onPlainTestAlert: () -> Void = {} + /// Recomputes the debug location snapshot (coordinates + in-range count). + var onRefreshDebugStatus: () -> Void = {} var onShowOnboarding: () -> Void = {} + /// Live snapshot of the device fix + stations in range (from the monitor). + var debugStatus: DebugLocationStatus? @StateObject private var tipStore = TipStore() @State private var showTipAlert = false @@ -88,11 +92,13 @@ struct SettingsView: View { } label: { Label("Send plain test notification", systemImage: "bell.slash.fill") } + debugLocationRows } header: { Text("Debug") } footer: { 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, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions.") } + .onAppear { onRefreshDebugStatus() } } Section { @@ -146,6 +152,100 @@ struct SettingsView: View { } } + /// Debug location block: device coordinates + fix age, then the in-range + /// indicator. "In range" mirrors the live alert criteria — stations selling + /// the monitored fuel within the alert radius of the last fix. + @ViewBuilder + private var debugLocationRows: some View { + Divider() + if let status = debugStatus { + HStack { + Label("Device", systemImage: "location.fill") + Spacer() + if let coord = status.coordinate { + Text(String(format: "%.5f, %.5f", coord.lat, coord.lng)) + .font(.footnote) + .monospacedDigit() + .foregroundStyle(.secondary) + } else { + Text("No fix") + .foregroundStyle(.secondary) + } + } + if let age = status.fixAge { + HStack { + Label("Fix age", systemImage: "clock.fill") + Spacer() + Text(ageText(age)) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + HStack { + Label("In range", systemImage: "location.circle.fill") + Spacer() + HStack(spacing: 6) { + if let coord = status.coordinate { + Circle() + .fill(inRangeColor(status.inRangeCount)) + .frame(width: 10, height: 10) + } else { + Circle() + .fill(.gray) + .frame(width: 10, height: 10) + } + Text(inRangeText(status)) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + if let cheapest = status.cheapestInRange { + HStack { + Label("Cheapest in range", systemImage: "fuelpump.fill") + Spacer() + Text(cheapestText(cheapest, fuel: status.fuel, distance: status.cheapestDistanceKM)) + .font(.footnote) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + } else { + HStack { + Label("Device", systemImage: "location.fill") + Spacer() + Text("—") + .foregroundStyle(.secondary) + } + } + } + + private func inRangeColor(_ count: Int) -> Color { + count > 0 ? .green : .red + } + + private func inRangeText(_ status: DebugLocationStatus) -> String { + guard status.coordinate != nil else { return "waiting for fix" } + let fuelName = status.fuel.displayName.lowercased() + if status.inRangeCount == 1 { + return "1 \(fuelName) station within \(distanceUnit.format(status.radiusKM))" + } + return "\(status.inRangeCount) \(fuelName) stations within \(distanceUnit.format(status.radiusKM))" + } + + private func cheapestText(_ station: FuelStation, fuel: FuelType, distance: Double?) -> String { + let price = station.prices[fuel].map { String(format: "%.1fp", $0) } ?? "—" + if let distance { + return "\(station.name) · \(distanceUnit.format(distance)) · \(price)" + } + return "\(station.name) · \(price)" + } + + private func ageText(_ age: TimeInterval) -> String { + if age < 60 { return "\(Int(age))s ago" } + if age < 3600 { return "\(Int(age / 60))m \(Int(age.truncatingRemainder(dividingBy: 60)))s ago" } + return "\(Int(age / 3600))h \(Int((age.truncatingRemainder(dividingBy: 3600)) / 60))m ago" + } + private var appVersion: String { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 738247f..61c5107 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -326,6 +326,15 @@ struct FuelStore { return Coordinate(lat: parts[0], lng: parts[1]) } + /// Location + the timestamp it was saved, for debug display (fix age). + static func loadLocationWithDate() -> (coordinate: Coordinate, date: Date)? { + let raw = loadString(service: locationKey) + let parts = raw?.split(separator: ",").compactMap { Double($0) } + guard let parts, parts.count == 3 else { return nil } + return (Coordinate(lat: parts[0], lng: parts[1]), + Date(timeIntervalSince1970: parts[2])) + } + static func saveLocation(lat: Double, lng: Double, date: Date = Date()) { saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: locationKey) }