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:
@@ -7,6 +7,7 @@ struct AlertsView: View {
|
|||||||
@Binding var radius: Double
|
@Binding var radius: Double
|
||||||
let monitoredCount: Int
|
let monitoredCount: Int
|
||||||
let lastAlert: String?
|
let lastAlert: String?
|
||||||
|
var onShowOnboarding: () -> Void = {}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
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")
|
.navigationTitle("Alerts")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct ContentView: View {
|
|||||||
}()
|
}()
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var statusMessage = ""
|
@State private var statusMessage = ""
|
||||||
|
@State private var showOnboarding = false
|
||||||
@State private var locationManager = LocationManager()
|
@State private var locationManager = LocationManager()
|
||||||
@StateObject private var monitor = ProximityMonitor()
|
@StateObject private var monitor = ProximityMonitor()
|
||||||
|
|
||||||
@@ -132,18 +133,37 @@ struct ContentView: View {
|
|||||||
enabled: $alertsEnabled,
|
enabled: $alertsEnabled,
|
||||||
radius: $alertsRadius,
|
radius: $alertsRadius,
|
||||||
monitoredCount: monitor.monitoredStationIDs.count,
|
monitoredCount: monitor.monitoredStationIDs.count,
|
||||||
lastAlert: monitor.lastAlert
|
lastAlert: monitor.lastAlert,
|
||||||
|
onShowOnboarding: { showOnboarding = true }
|
||||||
)
|
)
|
||||||
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $showOnboarding) {
|
||||||
|
OnboardingView {
|
||||||
|
showOnboarding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
locationManager.startForegroundTracking()
|
// 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,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: selectedFuel, radiusKM: alertsRadius)
|
fuel: selectedFuel, radiusKM: alertsRadius)
|
||||||
monitor.setEnabled(alertsEnabled)
|
monitor.setEnabled(alertsEnabled)
|
||||||
// Refresh only when the cache is stale (twice-a-day policy).
|
// Refresh only when the cache is stale (twice-a-day policy).
|
||||||
Task { await refresh() }
|
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
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,6 +182,7 @@ struct FuelStore {
|
|||||||
static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON
|
static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON
|
||||||
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
||||||
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
||||||
|
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
||||||
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
||||||
|
|
||||||
// MARK: Stations
|
// MARK: Stations
|
||||||
@@ -358,6 +359,18 @@ struct FuelStore {
|
|||||||
return Date().timeIntervalSince(last) < refreshInterval
|
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
|
// MARK: Low-level keychain helpers
|
||||||
|
|
||||||
private static func keychainData(service: String) -> Data? {
|
private static func keychainData(service: String) -> Data? {
|
||||||
|
|||||||
Reference in New Issue
Block a user