Files
fuelboard/FuelBoard/OnboardingView.swift
T
FuelBoard Contributor 535a504160 Consumer chain: GitHub-only — relay removed from app+widget, Local Network permission gone
The LAN relay is unreachable from any phone outside the developer's LAN, so
for App Store users a relay fallback was dead weight AND harmful: attempting
the private IP fires the iOS Local Network prompt on a stranger's phone and
adds a timeout before the bundled dump. GitHub raw is reachable anywhere the
relay would be, and more reliably.

- LiveChainProvider: GitHub mirror first EVERYWHERE (focused alert path no
  longer relay-first); LAN relay only joins behind the hidden dev flag
  fuelboard.relayFallback (Settings → Debug, keychain-first, off by default)
- MirrorFuelProvider: focused() projection replaces the relay's server-side
  radius endpoint (fuel filter + within-radius + nearest-first + limit 500)
  — the alert path re-checks radius/fuel itself, so behaviour is identical
- Widget: fetchFocused/fetchFuelOnly now GitHub-first via the chain (day-cache
  shared through the app group); relay + widget-diag GET gated on the same
  dev flag — off-LAN widgets no longer attempt local network at all
- FuelBeacon: fires only when the dev flag is on — consumers make zero
  local-network attempts; /stats app-hit spike = GitHub down + dev flag on
- Onboarding: Local Network machinery removed entirely (no probe, no prompt,
  no 12 s stall) — data page is now informational: prices download from the
  internet, no permissions needed. Replay on cellular can no longer hang.
- Info.plist: NSLocalNetworkUsageDescription copy → optional dev-only wording
- 95 tests (6 new: relay-skip-when-disabled x2, focused prefer-mirror, mirror
  focused projection x3, beacon dev-flag gate)
2026-08-15 13:33:02 +01:00

413 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 — 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 }
}
// 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: "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 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 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:
// 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
}
}
}
}
}