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: "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 { 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) } } }