Add first-launch onboarding with location + notification permission prompts

- OnboardingView: 4-page flow (welcome + features, location, notifications, done)
- Triggers system prompts in-context via OnboardingPermissionPrompter
- Shown at initial launch only (FuelStore.onboardingCompleted flag, app-group defaults)
- Test hook: 'Show onboarding (testing)' button at bottom of Alerts tab
This commit is contained in:
FuelBoard Contributor
2026-08-12 07:10:54 +01:00
parent 2ed18d81a3
commit fb79cf0788
4 changed files with 422 additions and 2 deletions
+14
View File
@@ -7,6 +7,7 @@ struct AlertsView: View {
@Binding var radius: Double
let monitoredCount: Int
let lastAlert: String?
var onShowOnboarding: () -> Void = {}
var body: some View {
NavigationStack {
@@ -54,6 +55,19 @@ struct AlertsView: View {
}
}
}
// Testing hook in production onboarding shows once at first
// launch; this button re-opens it to verify the flow.
Section {
Button {
onShowOnboarding()
} label: {
Label("Show onboarding (testing)", systemImage: "flag.fill")
.font(.footnote)
}
} footer: {
Text("Testing only — onboarding normally appears once on first launch.")
}
}
.navigationTitle("Alerts")
}
+21 -1
View File
@@ -18,6 +18,7 @@ struct ContentView: View {
}()
@State private var isLoading = false
@State private var statusMessage = ""
@State private var showOnboarding = false
@State private var locationManager = LocationManager()
@StateObject private var monitor = ProximityMonitor()
@@ -132,18 +133,37 @@ struct ContentView: View {
enabled: $alertsEnabled,
radius: $alertsRadius,
monitoredCount: monitor.monitoredStationIDs.count,
lastAlert: monitor.lastAlert
lastAlert: monitor.lastAlert,
onShowOnboarding: { showOnboarding = true }
)
.tabItem { Label("Alerts", systemImage: "bell.fill") }
}
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView {
showOnboarding = false
}
}
.onAppear {
// Onboarding runs first on a fresh install it owns the initial
// permission prompts. Location tracking starts once it's done.
if FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
} else {
showOnboarding = true
}
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: selectedFuel, radiusKM: alertsRadius)
monitor.setEnabled(alertsEnabled)
// Refresh only when the cache is stale (twice-a-day policy).
Task { await refresh() }
}
.onChange(of: showOnboarding) { _, showing in
// After onboarding finishes (or the test re-run is dismissed),
// begin foreground location tracking if permission allows.
if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
}
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
locationManager.startForegroundTracking()
+373
View File
@@ -0,0 +1,373 @@
import SwiftUI
import CoreLocation
import UserNotifications
/// First-launch onboarding: introduces FuelBoard, then walks the user through
/// the two system permissions (location + notifications) 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 = 4
var body: some View {
VStack(spacing: 0) {
// Top bar: skip + page dots
HStack {
if page < totalPages - 1 {
Button("Skip") { finish() }
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Color.clear.frame(width: 40, height: 20)
}
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()
Color.clear.frame(width: 40, height: 20) // balances Skip
}
.padding(.horizontal, 20)
.padding(.top, 12)
TabView(selection: $page) {
welcomePage.tag(0)
locationPage.tag(1)
notificationsPage.tag(2)
donePage.tag(3)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: page) { _, newPage in
// Trigger each system prompt the moment its page appears.
if newPage == 1 { prompter.requestLocation() }
if newPage == 2 { prompter.requestNotifications() }
}
bottomAction
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
.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 5/10/15 miles, 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 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 {
prompter.requestLocation()
if prompter.locationGranted { page = 2 }
}
}
case 2:
primaryButton(
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
) {
if prompter.notificationsDenied {
page = 3
} else {
prompter.requestNotifications()
if prompter.notificationsGranted { page = 3 }
}
}
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 two 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
}
}
}
}
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
}
}
}
}
}
+13
View File
@@ -182,6 +182,7 @@ struct FuelStore {
static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
// MARK: Stations
@@ -358,6 +359,18 @@ struct FuelStore {
return Date().timeIntervalSince(last) < refreshInterval
}
// MARK: Onboarding the app shows the intro screen on first launch only
// (a test button in the Alerts tab re-opens it). Stored in the app group
// so the widget can see it too if ever needed.
static func loadHasCompletedOnboarding() -> Bool {
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false
}
static func saveHasCompletedOnboarding(_ completed: Bool) {
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
}
// MARK: Low-level keychain helpers
private static func keychainData(service: String) -> Data? {