import SwiftUI import CoreLocation import UserNotifications /// First-launch onboarding: introduces FuelBoard, then walks the user through /// the three system permissions (location, notifications, local network for /// data loading) with in-context prompts. In production it appears once at /// initial launch (driven by `FuelStore.loadHasCompletedOnboarding()`); a /// test button in the Alerts tab re-opens it anytime. struct OnboardingView: View { var onFinish: () -> Void @Environment(\.dismiss) private var dismiss @StateObject private var prompter = OnboardingPermissionPrompter() @State private var page = 0 private let totalPages = 5 var body: some View { VStack(spacing: 0) { // Top bar: skip + page dots HStack { if page < totalPages - 1 { Button("Skip") { finish() } .font(.subheadline) .foregroundStyle(.secondary) } else { Color.clear.frame(width: 40, height: 20) } Spacer() HStack(spacing: 8) { ForEach(0.. Void) -> some View { Button(action: action) { Text(title) .font(.headline) .frame(maxWidth: .infinity) .padding(.vertical, 14) } .buttonStyle(.borderedProminent) } private func featureRow(icon: String, text: String) -> some View { HStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Color.accentColor) .frame(width: 28) Text(text) .font(.subheadline) Spacer(minLength: 0) } .padding(.horizontal, 36) .padding(.vertical, 5) } private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View { Group { if granted { Label("Allowed", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } else if denied { Text(deniedText) .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) } else { Text("The system prompt will appear next.") .font(.footnote) .foregroundStyle(.secondary) } } .font(.subheadline) } private func openSettings() { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } private func finish() { FuelStore.saveHasCompletedOnboarding(true) onFinish() } } /// Owns the system permission requests during onboarding and publishes /// their outcomes so the pages can reflect them live. @MainActor final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate { @Published private(set) var locationGranted = false @Published private(set) var locationDenied = false @Published private(set) var notificationsGranted = false @Published private(set) var notificationsDenied = false @Published private(set) var dataGranted = false @Published private(set) var dataDenied = false @Published private(set) var dataLoading = false private let manager = CLLocationManager() override init() { super.init() manager.delegate = self refreshLocationStatus() refreshNotificationStatus() } func requestLocation() { switch manager.authorizationStatus { case .notDetermined: manager.requestWhenInUseAuthorization() default: refreshLocationStatus() } } func requestNotifications() { UNUserNotificationCenter.current().getNotificationSettings { settings in Task { @MainActor in switch settings.authorizationStatus { case .notDetermined: UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in Task { @MainActor in self.notificationsGranted = granted self.notificationsDenied = !granted } } case .authorized: self.notificationsGranted = true self.notificationsDenied = false default: self.notificationsGranted = false self.notificationsDenied = true } } } } /// Probes the FuelBoard Relay. The first connection to a local-network /// address is what makes iOS show the Local Network permission prompt — /// so this both triggers the prompt in context and verifies data will /// actually load. A small request (limit 1) is enough; the full download /// happens after onboarding completes. func requestDataAccess() { guard !dataLoading else { return } dataLoading = true dataDenied = false Task { var ok = false defer { dataLoading = false dataGranted = ok dataDenied = !ok } do { var components = URLComponents( url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false )! components.queryItems = [ URLQueryItem(name: "fuel", value: FuelType.e10.rawValue), URLQueryItem(name: "limit", value: "1"), ] var request = URLRequest(url: components.url!) request.timeoutInterval = 8 let (_, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw FuelProviderError.relayUnavailable } ok = true } catch { ok = false } } } nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { Task { @MainActor in self.refreshLocationStatus() } } private func refreshLocationStatus() { switch manager.authorizationStatus { case .authorizedWhenInUse, .authorizedAlways: locationGranted = true locationDenied = false case .denied, .restricted: locationGranted = false locationDenied = true default: locationGranted = false locationDenied = false } } private func refreshNotificationStatus() { UNUserNotificationCenter.current().getNotificationSettings { settings in Task { @MainActor in switch settings.authorizationStatus { case .authorized: self.notificationsGranted = true self.notificationsDenied = false case .denied, .provisional, .ephemeral: self.notificationsGranted = settings.authorizationStatus == .provisional self.notificationsDenied = !self.notificationsGranted default: break } } } } }