583 lines
23 KiB
Swift
583 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, showsHalo: false) {
|
|
// 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, 16)
|
|
|
|
Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title"))
|
|
.font(.title.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: "map.fill", title: String(localized: "Compare fuel prices near you", comment: "Onboarding feature row title"), subtitle: String(localized: "Over 8,000 stations across Great Britain, with prices updated daily.", comment: "Onboarding feature row subtitle"))
|
|
|
|
featureRow(icon: "chart.line.uptrend.xyaxis", title: String(localized: "See how much you could save", comment: "Onboarding feature row title"), subtitle: String(localized: "Compare fill-up costs and potential savings at different stations.", comment: "Onboarding feature row subtitle"))
|
|
|
|
featureRow(icon: "car.fill", title: String(localized: "Know if it's worth the drive", comment: "Onboarding feature row title"), subtitle: String(localized: "Factor in your journey and fuel costs to find the best value.", comment: "Onboarding feature row subtitle"))
|
|
|
|
featureRow(icon: "star.fill", title: String(localized: "Keep your favourites close", comment: "Onboarding feature row title"), subtitle: String(localized: "Save stations and quickly compare their latest prices.", comment: "Onboarding feature row subtitle"))
|
|
|
|
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 \"closest\" 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, title: String, subtitle: String) -> some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(Color.accentColor)
|
|
.frame(width: 32)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(.subheadline.bold())
|
|
Text(subtitle)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 36)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
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
|
|
/// The soft circle behind the glyph. The welcome page turns it off: it shows
|
|
/// the app icon itself and reads better with no container around it.
|
|
var showsHalo: Bool = true
|
|
@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 {
|
|
if showsHalo {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|