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:
@@ -26,6 +26,8 @@
|
||||
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<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>
|
||||
<array>
|
||||
<string>location</string>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user