Widget distance menu: the Distance picker only makes sense for Cheapest ordering. It was hidden for Favourites but Closest still showed it (regressed when the per-widget Distance picker was added). parameterSummary now nests When clauses: Distance visible for Cheapest only; Closest and Favourites hide it. Notifications: three compounding defects kept real geofence alerts from ever firing while the app was suspended: 1. Always permission was never properly requested. requestPermissions fired WhenInUse and Always back-to-back; iOS ignores the second call while the first prompt is pending, leaving the app stuck on WhenInUse — and region entries are never delivered in the background. Added locationManagerDidChangeAuthorization to ProximityMonitor: escalate WhenInUse -> Always, and re-register geofences on grant (regions registered under WhenInUse-only won't deliver in the background). 2. The 18-region window was frozen in the background. Re-registration lived in SwiftUI .onChange(of: locationManager.current), which never runs while suspended. Added a delegate hook (LocationManager.onLocationUpdate) fired from didUpdateLocations on every fix including background significant-change wake-ups; the app wires it to re-register geofences around the new position. 3. Region-registration failures were invisible. monitoringDidFailFor was never implemented, so a failed startMonitoring (region budget, auth, radius) silently stopped alerts. Now surfaced as monitor.lastRegionError and shown in Settings -> Debug; cleared on the next successful registration.
443 lines
19 KiB
Swift
443 lines
19 KiB
Swift
import SwiftUI
|
|
import StoreKit
|
|
import UserNotifications
|
|
import WidgetKit
|
|
|
|
/// Settings tab — distance units, onboarding replay, a tip jar, and an About
|
|
/// section. Test-notification buttons live in a Debug section that is hidden
|
|
/// unless the developer debug flag is on (tap the About → Version row five
|
|
/// times to toggle it — no user-facing switch). 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 = {}
|
|
/// 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?
|
|
/// The app's own TOP-badge cheapest (selected fuel + stationLimit radius),
|
|
/// passed in so the Debug section mirrors the app list exactly.
|
|
var appCheapest: DebugAppCheapest?
|
|
/// Last CoreLocation region-monitoring failure (monitor.lastRegionError).
|
|
/// Shown in Debug so a silently-failed geofence registration is visible.
|
|
var regionError: String?
|
|
|
|
@StateObject private var tipStore = TipStore()
|
|
@State private var showTipAlert = false
|
|
@State private var tipAlertMessage = ""
|
|
@State private var testAlertMessage: String?
|
|
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
|
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
|
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
|
@State private var versionTapCount = 0
|
|
@State private var lastVersionTap = Date.distantPast
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
Picker("Distance", selection: $distanceUnit) {
|
|
ForEach(DistanceUnit.allCases) { unit in
|
|
Text(unit.displayName).tag(unit)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.onChange(of: distanceUnit) { _, newValue in
|
|
FuelStore.saveDistanceUnit(newValue)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
} header: {
|
|
Text("Units")
|
|
} footer: {
|
|
Text("Distances and search radii across the app, widget and alerts are shown in this unit.")
|
|
}
|
|
|
|
Section {
|
|
Button {
|
|
onShowOnboarding()
|
|
} label: {
|
|
Label("Show introduction", systemImage: "sparkles")
|
|
}
|
|
} footer: {
|
|
Text("Replay the welcome screen, including the location and notification permission prompts.")
|
|
}
|
|
|
|
if debugMode {
|
|
Section {
|
|
Button {
|
|
sendTestAlert()
|
|
} label: {
|
|
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)
|
|
}
|
|
if let regionError {
|
|
Label(regionError, systemImage: "exclamationmark.triangle.fill")
|
|
.font(.footnote)
|
|
.foregroundStyle(.red)
|
|
}
|
|
Button {
|
|
onPlainTestAlert()
|
|
} 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. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
|
|
}
|
|
.onAppear { onRefreshDebugStatus() }
|
|
}
|
|
|
|
Section {
|
|
Button {
|
|
Task { await tipStore.purchase() }
|
|
} label: {
|
|
HStack {
|
|
Label("Leave a tip", systemImage: "heart.fill")
|
|
.foregroundStyle(.pink)
|
|
Spacer()
|
|
Text(tipStore.displayPrice)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
.disabled(tipStore.purchaseInProgress)
|
|
} header: {
|
|
Text("Support FuelBoard")
|
|
} footer: {
|
|
Text("A small tip helps keep the data relay and app development going. Thank you!")
|
|
}
|
|
|
|
Section {
|
|
HStack {
|
|
Text("Connection")
|
|
Spacer()
|
|
Text(connectionText)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
HStack {
|
|
Text("Stations")
|
|
Spacer()
|
|
Text(stationCountText)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
HStack {
|
|
Text("Data updated")
|
|
Spacer()
|
|
Text(dataUpdatedText)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
} header: {
|
|
Text("Data source")
|
|
} footer: {
|
|
Text("Connection shows whether prices come from the official Fuel Finder API or the CSV mirror. Data updated is the latest price change reported by the GOV.UK server itself.")
|
|
}
|
|
|
|
Section {
|
|
HStack {
|
|
Text("Version")
|
|
Spacer()
|
|
Text(appVersion)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { handleVersionTap() }
|
|
}
|
|
} header: {
|
|
Text("About")
|
|
} footer: {
|
|
Text("FuelBoard \(appVersion)")
|
|
}
|
|
|
|
if let message = tipStore.message {
|
|
Section {
|
|
Text(message)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Settings")
|
|
.onAppear {
|
|
Task { await tipStore.load() }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Debug location block: device coordinates + fix age, then the in-range
|
|
/// indicator. "Cheapest in range" mirrors the app list (selected fuel +
|
|
/// stationLimit radius) via `appCheapest`; the "In range" count mirrors
|
|
/// the alert prediction (alert fuel + alert radius).
|
|
@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 = appCheapest {
|
|
HStack {
|
|
Label("Cheapest in range", systemImage: "fuelpump.fill")
|
|
Spacer()
|
|
Text(cheapestText(cheapest.station, fuel: cheapest.fuel, distance: cheapest.distanceKM))
|
|
.font(.footnote)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else if let cheapest = status.cheapestInRange {
|
|
// Fallback when the app view hasn't resolved yet — show the
|
|
// alert-prediction cheapest rather than nothing.
|
|
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"
|
|
return "\(version) (\(build))"
|
|
}
|
|
|
|
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
|
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
|
private var connectionText: String {
|
|
switch FuelStore.loadRelaySource()?.lowercased() {
|
|
case "api": return "API"
|
|
case "csv": return "CSV"
|
|
default: return "—"
|
|
}
|
|
}
|
|
|
|
private var stationCountText: String {
|
|
guard let count = FuelStore.loadStationCount() else { return "—" }
|
|
return count.formatted()
|
|
}
|
|
|
|
/// GOV.UK server's own dataset update timestamp (ISO 8601 from the API,
|
|
/// e.g. 2026-08-12T10:23:00.000Z). Shown as a local date/time — it is the
|
|
/// data's own freshness, not the relay's sync time.
|
|
private var dataUpdatedText: String {
|
|
guard let raw = FuelStore.loadDataUpdated(), !raw.isEmpty else { return "—" }
|
|
let iso = ISO8601DateFormatter()
|
|
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let date = iso.date(from: raw) {
|
|
return date.formatted(date: .abbreviated, time: .shortened)
|
|
}
|
|
// Some sources send "YYYY-MM-DDTHH:MM:SS" without fractional seconds
|
|
// or timezone — try the plain form before showing the raw string.
|
|
iso.formatOptions = [.withInternetDateTime]
|
|
if let date = iso.date(from: raw) {
|
|
return date.formatted(date: .abbreviated, time: .shortened)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
/// Hidden debug-mode toggle: five taps on the Version row flips the flag.
|
|
/// Nothing in the UI advertises this — the Debug section simply appears or
|
|
/// disappears. Deliberately NOT a visible switch so end users never see it.
|
|
private func handleVersionTap() {
|
|
let now = Date()
|
|
if now.timeIntervalSince(lastVersionTap) > 0.6 {
|
|
versionTapCount = 0
|
|
}
|
|
lastVersionTap = now
|
|
versionTapCount += 1
|
|
if versionTapCount >= 5 {
|
|
versionTapCount = 0
|
|
debugMode.toggle()
|
|
FuelStore.saveDebugMode(debugMode)
|
|
}
|
|
}
|
|
|
|
private func sendTestAlert() {
|
|
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
|
Task { @MainActor in
|
|
switch settings.authorizationStatus {
|
|
case .authorized, .provisional, .ephemeral:
|
|
onTestAlert()
|
|
case .denied:
|
|
testAlertMessage = "Notifications are turned off for FuelBoard. Enable them in Settings → Notifications → FuelBoard, then try again."
|
|
default:
|
|
// First time — ask, then fire if granted.
|
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
|
Task { @MainActor in
|
|
if granted {
|
|
onTestAlert()
|
|
} else {
|
|
testAlertMessage = "Notifications weren't allowed, so no test alert was sent."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Loads the £4.99 tip product and drives its purchase.
|
|
@MainActor
|
|
final class TipStore: ObservableObject {
|
|
/// Product ID for the £4.99 tip (App Store Connect — consumable).
|
|
static let productID = "com.apt.fuelboard.tip499"
|
|
|
|
@Published private(set) var product: Product?
|
|
@Published private(set) var purchaseInProgress = false
|
|
@Published private(set) var message: String?
|
|
|
|
var displayPrice: String {
|
|
product?.displayPrice ?? "£4.99"
|
|
}
|
|
|
|
func load() async {
|
|
// Refreshes product state on every visit so a newly-approved product
|
|
// (or a restored transaction) is picked up.
|
|
do {
|
|
let products = try await Product.products(for: [Self.productID])
|
|
product = products.first
|
|
} catch {
|
|
// No product yet (sideloaded build) — the button still shows the
|
|
// intended price and reports the purchase attempt gracefully.
|
|
product = nil
|
|
}
|
|
}
|
|
|
|
func purchase() async {
|
|
guard !purchaseInProgress else { return }
|
|
purchaseInProgress = true
|
|
defer { purchaseInProgress = false }
|
|
|
|
// If the product hasn't loaded (e.g. not configured in App Store
|
|
// Connect yet), still allow the attempt so the user sees a clear
|
|
// outcome rather than a dead button.
|
|
guard let product else {
|
|
message = "The tip isn't available in this build yet — check back after an App Store release."
|
|
return
|
|
}
|
|
|
|
do {
|
|
let result = try await product.purchase()
|
|
switch result {
|
|
case .success(let verification):
|
|
switch verification {
|
|
case .verified:
|
|
message = "Thank you! Your tip has been received. ⛽"
|
|
case .unverified:
|
|
message = "The purchase couldn't be verified. Please try again."
|
|
}
|
|
case .userCancelled:
|
|
message = nil // silent — the user just closed the sheet
|
|
case .pending:
|
|
message = "Your tip is pending approval. It'll finish automatically."
|
|
@unknown default:
|
|
message = nil
|
|
}
|
|
} catch {
|
|
message = "The tip couldn't be completed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|