diff --git a/Config/App-Info.plist b/Config/App-Info.plist index 893d1b5..a5269bf 100644 --- a/Config/App-Info.plist +++ b/Config/App-Info.plist @@ -26,6 +26,8 @@ FuelBoard uses your location to find the cheapest nearby petrol stations. NSLocationAlwaysAndWhenInUseUsageDescription FuelBoard uses Always location to alert you when you approach the cheapest station nearby. + NSLocalNetworkUsageDescription + FuelBoard connects to the FuelBoard Relay on your local network to download the latest fuel prices. UIBackgroundModes location diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 6d157a5..9dd84d5 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -165,28 +165,40 @@ struct ContentView: View { } .onAppear { // Onboarding runs first on a fresh install — it owns the initial - // permission prompts. Location tracking starts once it's done. + // permission prompts (location, notifications, and the data/local + // network probe on the Data page). Location tracking and the first + // network fetch start once it's done. if FuelStore.loadHasCompletedOnboarding() { locationManager.startForegroundTracking() + monitor.update(stations: stations, favourites: refreshedFavourites, + fuel: alertsFuel, radiusKM: alertsRadius) + monitor.setEnabled(alertsEnabled) + // Refresh only when the cache is stale (twice-a-day policy). + Task { await refresh() } } else { showOnboarding = true } - monitor.update(stations: stations, favourites: refreshedFavourites, - fuel: alertsFuel, 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. + // begin foreground location tracking if permission allows, and + // run the first data fetch (the relay probe during onboarding + // already surfaced the Local Network prompt). if !showing, FuelStore.loadHasCompletedOnboarding() { locationManager.startForegroundTracking() + monitor.update(stations: stations, favourites: refreshedFavourites, + fuel: alertsFuel, radiusKM: alertsRadius) + Task { await refresh() } } } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { - locationManager.startForegroundTracking() + // Never start location tracking while onboarding is on screen — + // onboarding owns the initial permission prompts. Once it's + // completed, normal foreground tracking resumes. + if !showOnboarding, FuelStore.loadHasCompletedOnboarding() { + locationManager.startForegroundTracking() + } monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: alertsRadius) // No network fetch on foreground — pull-to-refresh is the override. diff --git a/FuelBoard/OnboardingView.swift b/FuelBoard/OnboardingView.swift index e2dc867..4d6696e 100644 --- a/FuelBoard/OnboardingView.swift +++ b/FuelBoard/OnboardingView.swift @@ -3,10 +3,10 @@ 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. +/// 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 @@ -14,7 +14,7 @@ struct OnboardingView: View { @StateObject private var prompter = OnboardingPermissionPrompter() @State private var page = 0 - private let totalPages = 4 + private let totalPages = 5 var body: some View { VStack(spacing: 0) { @@ -45,13 +45,15 @@ struct OnboardingView: View { welcomePage.tag(0) locationPage.tag(1) notificationsPage.tag(2) - donePage.tag(3) + dataPage.tag(3) + donePage.tag(4) } .tabViewStyle(.page(indexDisplayMode: .never)) .onChange(of: page) { _, newPage in // Trigger each system prompt the moment its page appears. if newPage == 1 { prompter.requestLocation() } if newPage == 2 { prompter.requestNotifications() } + if newPage == 3 { prompter.requestDataAccess() } } bottomAction @@ -168,6 +170,59 @@ struct OnboardingView: View { } } + private var dataPage: some View { + VStack(spacing: 0) { + Spacer() + ZStack { + Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96) + 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 a relay on your local network — the full UK dataset, refreshed twice a day. Local network access is needed for that first download.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .padding(.top, 12) + + dataStatusLabel + .padding(.top, 24) + + Spacer() + } + } + + @ViewBuilder + private var dataStatusLabel: some View { + if prompter.dataLoading { + VStack(spacing: 10) { + ProgressView() + Text("Checking the FuelBoard Relay…") + .font(.footnote) + .foregroundStyle(.secondary) + } + } else if prompter.dataGranted { + Label("Connected — prices will load", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else if prompter.dataDenied { + Text("Local network access was denied — prices won't load until it's allowed. You can open Settings to change this.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } else { + Text("The system prompt will appear next.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + private var donePage: some View { VStack(spacing: 0) { Spacer() @@ -224,6 +279,24 @@ struct OnboardingView: View { if prompter.notificationsGranted { page = 3 } } } + case 3: + VStack(spacing: 10) { + primaryButton( + prompter.dataDenied ? "Open Settings" : (prompter.dataGranted ? "Continue" : (prompter.dataLoading ? "Checking…" : "Continue")) + ) { + if prompter.dataDenied { + openSettings() + } else if prompter.dataGranted || !prompter.dataLoading { + page = 4 + } + } + .disabled(prompter.dataLoading) + if prompter.dataDenied { + Button("Check again") { prompter.requestDataAccess() } + .font(.subheadline) + .foregroundStyle(.secondary) + } + } default: primaryButton("Start Using FuelBoard") { finish() } } @@ -285,7 +358,7 @@ struct OnboardingView: View { } } -/// Owns the two system permission requests during onboarding and publishes +/// 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 { @@ -293,6 +366,9 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur @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() @@ -334,6 +410,44 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur } } + /// 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()