The declined state had its own "Not now" label, on the theory that declining should read as a decision rather than a skip. In practice the toggle above it already shows which answer is being given, so the second label only added a second thing to read. The label is now an unconditional Continue for both states, which also let primaryTitle drop the settings value it no longer consulted. Nothing else about the page changed: the toggle still hides the sliders when off, the "can be turned on in Settings" note still appears when declined, and the settings still save as the controls move. Verified on the simulator (OCR of the rendered screens): the announcement page, and the onboarding page with the toggle both off and on, all show Continue as the bottom button. No "Not now" remains in the app's source. 180 tests, 0 failures.
572 lines
23 KiB
Swift
572 lines
23 KiB
Swift
import SwiftUI
|
|
import CoreLocation
|
|
import UserNotifications
|
|
import Network
|
|
|
|
/// First-launch onboarding: introduces FuelBoard, walks the user through the
|
|
/// two system permissions (location and notifications) with in-context prompts,
|
|
/// then offers Best Value — explained, switched on or declined, and configured
|
|
/// with the same sliders Settings uses. Price data downloads over HTTPS with no
|
|
/// Local Network permission. In production it appears once at initial launch
|
|
/// (driven by `FuelStore.loadHasCompletedOnboarding()`); the Settings tab has a
|
|
/// row that re-opens it anytime.
|
|
///
|
|
/// The Best Value page itself lives in `BestValueIntroContent`, because the same
|
|
/// page is also shown to EXISTING users (once per announcement version, by
|
|
/// `BestValueIntroView`) — one source for its copy, controls and rules, so the
|
|
/// two can never drift apart.
|
|
struct OnboardingView: View {
|
|
var onFinish: () -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@StateObject private var prompter = OnboardingPermissionPrompter()
|
|
@State private var page = OnboardingView.initialPage()
|
|
/// Best Value's own copy of the stored settings. Loaded so a replay starts
|
|
/// from what the user already has; the shared page view saves on every
|
|
/// change — the same immediate-persist rule as Settings — so a swipe past
|
|
/// the page cannot lose a choice the user has already made.
|
|
@State private var fuelSavings = OnboardingView.initialFuelSavings()
|
|
|
|
private static let totalPages = 6
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Top bar: page dots only — onboarding is mandatory, no skip.
|
|
HStack {
|
|
Spacer()
|
|
HStack(spacing: 8) {
|
|
ForEach(0..<Self.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)
|
|
bestValuePage.tag(4)
|
|
donePage.tag(5)
|
|
}
|
|
.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()
|
|
AnimatedOnboardingIcon(tint: .accentColor) {
|
|
// 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(String(localized: "Welcome to FuelBoard", comment: "Onboarding welcome title"))
|
|
.font(.largeTitle.bold())
|
|
.multilineTextAlignment(.center)
|
|
|
|
Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle"))
|
|
.font(.body)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 32)
|
|
.padding(.top, 12)
|
|
|
|
featureRow(icon: "globe.europe.africa.fill", text: String(localized: "UK-wide prices — 8,000+ stations, updated twice a day", comment: "Onboarding feature row"))
|
|
featureRow(icon: "scope", text: String(localized: "Cheapest within your chosen radius, or closest station first", comment: "Onboarding feature row"))
|
|
featureRow(icon: "star.fill", text: String(localized: "Favourites with instant price comparison", comment: "Onboarding feature row"))
|
|
featureRow(icon: "chart.bar.fill", text: String(localized: "Green/amber/red rating vs the best nearby price", comment: "Onboarding feature row"))
|
|
featureRow(icon: "square.grid.2x2.fill", text: String(localized: "Home-screen widget showing the cheapest nearby", comment: "Onboarding feature row"))
|
|
featureRow(icon: "bell.fill", text: String(localized: "Alerts when you approach the cheapest station", comment: "Onboarding feature row"))
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var locationPage: some View {
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
|
|
Image(systemName: "location.fill")
|
|
.font(.system(size: 40, weight: .semibold))
|
|
.foregroundStyle(.blue)
|
|
}
|
|
.padding(.bottom, 28)
|
|
|
|
Text(String(localized: "Prices near you", comment: "Onboarding location permission title"))
|
|
.font(.largeTitle.bold())
|
|
|
|
Text(String(localized: "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.", comment: "Onboarding location permission explanation"))
|
|
.font(.body)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 32)
|
|
.padding(.top, 12)
|
|
|
|
permissionStatusLabel(
|
|
granted: prompter.locationGranted,
|
|
denied: prompter.locationDenied,
|
|
deniedText: String(localized: "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it.", comment: "Onboarding location denied text")
|
|
)
|
|
.padding(.top, 24)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var notificationsPage: some View {
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
|
|
Image(systemName: "bell.badge.fill")
|
|
.font(.system(size: 40, weight: .semibold))
|
|
.foregroundStyle(.orange)
|
|
}
|
|
.padding(.bottom, 28)
|
|
|
|
Text(String(localized: "Cheapest-station alerts", comment: "Onboarding notifications title"))
|
|
.font(.largeTitle.bold())
|
|
|
|
Text(String(localized: "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.", comment: "Onboarding notifications explanation"))
|
|
.font(.body)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 32)
|
|
.padding(.top, 12)
|
|
|
|
permissionStatusLabel(
|
|
granted: prompter.notificationsGranted,
|
|
denied: prompter.notificationsDenied,
|
|
deniedText: String(localized: "Notifications were denied — you can still use FuelBoard, just without alert banners.", comment: "Onboarding notifications denied text")
|
|
)
|
|
.padding(.top, 24)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var dataPage: some View {
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
|
|
Image(systemName: "arrow.down.circle.fill")
|
|
.font(.system(size: 40, weight: .semibold))
|
|
.foregroundStyle(.teal)
|
|
}
|
|
.padding(.bottom, 28)
|
|
|
|
Text(String(localized: "Prices, ready when you are", comment: "Onboarding data title"))
|
|
.font(.largeTitle.bold())
|
|
|
|
Text(String(localized: "FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.", comment: "Onboarding data explanation"))
|
|
.font(.body)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 32)
|
|
.padding(.top, 12)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
/// The Best Value page — the shared body, so the first-run page and the
|
|
/// announcement existing users see can never drift apart.
|
|
private var bestValuePage: some View {
|
|
BestValueIntroContent(settings: $fuelSavings)
|
|
}
|
|
|
|
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(String(localized: "You're all set", comment: "Onboarding final title"))
|
|
.font(.largeTitle.bold())
|
|
|
|
Text(String(localized: "Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.", comment: "Onboarding final explanation"))
|
|
.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(String(localized: "Continue", comment: "Onboarding continue button")) { page = 1 }
|
|
case 1:
|
|
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) {
|
|
if prompter.locationDenied {
|
|
openSettings()
|
|
} else if prompter.locationGranted {
|
|
page = 2 // already decided — just move on
|
|
} else {
|
|
// Fire the system prompt NOW, while this page is still on
|
|
// screen (after it's been read) and BEFORE advancing — so
|
|
// the prompt never covers the next page's animation. Auto-
|
|
// advance on grant moves us on once the user responds;
|
|
// denying leaves the page's Settings path.
|
|
prompter.requestLocation()
|
|
}
|
|
}
|
|
case 2:
|
|
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) {
|
|
if prompter.notificationsDenied || prompter.notificationsGranted {
|
|
page = 3 // decided either way — move on
|
|
} else {
|
|
// Same rule as Location: prompt now, before advancing.
|
|
prompter.requestNotifications()
|
|
}
|
|
}
|
|
case 3:
|
|
// No permission on this page — prices need none. Straight on.
|
|
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) { page = 4 }
|
|
case 4:
|
|
// Best Value: the settings were saved when the toggle moved, so a
|
|
// swipe past this page keeps the choice.
|
|
primaryButton(BestValueIntroContent.primaryTitle) { page = 5 }
|
|
default:
|
|
primaryButton(String(localized: "Start Using FuelBoard", comment: "Finish onboarding button")) { finish() }
|
|
}
|
|
}
|
|
|
|
private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View {
|
|
OnboardingPrimaryButton(title: title, action: action)
|
|
}
|
|
|
|
private func featureRow(icon: String, text: String) -> 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: String) -> some View {
|
|
Group {
|
|
if granted {
|
|
Label(String(localized: "Allowed", comment: "Permission granted status"), systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
} else if denied {
|
|
Text(deniedText)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 32)
|
|
} else {
|
|
Text(String(localized: "The system prompt will appear next.", comment: "Permission prompt pending status"))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.font(.subheadline)
|
|
}
|
|
|
|
private func openSettings() {
|
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}
|
|
|
|
private func finish() {
|
|
// Anyone who goes through the whole of onboarding has now seen the Best
|
|
// Value page — mark the announcement seen so they don't immediately get
|
|
// the standalone one aimed at existing users.
|
|
FuelStore.markBestValueIntroSeen()
|
|
FuelStore.saveHasCompletedOnboarding(true)
|
|
onFinish()
|
|
}
|
|
|
|
// MARK: - QA hooks
|
|
|
|
/// DEBUG-only launch args in the same spirit as ContentView's `-tab` and
|
|
/// `-skipOnboarding`, both no-ops in release:
|
|
///
|
|
/// - `-onboardingPage 4` opens straight on a page, so the screenshot harness
|
|
/// can photograph a page it cannot swipe or tap its way to.
|
|
/// - `-onboardingBestValue on|off` forces the Best Value page's choice, so
|
|
/// both of its states can be captured without driving the toggle.
|
|
private static func initialPage() -> Int {
|
|
#if DEBUG
|
|
let args = ProcessInfo.processInfo.arguments
|
|
if let index = args.firstIndex(of: "-onboardingPage"),
|
|
index + 1 < args.count,
|
|
let value = Int(args[index + 1]),
|
|
(0..<totalPages).contains(value) {
|
|
return value
|
|
}
|
|
#endif
|
|
return 0
|
|
}
|
|
|
|
private static func initialFuelSavings() -> FuelSavingsSettings {
|
|
var settings = FuelStore.loadFuelSavingsSettings()
|
|
#if DEBUG
|
|
let args = ProcessInfo.processInfo.arguments
|
|
if let index = args.firstIndex(of: "-onboardingBestValue"), index + 1 < args.count {
|
|
settings.enabled = args[index + 1].lowercased() == "on"
|
|
}
|
|
#endif
|
|
return settings
|
|
}
|
|
}
|
|
|
|
/// The animated badge behind each page's icon. Internal rather than private
|
|
/// because `BestValueIntroContent` lives in its own file and uses it too.
|
|
struct AnimatedOnboardingIcon<Content: View>: View {
|
|
enum Motion {
|
|
case gentle
|
|
case locationArrow
|
|
case alarmBell
|
|
case downloadArrow
|
|
}
|
|
|
|
let tint: Color
|
|
var motion: Motion = .gentle
|
|
@ViewBuilder var content: Content
|
|
|
|
@State private var settled = false
|
|
@State private var iconRotation: Double = 0
|
|
@State private var iconOffsetY: CGFloat = 0
|
|
@State private var iconScale: CGFloat = 0.92
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.fill(tint.opacity(0.10))
|
|
.frame(width: 108, height: 108)
|
|
.scaleEffect(settled ? 1.0 : 0.94)
|
|
|
|
Circle()
|
|
.stroke(tint.opacity(0.18), lineWidth: 1)
|
|
.frame(width: 118, height: 118)
|
|
.scaleEffect(settled ? 1.03 : 0.97)
|
|
.opacity(settled ? 1.0 : 0.55)
|
|
|
|
content
|
|
.scaleEffect(iconScale)
|
|
.rotationEffect(.degrees(iconRotation))
|
|
.offset(y: iconOffsetY)
|
|
}
|
|
.shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2)
|
|
.animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled)
|
|
.onAppear {
|
|
playAnimation()
|
|
}
|
|
}
|
|
|
|
private func playAnimation() {
|
|
settled = false
|
|
iconRotation = 0
|
|
iconOffsetY = 4
|
|
iconScale = 0.92
|
|
|
|
switch motion {
|
|
case .gentle:
|
|
withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
|
|
settled = true
|
|
iconOffsetY = 0
|
|
iconScale = 1.0
|
|
}
|
|
|
|
case .locationArrow:
|
|
iconRotation = -120
|
|
withAnimation(.linear(duration: 0.45)) {
|
|
settled = true
|
|
iconRotation = 24
|
|
iconOffsetY = 0
|
|
iconScale = 1.02
|
|
}
|
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) {
|
|
iconRotation = 0
|
|
iconScale = 1.0
|
|
}
|
|
|
|
case .alarmBell:
|
|
settled = true
|
|
iconOffsetY = 0
|
|
iconScale = 1.0
|
|
withAnimation(.linear(duration: 0.10)) { iconRotation = -14 }
|
|
withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 }
|
|
withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 }
|
|
withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 }
|
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 }
|
|
|
|
case .downloadArrow:
|
|
iconOffsetY = -22
|
|
withAnimation(.easeIn(duration: 0.22)) {
|
|
settled = true
|
|
iconScale = 1.0
|
|
iconOffsetY = 8
|
|
}
|
|
withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) {
|
|
iconOffsetY = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Owns the system permission requests during onboarding and publishes
|
|
/// their outcomes so the pages can reflect them live.
|
|
@MainActor
|
|
final class OnboardingPermissionPrompter: NSObject, ObservableObject, 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:
|
|
do {
|
|
let granted = try await UNUserNotificationCenter.current()
|
|
.requestAuthorization(options: [.alert, .sound, .badge])
|
|
self.notificationsGranted = granted
|
|
self.notificationsDenied = !granted
|
|
} catch {
|
|
self.notificationsGranted = false
|
|
self.notificationsDenied = true
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|