import SwiftUI import StoreKit import UserNotifications import WidgetKit /// Settings tab — distance units, onboarding replay, a tip jar, and a /// test-alert button that previews what a real price notification looks like. 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 var onShowOnboarding: () -> Void = {} @StateObject private var tipStore = TipStore() @State private var showTipAlert = false @State private var tipAlertMessage = "" @State private var testAlertMessage: String? 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.") } Section { Button { sendTestAlert() } label: { Label("Test alert notification", systemImage: "bell.badge.fill") } if let testAlertMessage { Text(testAlertMessage) .font(.footnote) .foregroundStyle(.secondary) } } header: { Text("Alerts") } footer: { Text("Sends a notification exactly like a real price alert for \(alertsFuel.displayName.lowercased()) within \(distanceUnit.format(alertsRadiusKM)). Tapping it opens FuelBoard — just like a real alert.") } 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!") } if let message = tipStore.message { Section { Text(message) .font(.footnote) .foregroundStyle(.secondary) } } } .navigationTitle("Settings") .onAppear { Task { await tipStore.load() } } } } private func sendTestAlert() { UNUserNotificationCenter.current().getNotificationSettings { settings in Task { @MainActor in switch settings.authorizationStatus { case .authorized, .provisional, .ephemeral: ProximityMonitor.sendTestNotification(fuel: alertsFuel, radiusKM: alertsRadiusKM) testAlertMessage = "Test alert sent — check your notification centre." 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 { ProximityMonitor.sendTestNotification(fuel: alertsFuel, radiusKM: alertsRadiusKM) testAlertMessage = "Test alert sent — check your notification centre." } 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)" } } }