Files
fuelboard/FuelBoard/OnboardingView.swift
T
FuelBoard Contributor 050c9f13a3 Onboarding: show the app icon with iOS rounded corners
The welcome page's green-gradient circle + SF symbol is replaced by the
app icon itself (AppIconArtwork imageset mirroring AppIcon.png), clipped
to the standard iOS rounded-rect corner radius (21pt at 96pt — ~22%),
so onboarding matches the Home Screen icon.
2026-08-13 23:41:16 +01:00

598 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 {
// The app icon itself, with the standard iOS rounded-corner
// treatment (~22% of the size), so onboarding matches the
// Home Screen.
Image("AppIconArtwork")
.resizable()
.scaledToFill()
.frame(width: 96, height: 96)
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
}
.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 {
// Same confirmation styling as the Location/Notifications pages.
Label("Connected", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.subheadline)
} 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: LocalizedStringKey, 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: LocalizedStringKey) -> 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: LocalizedStringKey) -> 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
}
}
}
}
}