From a69ee14e078a2545926261faffe1b4bb99628bbc Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Sun, 11 Oct 2026 20:33:30 +0100 Subject: [PATCH] Onboarding: add a Best Value page before the final screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New page 5 of 6, sitting between "Prices, ready when you are" and "You're all set": - Explains the feature: the cheapest pump isn't always the cheapest fill-up. - Asks whether to switch it on, with a toggle. - Switched ON, it asks for the two figures the ranking needs — average fuel economy and typical fill-up — using exactly the Settings slider pattern (read-out above a slider, £/Litres picker, no text entry anywhere). - Switched OFF, it says where to switch Best Value on later ("Settings, under Fuel Savings") instead of showing controls the user has just declined, and the button reads "Not now" rather than "Continue" so declining reads as a decision. Chosen figures are written to the store as the controls move — the same immediate-persist rule as Settings — so swiping past the page cannot lose a choice. ContentView now reloads the stored savings settings when onboarding dismisses: onboarding is a full-screen cover writing straight to the store rather than through ContentView's binding, so without the reload the choice would not take effect until the next launch. QA hooks (DEBUG only, alongside -tab / -skipOnboarding): -showOnboarding forces the cover up (the app-group onboarding flag survives an app delete, so a reinstall alone won't bring it back), -onboardingPage N opens straight on a page, -onboardingBestValue on|off forces the page's choice. Verified on the simulator: with the toggle off the page shows the title, explanation, toggle, the "set it up in Settings" note and a "Not now" button; with it on it shows the MPG slider (45 MPG), the £/Litres picker, the fill-up slider (£40), the "change these in Settings" note and "Continue". Page 5 is still "You're all set". Checked on an iPhone 17 Pro and on an iPhone SE (3rd gen) screen — nothing clips on the small one. 172 tests, 0 failures. --- FuelBoard/ContentView.swift | 18 ++- FuelBoard/OnboardingView.swift | 248 +++++++++++++++++++++++++++++++-- 2 files changed, 255 insertions(+), 11 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 3865e5d..e1160ff 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -567,10 +567,19 @@ struct ContentView: View { // touching the stored flag. #if DEBUG let shouldSkipOnboarding = args.contains("-skipOnboarding") + // The onboarding flag lives in app-group defaults, which survive an + // app delete — so a reinstall alone won't bring onboarding back. + // `-showOnboarding` forces it up for the screenshot harness (and + // pairs with OnboardingView's `-onboardingPage` / + // `-onboardingBestValue`). + let shouldForceOnboarding = args.contains("-showOnboarding") #else let shouldSkipOnboarding = false + let shouldForceOnboarding = false #endif - if !FuelStore.shouldShowOnboarding() + if shouldForceOnboarding { + showOnboarding = true + } else if !FuelStore.shouldShowOnboarding() || shouldSkipOnboarding { locationManager.startForegroundTracking() // Geofences and the Live Activity must follow the user even in @@ -612,6 +621,13 @@ struct ContentView: View { // cache-gated refresh skip the fetch and leave the station list // empty on a brand-new install. if !showing, FuelStore.loadHasCompletedOnboarding() { + // Onboarding can switch Best Value on and set the MPG and + // fill-up figures. It is a full-screen cover with its own copy + // of the settings, so it writes straight to the store instead of + // through this view's binding — pull the stored values back in + // or the choice wouldn't take effect until the next launch. + fuelSavingsSettings = FuelStore.loadFuelSavingsSettings() + sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount locationManager.startForegroundTracking() installLocationUpdateHook() ensureBackgroundMotionTrackingIfNeeded() diff --git a/FuelBoard/OnboardingView.swift b/FuelBoard/OnboardingView.swift index 8c102ef..a046c68 100644 --- a/FuelBoard/OnboardingView.swift +++ b/FuelBoard/OnboardingView.swift @@ -3,20 +3,26 @@ import CoreLocation import UserNotifications import Network -/// First-launch onboarding: introduces FuelBoard, then walks the user through -/// the two system permissions (location and notifications) with in-context -/// prompts. Price data downloads over HTTPS with no Local Network permission. -/// In production it appears once at -/// initial launch (driven by `FuelStore.loadHasCompletedOnboarding()`); a -/// test button in the Alerts tab re-opens it anytime. +/// 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. struct OnboardingView: View { var onFinish: () -> Void @Environment(\.dismiss) private var dismiss @StateObject private var prompter = OnboardingPermissionPrompter() - @State private var page = 0 + @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, and saved 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 let totalPages = 5 + private static let totalPages = 6 var body: some View { VStack(spacing: 0) { @@ -24,7 +30,7 @@ struct OnboardingView: View { HStack { Spacer() HStack(spacing: 8) { - ForEach(0.., + range: ClosedRange, + step: Double) -> some View { + VStack(spacing: 2) { + HStack { + Text(title) + .font(.subheadline) + Spacer() + Text(readOut) + .font(.subheadline) + .monospacedDigit() + .foregroundStyle(.secondary) + } + Slider(value: value, in: range, step: step) + .accessibilityLabel(title) + .accessibilityValue(readOut) + } + } + + private var bestValueEnabledBinding: Binding { + Binding( + get: { fuelSavings.enabled }, + set: { fuelSavings.enabled = $0; persistFuelSavings() } + ) + } + + private var bestValueMPGBinding: Binding { + Binding( + get: { fuelSavings.mpg }, + set: { + fuelSavings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG) + persistFuelSavings() + } + ) + } + + private var bestValueAmountBinding: Binding { + Binding( + get: { fuelSavings.preferredAmount.value }, + set: { + fuelSavings.preferredAmount.value = min( + max($0, 0), + FuelSavingsCalculator.maximumFillAmount(for: fuelSavings.preferredAmount.unit) + ) + persistFuelSavings() + } + ) + } + + private var bestValueUnitBinding: Binding { + Binding( + get: { fuelSavings.preferredAmount.unit }, + set: { newUnit in + fuelSavings.preferredAmount.unit = newUnit + // Same guard as Settings: a figure valid in one unit may be + // valid in neither after the switch, so fall back to a sensible + // default for the new unit rather than storing junk. + if !fuelSavings.preferredAmount.isValid { + fuelSavings.preferredAmount.value = newUnit == .pounds ? 40 : 30 + } + persistFuelSavings() + } + ) + } + + /// "45 MPG" / "45.5 MPG" — the slider is the only control, so this read-out + /// is the one place the figure is stated. + private var mpgText: String { + let mpg = fuelSavings.mpg + return mpg.rounded() == mpg ? String(format: "%.0f MPG", mpg) : String(format: "%.1f MPG", mpg) + } + + /// "£40" / "40 L" — the fill-up slider's read-out. + private var fillUpText: String { + switch fuelSavings.preferredAmount.unit { + case .pounds: return String(format: "£%.0f", fuelSavings.preferredAmount.value) + case .litres: return String(format: "%.0f L", fuelSavings.preferredAmount.value) + } + } + + private func persistFuelSavings() { + FuelStore.saveFuelSavingsSettings(fuelSavings) + } + // MARK: - Bottom action @ViewBuilder @@ -267,11 +450,23 @@ struct OnboardingView: View { 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 button states the answer the toggle is showing, so + // declining reads as a decision rather than a skip. Either way the + // settings were saved when the toggle moved, so a swipe past this + // page keeps the choice. + primaryButton(bestValueButtonTitle) { page = 5 } default: primaryButton(String(localized: "Start Using FuelBoard", comment: "Finish onboarding button")) { finish() } } } + private var bestValueButtonTitle: String { + fuelSavings.enabled + ? String(localized: "Continue", comment: "Onboarding continue button") + : String(localized: "Not now", comment: "Onboarding Best Value declined button") + } + private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View { Button(action: action) { Text(title) @@ -326,6 +521,39 @@ struct OnboardingView: View { 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.. 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 + } } private struct AnimatedOnboardingIcon: View {