Replaying onboarding showed a visible probe on the 4th page because iOS exposes no status API for the Local Network permission — the connection attempt IS the only detector. Location and Notifications query their status at init, so their ticks are already known when the page appears; Local Network could only be known by probing. Now, when onboarding is re-opened (already completed), a pre-flight probe runs silently at open: granted connects in ms, denied reports .waiting/.localNetworkDenied immediately, and a plain .waiting (permission undetermined after a reinstall) cancels so page 4's Continue still fires the prompt in context. By the time the data page appears its tick (or denied text) is already on screen, matching the other permission pages. First run is unchanged.
594 lines
23 KiB
Swift
594 lines
23 KiB
Swift
import SwiftUI
|
|
import CoreLocation
|
|
import UserNotifications
|
|
import Network
|
|
|
|
/// 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: page dots only — onboarding is mandatory, no skip.
|
|
HStack {
|
|
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()
|
|
}
|
|
.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))
|
|
|
|
bottomAction
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 24)
|
|
}
|
|
// Auto-advance once the user grants a permission — the prompt itself
|
|
// only fires when the Continue button is tapped (after reading the
|
|
// page's description), never when the page merely appears.
|
|
.onChange(of: prompter.locationGranted) { _, granted in
|
|
if granted, page == 1 { page = 2 }
|
|
}
|
|
.onChange(of: prompter.notificationsGranted) { _, granted in
|
|
if granted, page == 2 { page = 3 }
|
|
}
|
|
.onChange(of: prompter.dataGranted) { _, granted in
|
|
if granted, page == 3 {
|
|
// The probe result lands while the Local Network prompt is
|
|
// still dismissing, so without a beat the green "Connected"
|
|
// tick is skipped and the page jumps straight to Done —
|
|
// unlike the Location/Notifications pages where the granted
|
|
// tick is visible. Hold the tick on screen, then advance.
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 800_000_000)
|
|
if page == 3 { page = 4 }
|
|
}
|
|
}
|
|
}
|
|
// Replay path (test button / returning user): the Local Network
|
|
// permission is already decided, so probe silently up front. The
|
|
// probe fires no prompt once the permission is granted or denied —
|
|
// only the FIRST attempt (undetermined) shows the system alert, and
|
|
// that only happens on first run. By the time the data page appears
|
|
// its status is known, so it shows the green tick (or denied text)
|
|
// immediately, matching the Location and Notifications pages.
|
|
.onAppear {
|
|
if FuelStore.loadHasCompletedOnboarding() {
|
|
prompter.preflightDataAccess()
|
|
}
|
|
}
|
|
.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("Waiting for network permission…")
|
|
.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, but you can keep using FuelBoard.")
|
|
.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 if !prompter.locationGranted {
|
|
// The system prompt fires HERE — after the user has read
|
|
// the description and tapped Continue — not on page appear.
|
|
prompter.requestLocation()
|
|
} else {
|
|
page = 2
|
|
}
|
|
}
|
|
case 2:
|
|
primaryButton(
|
|
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
|
) {
|
|
if prompter.notificationsDenied {
|
|
page = 3
|
|
} else if !prompter.notificationsGranted {
|
|
prompter.requestNotifications()
|
|
} else {
|
|
page = 3
|
|
}
|
|
}
|
|
case 3:
|
|
primaryButton(prompter.dataLoading ? "Waiting…" : "Continue") {
|
|
if prompter.dataGranted || prompter.dataDenied {
|
|
// Prompt answered (allowed or denied) — move on to the
|
|
// final slide. Never dead-end on an Open Settings button.
|
|
page = 4
|
|
} else if !prompter.dataLoading {
|
|
// Fires the Local Network prompt + relay probe here,
|
|
// after the user has read the page and tapped Continue.
|
|
prompter.requestDataAccess()
|
|
}
|
|
}
|
|
.disabled(prompter.dataLoading)
|
|
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
|
|
|
|
/// Held while the Local Network permission prompt is pending — keeps the
|
|
/// bare TCP connect alive until the user answers (nil after resolve).
|
|
private var dataConnection: NWConnection?
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Triggers the Local Network permission prompt WITHOUT loading any data.
|
|
/// iOS shows the prompt on the first attempt to reach a local-network
|
|
/// address, so a bare TCP connect to the relay host:port is enough — no
|
|
/// HTTP request, no payload, nothing to wait on. Once the user answers
|
|
/// the prompt the connect either succeeds (granted) or fails (denied);
|
|
/// either way onboarding proceeds. The full dataset download happens
|
|
/// AFTER onboarding completes (ContentView's forced refresh).
|
|
func requestDataAccess() {
|
|
guard !dataLoading else { return }
|
|
dataLoading = true
|
|
dataDenied = false
|
|
let baseURL = RelayFuelProvider().baseURL
|
|
guard let host = baseURL.host,
|
|
let port = baseURL.port else {
|
|
dataLoading = false
|
|
dataDenied = true
|
|
return
|
|
}
|
|
let connection = NWConnection(
|
|
host: NWEndpoint.Host(host),
|
|
port: NWEndpoint.Port(rawValue: UInt16(port))!,
|
|
using: .tcp
|
|
)
|
|
dataConnection = connection
|
|
connection.stateUpdateHandler = { [weak self] state in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
switch state {
|
|
case .ready:
|
|
// Connect succeeded — local network allowed.
|
|
self.dataConnection = nil
|
|
self.dataLoading = false
|
|
self.dataGranted = true
|
|
self.dataDenied = false
|
|
case .failed, .cancelled:
|
|
// Connect failed (denied, or relay unreachable). Either
|
|
// way we do NOT dead-end: onboarding continues.
|
|
self.dataConnection = nil
|
|
self.dataLoading = false
|
|
self.dataGranted = false
|
|
self.dataDenied = true
|
|
default:
|
|
break // .preparing / .waiting — prompt pending, hold on
|
|
}
|
|
}
|
|
}
|
|
connection.start(queue: .main)
|
|
// Safety timeout: if the prompt is ignored or the connect stalls, drop
|
|
// back to the enabled Continue button instead of an endless spinner.
|
|
Task { @MainActor [weak self] in
|
|
try? await Task.sleep(nanoseconds: 12_000_000_000)
|
|
guard let self, self.dataLoading else { return }
|
|
self.dataLoading = false
|
|
self.dataGranted = false
|
|
self.dataDenied = true
|
|
self.dataConnection = nil
|
|
connection.cancel()
|
|
}
|
|
}
|
|
|
|
/// Replay pre-flight: determines the Local Network status WITHOUT any
|
|
/// user-visible check. Once the permission is decided (granted or denied)
|
|
/// a probe resolves silently — granted connects in milliseconds, denied
|
|
/// reports `.waiting` with `unsatisfiedReason == .localNetworkDenied`.
|
|
/// Only the FIRST-ever attempt (undetermined) shows the system alert; in
|
|
/// that case we cancel and leave the state unknown so the data page's
|
|
/// Continue triggers the prompt in context. First run never calls this.
|
|
func preflightDataAccess() {
|
|
guard !dataLoading, !dataGranted, !dataDenied else { return }
|
|
dataLoading = true
|
|
let baseURL = RelayFuelProvider().baseURL
|
|
guard let host = baseURL.host,
|
|
let port = baseURL.port else {
|
|
dataLoading = false
|
|
return
|
|
}
|
|
let connection = NWConnection(
|
|
host: NWEndpoint.Host(host),
|
|
port: NWEndpoint.Port(rawValue: UInt16(port))!,
|
|
using: .tcp
|
|
)
|
|
dataConnection = connection
|
|
connection.stateUpdateHandler = { [weak self] state in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
switch state {
|
|
case .ready:
|
|
self.dataConnection = nil
|
|
self.dataLoading = false
|
|
self.dataGranted = true
|
|
self.dataDenied = false
|
|
case .waiting:
|
|
if connection.currentPath?.unsatisfiedReason == .localNetworkDenied {
|
|
// Denied in Settings — resolved silently, no prompt.
|
|
self.dataConnection = nil
|
|
self.dataLoading = false
|
|
self.dataGranted = false
|
|
self.dataDenied = true
|
|
} else {
|
|
// Prompt pending (permission undetermined — only
|
|
// possible after a reinstall, where the onboarding
|
|
// flag persisted but the permission reset). Cancel so
|
|
// page 4's Continue fires the prompt in context.
|
|
self.dataConnection = nil
|
|
self.dataLoading = false
|
|
connection.cancel()
|
|
}
|
|
case .failed, .cancelled:
|
|
self.dataConnection = nil
|
|
self.dataLoading = false
|
|
self.dataGranted = false
|
|
self.dataDenied = true
|
|
default:
|
|
break // .preparing — probe starting, hold on
|
|
}
|
|
}
|
|
}
|
|
connection.start(queue: .main)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|