598 lines
23 KiB
Swift
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: 20, weight: .semibold))
|
|
.foregroundStyle(Color.accentColor)
|
|
.frame(width: 32)
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|