import SwiftUI import CoreLocation import UserNotifications import Network /// 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: page dots only — onboarding is mandatory, no skip. HStack { 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 /// Held while the Local Network permission prompt is pending — keeps the /// bare TCP connect alive until the user answers (nil after resolve). private var dataConnection: NWConnection? 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 } } } } /// Triggers the Local Network permission prompt WITHOUT loading any data. /// iOS shows the prompt on the first attempt to reach a local-network /// address, so a bare TCP connect to the relay host:port is enough — no /// HTTP request, no payload, nothing to wait on. Once the user answers /// the prompt the connect either succeeds (granted) or fails (denied); /// either way onboarding proceeds. The full dataset download happens /// AFTER onboarding completes (ContentView's forced refresh). func requestDataAccess() { guard !dataLoading else { return } dataLoading = true dataDenied = false let baseURL = RelayFuelProvider().baseURL guard let host = baseURL.host, let port = baseURL.port else { dataLoading = false dataDenied = true return } let connection = NWConnection( host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: UInt16(port))!, using: .tcp ) dataConnection = connection connection.stateUpdateHandler = { [weak self] state in Task { @MainActor in guard let self else { return } switch state { case .ready: // Connect succeeded — local network allowed. self.dataConnection = nil self.dataLoading = false self.dataGranted = true self.dataDenied = false case .failed, .cancelled: // Connect failed (denied, or relay unreachable). Either // way we do NOT dead-end: onboarding continues. self.dataConnection = nil self.dataLoading = false self.dataGranted = false self.dataDenied = true default: break // .preparing / .waiting — prompt pending, hold on } } } connection.start(queue: .main) // Safety timeout: if the prompt is ignored or the connect stalls, drop // back to the enabled Continue button instead of an endless spinner. Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 12_000_000_000) guard let self, self.dataLoading else { return } self.dataLoading = false self.dataGranted = false self.dataDenied = true self.dataConnection = nil connection.cancel() } } /// Replay pre-flight: determines the Local Network status WITHOUT any /// user-visible check. Once the permission is decided (granted or denied) /// a probe resolves silently — granted connects in milliseconds, denied /// reports `.waiting` with `unsatisfiedReason == .localNetworkDenied`. /// Only the FIRST-ever attempt (undetermined) shows the system alert; in /// that case we cancel and leave the state unknown so the data page's /// Continue triggers the prompt in context. First run never calls this. func preflightDataAccess() { guard !dataLoading, !dataGranted, !dataDenied else { return } dataLoading = true let baseURL = RelayFuelProvider().baseURL guard let host = baseURL.host, let port = baseURL.port else { dataLoading = false return } let connection = NWConnection( host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: UInt16(port))!, using: .tcp ) dataConnection = connection connection.stateUpdateHandler = { [weak self] state in Task { @MainActor in guard let self else { return } switch state { case .ready: self.dataConnection = nil self.dataLoading = false self.dataGranted = true self.dataDenied = false case .waiting: if connection.currentPath?.unsatisfiedReason == .localNetworkDenied { // Denied in Settings — resolved silently, no prompt. self.dataConnection = nil self.dataLoading = false self.dataGranted = false self.dataDenied = true } else { // Prompt pending (permission undetermined — only // possible after a reinstall, where the onboarding // flag persisted but the permission reset). Cancel so // page 4's Continue fires the prompt in context. self.dataConnection = nil self.dataLoading = false connection.cancel() } case .failed, .cancelled: self.dataConnection = nil self.dataLoading = false self.dataGranted = false self.dataDenied = true default: break // .preparing — probe starting, hold on } } } connection.start(queue: .main) } 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 } } } } }