Files
fuelboard/FuelBoard/OnboardingView.swift
T
FuelBoard Contributor 24b6ca9bb2 Copy: England → UK-wide, include Northern Ireland; migrate user-facing copy into Localizable.strings
- Onboarding feature: 'England-wide prices' → 'UK-wide prices — 8,000+ stations, updated twice a day' (key + value unified)
- Stations empty state: 'covers England, Scotland and Wales' → 'covers the whole UK' (dataset includes Northern Ireland)
- Settings Data source footer: source wording updated (CSV mirror or API), OGL attribution retained
- Empty state, browse button and OGL footer now keys in Localizable.strings so the web copy editor covers them
- APPSTORE.md + BACKLOG.md + code comments: England-wide → UK-wide, NI included
2026-08-15 16:04:56 +01:00

419 lines
15 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.
.onChange(of: prompter.locationGranted) { _, granted in
if granted, page == 1 { page = 2 }
}
.onChange(of: prompter.notificationsGranted) { _, granted in
if granted, page == 2 { page = 3 }
}
// Fire each permission when the user LEAVES its page by advancing
// forward (swipe or Continue) — by then the screen has been read.
// Never on arrival: a fast swipe can't stack prompts before a page
// is seen. Forward-only: swiping back never fires. Idempotent —
// an already-determined permission just refreshes, so the
// auto-advance path and replays stay clean (no duplicate alerts).
.onChange(of: page) { oldPage, newPage in
guard newPage > oldPage else { return }
if oldPage == 1 {
prompter.requestLocation()
} else if oldPage == 2 {
prompter.requestNotifications()
}
}
// No data-permission step: prices download from the internet (GitHub
// mirror) with no permission at all — Local Network was removed from
// the app when the relay left the consumer chain.
.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: "UK-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 the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.top, 12)
Spacer()
}
}
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 {
// The prompt fires on LEAVING this page (onChange(of:
// page), forward advance only) — the description is on
// screen until the user moves on.
page = 2
}
}
case 2:
primaryButton(
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
) {
// Prompt fires on leaving this page (same rule as Location).
page = 3
}
case 3:
// No permission on this page — prices need none. Straight on.
primaryButton("Continue") { page = 4 }
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, 10)
}
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
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
}
}
}
}
/// Replay pre-flight: NO data permission exists anymore — prices download
/// from the internet with no prompt, so there is nothing to preflight.
/// (The LAN relay left the consumer chain; Local Network is gone.)
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
}
}
}
}
}