Files
fuelboard/FuelBoard/SettingsView.swift
T

532 lines
24 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. The Data source section (connection/stations/
/// freshness) is debug-only too — moved here so release builds don't expose
/// relay plumbing.
struct SettingsView: View {
@Binding var distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle
/// 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?
/// Live geofence count (monitor.monitoredStationIDs.count) — Debug-only
/// status; the user-facing Alerts tab no longer exposes fence plumbing.
var monitoredCount: Int = 0
@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
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
/// type (keychain) + the installed-widget inventory from WidgetCenter.
@State private var smallWidgetDiag: String?
@State private var mediumWidgetDiag: String?
@State private var installedWidgets: String = ""
private func refreshWidgetDiag() {
smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidget")
mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent")
WidgetCenter.shared.getCurrentConfigurations { result in
let text: String
switch result {
case .success(let widgets):
text = widgets.isEmpty
? "No widgets installed"
: widgets.map { "\($0.kind) · \($0.family)" }.joined(separator: "\n")
case .failure(let error):
text = "Error: \(error.localizedDescription)"
}
Task { @MainActor in
installedWidgets = text
}
}
}
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()
}
Picker("Price display", selection: $priceDisplayStyle) {
ForEach(PriceDisplayStyle.allCases) { style in
Text(style.displayName).tag(style)
}
}
.pickerStyle(.segmented)
.onChange(of: priceDisplayStyle) { _, newValue in
FuelStore.savePriceDisplayStyle(newValue)
WidgetCenter.shared.reloadAllTimelines()
}
} header: {
Text("Units")
} footer: {
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
}
Section {
Button {
onShowOnboarding()
} label: {
Label("Show introduction", systemImage: "arrow.clockwise.circle")
}
} 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 {
refreshWidgetDiag()
} label: {
Label("Refresh widget diagnostics", systemImage: "arrow.clockwise")
}
if let smallWidgetDiag {
Text("SMALL: \(smallWidgetDiag)")
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} else {
Text("SMALL: no beacon yet")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let mediumWidgetDiag {
Text("MEDIUM: \(mediumWidgetDiag)")
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} else {
Text("MEDIUM: no beacon yet")
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(installedWidgets.isEmpty ? "No widgets installed" : installedWidgets)
.font(.caption2)
.foregroundStyle(.secondary)
} header: {
Text("Widget Diagnostics")
} footer: {
Text("Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter.")
}
.onAppear { refreshWidgetDiag() }
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 {
if monitoredCount == 0 {
Text("No stations monitored yet — open the Stations tab to load prices first.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
LabeledContent("Geofenced stations", value: "\(monitoredCount)")
}
Text("Favourites for \(alertsFuel.displayName.lowercased()) get priority, then the closest stations selling that fuel fill the rest (18 max, iOS region limit). Each station alerts at most once per hour.")
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Geofence monitoring")
}
}
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("Version")
Spacer()
Text(appVersion)
.foregroundStyle(.secondary)
.monospacedDigit()
.contentShape(Rectangle())
.onTapGesture { handleVersionTap() }
}
} header: {
Text("About")
}
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 NSLocalizedString("waiting for fix", comment: "") }
let fuelName = status.fuel.displayName.lowercased()
if status.inRangeCount == 1 {
return String(format: NSLocalizedString("1 %@ station within %@", comment: ""), fuelName, distanceUnit.format(status.radiusKM))
}
return String(format: NSLocalizedString("%lld %@ stations within %@", comment: ""), status.inRangeCount, fuelName, 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 String(format: NSLocalizedString("%llds ago", comment: ""), Int(age)) }
if age < 3600 { return String(format: NSLocalizedString("%lldm %llds ago", comment: ""), Int(age / 60), Int(age.truncatingRemainder(dividingBy: 60))) }
return String(format: NSLocalizedString("%lldh %lldm ago", comment: ""), Int(age / 3600), Int((age.truncatingRemainder(dividingBy: 3600)) / 60))
}
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 = NSLocalizedString("Notifications are turned off for FuelBoard. Enable them in Settings → Notifications → FuelBoard, then try again.", comment: "")
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 = NSLocalizedString("Notifications weren't allowed, so no test alert was sent.", comment: "")
}
}
}
}
}
}
}
}
/// 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 = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "")
return
}
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
switch verification {
case .verified:
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
case .unverified:
message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "")
}
case .userCancelled:
message = nil // silent — the user just closed the sheet
case .pending:
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
@unknown default:
message = nil
}
} catch {
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
}
}
}