Onboarding: prompt permissions in context + data page

Location and network fetches no longer fire at launch before onboarding
reaches the relevant step: foreground tracking and the first refresh are
gated behind onboarding completion (scenePhase no longer starts tracking
while the cover is up, and onAppear defers its fetch until after
finish). A new Data page sits just before 'You're all set': it probes
the FuelBoard Relay, which surfaces the iOS Local Network permission
prompt in context (NSLocalNetworkUsageDescription added), verifies
connectivity, and offers Open Settings/Check again on denial. Onboarding
is now 5 pages: Welcome, Location, Notifications, Data, Done. 35 tests
pass.
This commit is contained in:
FuelBoard Contributor
2026-08-12 11:59:07 +01:00
parent 002d7f63e2
commit 20589f41fe
3 changed files with 143 additions and 15 deletions
+2
View File
@@ -26,6 +26,8 @@
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string> <string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string> <string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>FuelBoard connects to the FuelBoard Relay on your local network to download the latest fuel prices.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>location</string> <string>location</string>
+20 -8
View File
@@ -165,28 +165,40 @@ struct ContentView: View {
} }
.onAppear { .onAppear {
// Onboarding runs first on a fresh install it owns the initial // 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() { if FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking() 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 { } else {
showOnboarding = true 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 .onChange(of: showOnboarding) { _, showing in
// After onboarding finishes (or the test re-run is dismissed), // 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() { if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
Task { await refresh() }
} }
} }
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
if newPhase == .active { 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, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius) fuel: alertsFuel, radiusKM: alertsRadius)
// No network fetch on foreground pull-to-refresh is the override. // No network fetch on foreground pull-to-refresh is the override.
+121 -7
View File
@@ -3,10 +3,10 @@ import CoreLocation
import UserNotifications import UserNotifications
/// First-launch onboarding: introduces FuelBoard, then walks the user through /// First-launch onboarding: introduces FuelBoard, then walks the user through
/// the two system permissions (location + notifications) with in-context /// the three system permissions (location, notifications, local network for
/// prompts. In production it appears once at initial launch (driven by /// data loading) with in-context prompts. In production it appears once at
/// `FuelStore.loadHasCompletedOnboarding()`); a test button in the Alerts tab /// initial launch (driven by `FuelStore.loadHasCompletedOnboarding()`); a
/// re-opens it anytime. /// test button in the Alerts tab re-opens it anytime.
struct OnboardingView: View { struct OnboardingView: View {
var onFinish: () -> Void var onFinish: () -> Void
@@ -14,7 +14,7 @@ struct OnboardingView: View {
@StateObject private var prompter = OnboardingPermissionPrompter() @StateObject private var prompter = OnboardingPermissionPrompter()
@State private var page = 0 @State private var page = 0
private let totalPages = 4 private let totalPages = 5
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -45,13 +45,15 @@ struct OnboardingView: View {
welcomePage.tag(0) welcomePage.tag(0)
locationPage.tag(1) locationPage.tag(1)
notificationsPage.tag(2) notificationsPage.tag(2)
donePage.tag(3) dataPage.tag(3)
donePage.tag(4)
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: page) { _, newPage in .onChange(of: page) { _, newPage in
// Trigger each system prompt the moment its page appears. // Trigger each system prompt the moment its page appears.
if newPage == 1 { prompter.requestLocation() } if newPage == 1 { prompter.requestLocation() }
if newPage == 2 { prompter.requestNotifications() } if newPage == 2 { prompter.requestNotifications() }
if newPage == 3 { prompter.requestDataAccess() }
} }
bottomAction 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 { private var donePage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
@@ -224,6 +279,24 @@ struct OnboardingView: View {
if prompter.notificationsGranted { page = 3 } 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: default:
primaryButton("Start Using FuelBoard") { finish() } 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. /// their outcomes so the pages can reflect them live.
@MainActor @MainActor
final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate { 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 locationDenied = false
@Published private(set) var notificationsGranted = false @Published private(set) var notificationsGranted = false
@Published private(set) var notificationsDenied = 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() 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) { nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
Task { @MainActor in Task { @MainActor in
self.refreshLocationStatus() self.refreshLocationStatus()