Settings Debug: device coordinates + in-range station indicator
- Debug section now shows the last device fix (lat/lng to 5dp) and its age. - In-range indicator uses the SAME criteria as live alerts: stations selling the monitored fuel within the alert radius of the fix. Green dot + count when in range, red when none, grey while waiting for a fix. - Shows the cheapest in-range station (name, distance, price). - ProximityMonitor recomputes the snapshot on every location/stations change and on Debug-section appear; ContentView passes the live status. - SettingsView extracted to its own property to keep TabView body within the compiler type-check budget. 35 tests pass.
This commit is contained in:
+18
-10
@@ -144,16 +144,7 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
||||||
|
|
||||||
SettingsView(
|
settingsTab
|
||||||
distanceUnit: $distanceUnit,
|
|
||||||
alertsFuel: alertsFuel,
|
|
||||||
alertsRadiusKM: alertsRadius,
|
|
||||||
testAlertResult: monitor.lastTestResult,
|
|
||||||
onTestAlert: { monitor.sendTestNotification() },
|
|
||||||
onPlainTestAlert: { monitor.sendPlainTestNotification() },
|
|
||||||
onShowOnboarding: { showOnboarding = true }
|
|
||||||
)
|
|
||||||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showOnboarding) {
|
.fullScreenCover(isPresented: $showOnboarding) {
|
||||||
OnboardingView {
|
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) {
|
private func toggleFavourite(_ station: FuelStation, fuel: FuelType) {
|
||||||
let key = FavouriteEntry(station: station, fuel: fuel)
|
let key = FavouriteEntry(station: station, fuel: fuel)
|
||||||
if favourites.contains(where: { $0.id == key.id }) {
|
if favourites.contains(where: { $0.id == key.id }) {
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ import SwiftUI
|
|||||||
import MapKit
|
import MapKit
|
||||||
import UIKit
|
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
|
/// 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
|
/// can't hand off to Apple Maps (e.g. inside LiveContainer), so the app shows
|
||||||
/// an in-app map with directions instead.
|
/// an in-app map with directions instead.
|
||||||
@@ -33,6 +47,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
@Published var lastAlert: String?
|
@Published var lastAlert: String?
|
||||||
@Published private(set) var lastTestResult: String?
|
@Published private(set) var lastTestResult: String?
|
||||||
@Published var pendingStationMap: StationMapRequest?
|
@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 let manager = CLLocationManager()
|
||||||
private var stations: [FuelStation] = []
|
private var stations: [FuelStation] = []
|
||||||
@@ -76,6 +93,8 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
self.fuel = fuel
|
self.fuel = fuel
|
||||||
self.radiusKM = radiusKM
|
self.radiusKM = radiusKM
|
||||||
|
|
||||||
|
refreshDebugStatus()
|
||||||
|
|
||||||
for region in manager.monitoredRegions {
|
for region in manager.monitoredRegions {
|
||||||
manager.stopMonitoring(for: region)
|
manager.stopMonitoring(for: region)
|
||||||
}
|
}
|
||||||
@@ -128,6 +147,33 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
|
|
||||||
var isEnabled: Bool { enabled }
|
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() {
|
private func requestPermissions() {
|
||||||
// Region monitoring needs Always location for background delivery.
|
// Region monitoring needs Always location for background delivery.
|
||||||
switch manager.authorizationStatus {
|
switch manager.authorizationStatus {
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ struct SettingsView: View {
|
|||||||
/// Drives the plain test (no criteria) — routed to the live monitor so it
|
/// Drives the plain test (no criteria) — routed to the live monitor so it
|
||||||
/// can carry a real station's name, price and coordinates.
|
/// can carry a real station's name, price and coordinates.
|
||||||
var onPlainTestAlert: () -> Void = {}
|
var onPlainTestAlert: () -> Void = {}
|
||||||
|
/// Recomputes the debug location snapshot (coordinates + in-range count).
|
||||||
|
var onRefreshDebugStatus: () -> Void = {}
|
||||||
var onShowOnboarding: () -> Void = {}
|
var onShowOnboarding: () -> Void = {}
|
||||||
|
/// Live snapshot of the device fix + stations in range (from the monitor).
|
||||||
|
var debugStatus: DebugLocationStatus?
|
||||||
|
|
||||||
@StateObject private var tipStore = TipStore()
|
@StateObject private var tipStore = TipStore()
|
||||||
@State private var showTipAlert = false
|
@State private var showTipAlert = false
|
||||||
@@ -88,11 +92,13 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Send plain test notification", systemImage: "bell.slash.fill")
|
Label("Send plain test notification", systemImage: "bell.slash.fill")
|
||||||
}
|
}
|
||||||
|
debugLocationRows
|
||||||
} header: {
|
} header: {
|
||||||
Text("Debug")
|
Text("Debug")
|
||||||
} footer: {
|
} 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.")
|
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 {
|
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 {
|
private var appVersion: String {
|
||||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
||||||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||||
|
|||||||
@@ -326,6 +326,15 @@ struct FuelStore {
|
|||||||
return Coordinate(lat: parts[0], lng: parts[1])
|
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()) {
|
static func saveLocation(lat: Double, lng: Double, date: Date = Date()) {
|
||||||
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: locationKey)
|
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: locationKey)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user