diff --git a/FuelBoard/AlertsView.swift b/FuelBoard/AlertsView.swift index 8513471..167f58a 100644 --- a/FuelBoard/AlertsView.swift +++ b/FuelBoard/AlertsView.swift @@ -7,6 +7,7 @@ struct AlertsView: View { @Binding var radius: Double let monitoredCount: Int let lastAlert: String? + var onShowOnboarding: () -> Void = {} var body: some View { NavigationStack { @@ -54,6 +55,19 @@ struct AlertsView: View { } } } + + // Testing hook — in production onboarding shows once at first + // launch; this button re-opens it to verify the flow. + Section { + Button { + onShowOnboarding() + } label: { + Label("Show onboarding (testing)", systemImage: "flag.fill") + .font(.footnote) + } + } footer: { + Text("Testing only — onboarding normally appears once on first launch.") + } } .navigationTitle("Alerts") } diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index e19a6de..79b171f 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -18,6 +18,7 @@ struct ContentView: View { }() @State private var isLoading = false @State private var statusMessage = "" + @State private var showOnboarding = false @State private var locationManager = LocationManager() @StateObject private var monitor = ProximityMonitor() @@ -132,18 +133,37 @@ struct ContentView: View { enabled: $alertsEnabled, radius: $alertsRadius, monitoredCount: monitor.monitoredStationIDs.count, - lastAlert: monitor.lastAlert + lastAlert: monitor.lastAlert, + onShowOnboarding: { showOnboarding = true } ) .tabItem { Label("Alerts", systemImage: "bell.fill") } } + .fullScreenCover(isPresented: $showOnboarding) { + OnboardingView { + showOnboarding = false + } + } .onAppear { - locationManager.startForegroundTracking() + // Onboarding runs first on a fresh install — it owns the initial + // permission prompts. Location tracking starts once it's done. + if FuelStore.loadHasCompletedOnboarding() { + locationManager.startForegroundTracking() + } else { + showOnboarding = true + } monitor.update(stations: stations, favourites: refreshedFavourites, fuel: selectedFuel, radiusKM: alertsRadius) monitor.setEnabled(alertsEnabled) // Refresh only when the cache is stale (twice-a-day policy). Task { await refresh() } } + .onChange(of: showOnboarding) { _, showing in + // After onboarding finishes (or the test re-run is dismissed), + // begin foreground location tracking if permission allows. + if !showing, FuelStore.loadHasCompletedOnboarding() { + locationManager.startForegroundTracking() + } + } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { locationManager.startForegroundTracking() diff --git a/FuelBoard/OnboardingView.swift b/FuelBoard/OnboardingView.swift new file mode 100644 index 0000000..fcf202f --- /dev/null +++ b/FuelBoard/OnboardingView.swift @@ -0,0 +1,373 @@ +import SwiftUI +import CoreLocation +import UserNotifications + +/// First-launch onboarding: introduces FuelBoard, then walks the user through +/// the two system permissions (location + notifications) 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 = 4 + + 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 two 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 + + 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 + } + } + } + } + + 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 + } + } + } + } +} diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 74a349d..eb29f5f 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -182,6 +182,7 @@ struct FuelStore { static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km + static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) // MARK: Stations @@ -358,6 +359,18 @@ struct FuelStore { return Date().timeIntervalSince(last) < refreshInterval } + // MARK: Onboarding — the app shows the intro screen on first launch only + // (a test button in the Alerts tab re-opens it). Stored in the app group + // so the widget can see it too if ever needed. + + static func loadHasCompletedOnboarding() -> Bool { + UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false + } + + static func saveHasCompletedOnboarding(_ completed: Bool) { + UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey) + } + // MARK: Low-level keychain helpers private static func keychainData(service: String) -> Data? {