Onboarding: add a Best Value page before the final screen

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.
This commit is contained in:
FuelBoard Contributor
2026-10-11 20:33:30 +01:00
parent f1d9e9591d
commit a69ee14e07
2 changed files with 255 additions and 11 deletions
+238 -10
View File
@@ -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..<totalPages, id: \.self) { index in
ForEach(0..<Self.totalPages, id: \.self) { index in
Circle()
.fill(index == page ? Color.accentColor : Color.gray.opacity(0.25))
.frame(width: 8, height: 8)
@@ -40,7 +46,8 @@ struct OnboardingView: View {
locationPage.tag(1)
notificationsPage.tag(2)
dataPage.tag(3)
donePage.tag(4)
bestValuePage.tag(4)
donePage.tag(5)
}
.tabViewStyle(.page(indexDisplayMode: .never))
@@ -206,6 +213,92 @@ struct OnboardingView: View {
}
}
/// Best Value: what it does, whether to switch it on, and — only if it is
/// switched on — the two figures the ranking needs, entered with the same
/// sliders Settings uses (one control, no text entry, so a first-run user
/// can't stall on a keyboard). Declining is a first-class answer: the page
/// says where to switch it on later rather than showing controls the user
/// has just said they don't want.
private var bestValuePage: some View {
VStack(spacing: 0) {
Spacer(minLength: 8)
AnimatedOnboardingIcon(tint: .green) {
Image(systemName: "sterlingsign.circle.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.green)
}
.padding(.bottom, 20)
Text(String(localized: "Best Value", comment: "Onboarding Best Value title"))
.font(.largeTitle.bold())
Text(String(localized: "The cheapest pump isn't always the cheapest fill-up — driving further burns fuel. Best Value sorts stations by what you'd really save once the trip is counted.", comment: "Onboarding Best Value explanation"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 28)
.padding(.top, 10)
VStack(spacing: 14) {
Toggle(isOn: bestValueEnabledBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "Turn on Best Value", comment: "Onboarding Best Value toggle"))
.font(.headline)
Text(String(localized: "Sort by what a station saves you, travel included.", comment: "Onboarding Best Value toggle detail"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if fuelSavings.enabled {
bestValueSlider(
title: String(localized: "Average fuel economy", comment: "Best Value MPG slider label"),
readOut: mpgText,
value: bestValueMPGBinding,
range: FuelSavingsCalculator.mpgSliderRange,
step: FuelSavingsCalculator.mpgSliderStep
)
Picker("Fill-up unit", selection: bestValueUnitBinding) {
ForEach(FuelSavingsFillUnit.allCases) { unit in
Text(unit.displayName).tag(unit)
}
}
.pickerStyle(.segmented)
bestValueSlider(
title: String(localized: "Typical fill-up", comment: "Best Value fill-up slider label"),
readOut: fillUpText,
value: bestValueAmountBinding,
range: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavings.preferredAmount.unit),
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavings.preferredAmount.unit)
)
Text(String(localized: "Best Value uses these, and can be changed any time in Settings.", comment: "Onboarding Best Value configured note"))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
} else {
Text(String(localized: "That's fine — you can turn Best Value on whenever you like in Settings, under Fuel Savings.", comment: "Onboarding Best Value declined note"))
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
.padding(.horizontal, 24)
.padding(.top, 16)
Spacer(minLength: 8)
}
}
private var donePage: some View {
VStack(spacing: 0) {
Spacer()
@@ -233,6 +326,96 @@ struct OnboardingView: View {
}
}
// MARK: - Best Value controls
/// The sliders are the only controls for these two figures, exactly as in
/// Settings: a labelled read-out above a slider, no text field.
private func bestValueSlider(title: String,
readOut: String,
value: Binding<Double>,
range: ClosedRange<Double>,
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<Bool> {
Binding(
get: { fuelSavings.enabled },
set: { fuelSavings.enabled = $0; persistFuelSavings() }
)
}
private var bestValueMPGBinding: Binding<Double> {
Binding(
get: { fuelSavings.mpg },
set: {
fuelSavings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG)
persistFuelSavings()
}
)
}
private var bestValueAmountBinding: Binding<Double> {
Binding(
get: { fuelSavings.preferredAmount.value },
set: {
fuelSavings.preferredAmount.value = min(
max($0, 0),
FuelSavingsCalculator.maximumFillAmount(for: fuelSavings.preferredAmount.unit)
)
persistFuelSavings()
}
)
}
private var bestValueUnitBinding: Binding<FuelSavingsFillUnit> {
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..<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
}
}
private struct AnimatedOnboardingIcon<Content: View>: View {