Files
fuelboard/FuelBoard/OnboardingView.swift
T
FuelBoard Contributor 20589f41fe 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.
2026-08-12 11:59:07 +01:00

488 lines
18 KiB
Swift

import SwiftUI
import CoreLocation
import UserNotifications
/// First-launch onboarding: introduces FuelBoard, then walks the user through
/// 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
@Environment(\.dismiss) private var dismiss
@StateObject private var prompter = OnboardingPermissionPrompter()
@State private var page = 0
private let totalPages = 5
var body: some View {
VStack(spacing: 0) {
// Top bar: skip + page dots
HStack {
if page < totalPages - 1 {
Button("Skip") { finish() }
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Color.clear.frame(width: 40, height: 20)
}
Spacer()
HStack(spacing: 8) {
ForEach(0..<totalPages, id: \.self) { index in
Circle()
.fill(index == page ? Color.accentColor : Color.gray.opacity(0.25))
.frame(width: 8, height: 8)
}
}
Spacer()
Color.clear.frame(width: 40, height: 20) // balances Skip
}
.padding(.horizontal, 20)
.padding(.top, 12)
TabView(selection: $page) {
welcomePage.tag(0)
locationPage.tag(1)
notificationsPage.tag(2)
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
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
.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()
ZStack {
Circle()
.fill(LinearGradient(colors: [.green, .blue], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 96, height: 96)
Image(systemName: "fuelpump.fill")
.font(.system(size: 44, weight: .bold))
.foregroundStyle(.white)
}
.padding(.bottom, 28)
Text("Welcome to FuelBoard")
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
Text("The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.top, 12)
featureRow(icon: "globe.europe.africa.fill", text: "England-wide prices — 8,000+ stations, updated twice a day")
featureRow(icon: "scope", text: "Cheapest within your chosen radius, or closest station first")
featureRow(icon: "star.fill", text: "Favourites with instant price comparison")
featureRow(icon: "chart.bar.fill", text: "Green/amber/red rating vs the best nearby price")
featureRow(icon: "square.grid.2x2.fill", text: "Home-screen widget showing the cheapest nearby")
featureRow(icon: "bell.fill", text: "Alerts when you approach the cheapest station")
Spacer()
}
}
private var locationPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
Image(systemName: "location.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.blue)
}
.padding(.bottom, 28)
Text("Prices near you")
.font(.largeTitle.bold())
Text("FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.top, 12)
permissionStatusLabel(
granted: prompter.locationGranted,
denied: prompter.locationDenied,
deniedText: "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it."
)
.padding(.top, 24)
Spacer()
}
}
private var notificationsPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
Image(systemName: "bell.badge.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.orange)
}
.padding(.bottom, 28)
Text("Cheapest-station alerts")
.font(.largeTitle.bold())
Text("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.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.top, 12)
permissionStatusLabel(
granted: prompter.notificationsGranted,
denied: prompter.notificationsDenied,
deniedText: "Notifications were denied — you can still use FuelBoard, just without alert banners."
)
.padding(.top, 24)
Spacer()
}
}
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()
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("You're all set")
.font(.largeTitle.bold())
Text("Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.")
.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("Continue") { page = 1 }
case 1:
primaryButton(
prompter.locationDenied ? "Open Settings" : (prompter.locationGranted ? "Continue" : "Allow Location Access")
) {
if prompter.locationDenied {
openSettings()
} else {
prompter.requestLocation()
if prompter.locationGranted { page = 2 }
}
}
case 2:
primaryButton(
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
) {
if prompter.notificationsDenied {
page = 3
} else {
prompter.requestNotifications()
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() }
}
}
private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
}
private func featureRow(icon: String, text: String) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 28)
Text(text)
.font(.subheadline)
Spacer(minLength: 0)
}
.padding(.horizontal, 36)
.padding(.vertical, 5)
}
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
Group {
if granted {
Label("Allowed", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else if denied {
Text(deniedText)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
} else {
Text("The system prompt will appear next.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.font(.subheadline)
}
private func openSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
private func finish() {
FuelStore.saveHasCompletedOnboarding(true)
onFinish()
}
}
/// 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 {
@Published private(set) var locationGranted = false
@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()
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:
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
Task { @MainActor in
self.notificationsGranted = granted
self.notificationsDenied = !granted
}
}
case .authorized:
self.notificationsGranted = true
self.notificationsDenied = false
default:
self.notificationsGranted = false
self.notificationsDenied = true
}
}
}
}
/// 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()
}
}
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
}
}
}
}
}