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.. oldPage else { return } if oldPage == 1 { prompter.requestLocation() } else if oldPage == 2 { prompter.requestNotifications() } } // No data-permission step: prices download from the internet (GitHub // mirror) with no permission at all — Local Network was removed from // the app when the relay left the consumer chain. .background( LinearGradient( colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() ) } // MARK: - Pages private var welcomePage: some View { VStack(spacing: 0) { Spacer() AnimatedOnboardingIcon(tint: .accentColor) { // The app icon itself, with the standard iOS rounded-corner // treatment (~22% of the size), so onboarding matches the // Home Screen. Image("AppIconArtwork") .resizable() .scaledToFill() .frame(width: 96, height: 96) .clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous)) } .padding(.bottom, 28) Text("Welcome to FuelBoard") .font(.largeTitle.bold()) .multilineTextAlignment(.center) Text("The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) .padding(.top, 12) featureRow(icon: "globe.europe.africa.fill", text: "UK-wide prices — 8,000+ stations, updated twice a day") featureRow(icon: "scope", text: "Cheapest within your chosen radius, or closest station first") featureRow(icon: "star.fill", text: "Favourites with instant price comparison") featureRow(icon: "chart.bar.fill", text: "Green/amber/red rating vs the best nearby price") featureRow(icon: "square.grid.2x2.fill", text: "Home-screen widget showing the cheapest nearby") featureRow(icon: "bell.fill", text: "Alerts when you approach the cheapest station") Spacer() } } private var locationPage: some View { VStack(spacing: 0) { Spacer() AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) { Image(systemName: "location.fill") .font(.system(size: 40, weight: .semibold)) .foregroundStyle(.blue) } .padding(.bottom, 28) Text("Prices near you") .font(.largeTitle.bold()) Text("FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) .padding(.top, 12) permissionStatusLabel( granted: prompter.locationGranted, denied: prompter.locationDenied, deniedText: "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it." ) .padding(.top, 24) Spacer() } } private var notificationsPage: some View { VStack(spacing: 0) { Spacer() AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) { Image(systemName: "bell.badge.fill") .font(.system(size: 40, weight: .semibold)) .foregroundStyle(.orange) } .padding(.bottom, 28) Text("Cheapest-station alerts") .font(.largeTitle.bold()) Text("FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) .padding(.top, 12) permissionStatusLabel( granted: prompter.notificationsGranted, denied: prompter.notificationsDenied, deniedText: "Notifications were denied — you can still use FuelBoard, just without alert banners." ) .padding(.top, 24) Spacer() } } private var dataPage: some View { VStack(spacing: 0) { Spacer() AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) { Image(systemName: "arrow.down.circle.fill") .font(.system(size: 40, weight: .semibold)) .foregroundStyle(.teal) } .padding(.bottom, 28) Text("Prices, ready when you are") .font(.largeTitle.bold()) Text("FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) .padding(.top, 12) Spacer() } } private var donePage: some View { VStack(spacing: 0) { Spacer() ZStack { Circle() .fill(LinearGradient(colors: [.green, .mint], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(width: 96, height: 96) Image(systemName: "checkmark") .font(.system(size: 44, weight: .bold)) .foregroundStyle(.white) } .padding(.bottom, 28) Text("You're all set") .font(.largeTitle.bold()) Text("Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) .padding(.top, 12) Spacer() } } // MARK: - Bottom action @ViewBuilder private var bottomAction: some View { switch page { case 0: primaryButton("Continue") { page = 1 } case 1: primaryButton( prompter.locationDenied ? "Open Settings" : (prompter.locationGranted ? "Continue" : "Allow Location Access") ) { if prompter.locationDenied { openSettings() } else { // The prompt fires on LEAVING this page (onChange(of: // page), forward advance only) — the description is on // screen until the user moves on. page = 2 } } case 2: primaryButton( prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications") ) { // Prompt fires on leaving this page (same rule as Location). page = 3 } case 3: // No permission on this page — prices need none. Straight on. primaryButton("Continue") { page = 4 } default: primaryButton("Start Using FuelBoard") { finish() } } } private func primaryButton(_ title: LocalizedStringKey, action: @escaping () -> Void) -> some View { Button(action: action) { Text(title) .font(.headline) .frame(maxWidth: .infinity) .padding(.vertical, 14) } .buttonStyle(.borderedProminent) } private func featureRow(icon: String, text: LocalizedStringKey) -> some View { HStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 20, weight: .semibold)) .foregroundStyle(Color.accentColor) .frame(width: 32) Text(text) .font(.subheadline) Spacer(minLength: 0) } .padding(.horizontal, 36) .padding(.vertical, 10) } private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: LocalizedStringKey) -> 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() } } private struct AnimatedOnboardingIcon: View { enum Motion { case gentle case locationArrow case alarmBell case downloadArrow } let tint: Color var motion: Motion = .gentle @ViewBuilder var content: Content @State private var settled = false @State private var iconRotation: Double = 0 @State private var iconOffsetY: CGFloat = 0 @State private var iconScale: CGFloat = 0.92 var body: some View { ZStack { Circle() .fill(tint.opacity(0.10)) .frame(width: 108, height: 108) .scaleEffect(settled ? 1.0 : 0.94) Circle() .stroke(tint.opacity(0.18), lineWidth: 1) .frame(width: 118, height: 118) .scaleEffect(settled ? 1.03 : 0.97) .opacity(settled ? 1.0 : 0.55) content .scaleEffect(iconScale) .rotationEffect(.degrees(iconRotation)) .offset(y: iconOffsetY) } .shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2) .animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled) .onAppear { playAnimation() } } private func playAnimation() { settled = false iconRotation = 0 iconOffsetY = 4 iconScale = 0.92 switch motion { case .gentle: withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) { settled = true iconOffsetY = 0 iconScale = 1.0 } case .locationArrow: iconRotation = -120 withAnimation(.linear(duration: 0.45)) { settled = true iconRotation = 24 iconOffsetY = 0 iconScale = 1.02 } withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) { iconRotation = 0 iconScale = 1.0 } case .alarmBell: settled = true iconOffsetY = 0 iconScale = 1.0 withAnimation(.linear(duration: 0.10)) { iconRotation = -14 } withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 } withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 } withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 } withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 } case .downloadArrow: iconOffsetY = -22 withAnimation(.easeIn(duration: 0.22)) { settled = true iconScale = 1.0 iconOffsetY = 8 } withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) { iconOffsetY = 0 } } } } /// Owns the system permission requests during onboarding and publishes /// their outcomes so the pages can reflect them live. @MainActor final class OnboardingPermissionPrompter: NSObject, ObservableObject, 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: do { let granted = try await UNUserNotificationCenter.current() .requestAuthorization(options: [.alert, .sound, .badge]) self.notificationsGranted = granted self.notificationsDenied = !granted } catch { self.notificationsGranted = false self.notificationsDenied = true } case .authorized: self.notificationsGranted = true self.notificationsDenied = false default: self.notificationsGranted = false self.notificationsDenied = true } } } } /// Replay pre-flight: NO data permission exists anymore — prices download /// from the internet with no prompt, so there is nothing to preflight. /// (The LAN relay left the consumer chain; Local Network is gone.) 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 } } } } }