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 {