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>
|
<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>
|
||||||
|
|||||||
@@ -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()
|
||||||
} else {
|
|
||||||
showOnboarding = true
|
|
||||||
}
|
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: alertsRadius)
|
||||||
monitor.setEnabled(alertsEnabled)
|
monitor.setEnabled(alertsEnabled)
|
||||||
// Refresh only when the cache is stale (twice-a-day policy).
|
// Refresh only when the cache is stale (twice-a-day policy).
|
||||||
Task { await refresh() }
|
Task { await refresh() }
|
||||||
|
} else {
|
||||||
|
showOnboarding = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.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 {
|
||||||
|
// 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()
|
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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user