import SwiftUI import CoreLocation import UserNotifications import Network /// First-launch onboarding: introduces FuelBoard, walks the user through the /// two system permissions (location and notifications) with in-context prompts, /// then offers Best Value — explained, switched on or declined, and configured /// with the same sliders Settings uses. Price data downloads over HTTPS with no /// Local Network permission. In production it appears once at initial launch /// (driven by `FuelStore.loadHasCompletedOnboarding()`); the Settings tab has a /// row that re-opens it anytime. /// /// The Best Value page itself lives in `BestValueIntroContent`, because the same /// page is also shown to EXISTING users (once per announcement version, by /// `BestValueIntroView`) — one source for its copy, controls and rules, so the /// two can never drift apart. struct OnboardingView: View { var onFinish: () -> Void @Environment(\.dismiss) private var dismiss @StateObject private var prompter = OnboardingPermissionPrompter() @State private var page = OnboardingView.initialPage() /// Best Value's own copy of the stored settings. Loaded so a replay starts /// from what the user already has; the shared page view saves on every /// change — the same immediate-persist rule as Settings — so a swipe past /// the page cannot lose a choice the user has already made. @State private var fuelSavings = OnboardingView.initialFuelSavings() private static let totalPages = 6 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(String(localized: "Welcome to FuelBoard", comment: "Onboarding welcome title")) .font(.largeTitle.bold()) .multilineTextAlignment(.center) Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle")) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) .padding(.top, 12) featureRow(icon: "globe.europe.africa.fill", text: String(localized: "UK-wide prices — 8,000+ stations, updated twice a day", comment: "Onboarding feature row")) featureRow(icon: "scope", text: String(localized: "Cheapest within your chosen radius, or closest station first", comment: "Onboarding feature row")) featureRow(icon: "star.fill", text: String(localized: "Favourites with instant price comparison", comment: "Onboarding feature row")) featureRow(icon: "chart.bar.fill", text: String(localized: "Green/amber/red rating vs the best nearby price", comment: "Onboarding feature row")) featureRow(icon: "square.grid.2x2.fill", text: String(localized: "Home-screen widget showing the cheapest nearby", comment: "Onboarding feature row")) featureRow(icon: "bell.fill", text: String(localized: "Alerts when you approach the cheapest station", comment: "Onboarding feature row")) 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(String(localized: "Prices near you", comment: "Onboarding location permission title")) .font(.largeTitle.bold()) Text(String(localized: "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.", comment: "Onboarding location permission explanation")) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) .padding(.top, 12) permissionStatusLabel( granted: prompter.locationGranted, denied: prompter.locationDenied, deniedText: String(localized: "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it.", comment: "Onboarding location denied text") ) .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(String(localized: "Cheapest-station alerts", comment: "Onboarding notifications title")) .font(.largeTitle.bold()) Text(String(localized: "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.", comment: "Onboarding notifications explanation")) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) .padding(.top, 12) permissionStatusLabel( granted: prompter.notificationsGranted, denied: prompter.notificationsDenied, deniedText: String(localized: "Notifications were denied — you can still use FuelBoard, just without alert banners.", comment: "Onboarding notifications denied text") ) .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(String(localized: "Prices, ready when you are", comment: "Onboarding data title")) .font(.largeTitle.bold()) Text(String(localized: "FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.", comment: "Onboarding data explanation")) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) .padding(.top, 12) Spacer() } } /// The Best Value page — the shared body, so the first-run page and the /// announcement existing users see can never drift apart. private var bestValuePage: some View { BestValueIntroContent(settings: $fuelSavings) } 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(String(localized: "You're all set", comment: "Onboarding final title")) .font(.largeTitle.bold()) Text(String(localized: "Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.", comment: "Onboarding final explanation")) .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(String(localized: "Continue", comment: "Onboarding continue button")) { page = 1 } case 1: primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) { if prompter.locationDenied { openSettings() } else if prompter.locationGranted { page = 2 // already decided — just move on } else { // Fire the system prompt NOW, while this page is still on // screen (after it's been read) and BEFORE advancing — so // the prompt never covers the next page's animation. Auto- // advance on grant moves us on once the user responds; // denying leaves the page's Settings path. prompter.requestLocation() } } case 2: primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) { if prompter.notificationsDenied || prompter.notificationsGranted { page = 3 // decided either way — move on } else { // Same rule as Location: prompt now, before advancing. prompter.requestNotifications() } } case 3: // No permission on this page — prices need none. Straight on. primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) { page = 4 } case 4: // Best Value: the settings were saved when the toggle moved, so a // swipe past this page keeps the choice. primaryButton(BestValueIntroContent.primaryTitle) { page = 5 } default: primaryButton(String(localized: "Start Using FuelBoard", comment: "Finish onboarding button")) { finish() } } } private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View { OnboardingPrimaryButton(title: title, action: action) } private func featureRow(icon: String, text: String) -> 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: String) -> some View { Group { if granted { Label(String(localized: "Allowed", comment: "Permission granted status"), systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } else if denied { Text(deniedText) .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) } else { Text(String(localized: "The system prompt will appear next.", comment: "Permission prompt pending status")) .font(.footnote) .foregroundStyle(.secondary) } } .font(.subheadline) } private func openSettings() { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } private func finish() { // Anyone who goes through the whole of onboarding has now seen the Best // Value page — mark the announcement seen so they don't immediately get // the standalone one aimed at existing users. FuelStore.markBestValueIntroSeen() FuelStore.saveHasCompletedOnboarding(true) onFinish() } // MARK: - QA hooks /// DEBUG-only launch args in the same spirit as ContentView's `-tab` and /// `-skipOnboarding`, both no-ops in release: /// /// - `-onboardingPage 4` opens straight on a page, so the screenshot harness /// can photograph a page it cannot swipe or tap its way to. /// - `-onboardingBestValue on|off` forces the Best Value page's choice, so /// both of its states can be captured without driving the toggle. private static func initialPage() -> Int { #if DEBUG let args = ProcessInfo.processInfo.arguments if let index = args.firstIndex(of: "-onboardingPage"), index + 1 < args.count, let value = Int(args[index + 1]), (0.. FuelSavingsSettings { var settings = FuelStore.loadFuelSavingsSettings() #if DEBUG let args = ProcessInfo.processInfo.arguments if let index = args.firstIndex(of: "-onboardingBestValue"), index + 1 < args.count { settings.enabled = args[index + 1].lowercased() == "on" } #endif return settings } } /// The animated badge behind each page's icon. Internal rather than private /// because `BestValueIntroContent` lives in its own file and uses it too. 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 } } } } }