Files
fuelboard/FuelBoard/BestValueIntroView.swift
T
FuelBoard Contributor bfa9209d68 Best Value: announce the page to existing users, once per announcement version
Users who finished onboarding before Best Value existed are "completed, not
fresh" forever, so the first-run gate never fires again for them and the feature
stayed invisible unless they went looking in Settings. They now get the Best
Value page on its own, once per announcement version — deliberately NOT a replay
of the onboarding flow, which would re-run permission pages they answered long
ago and has no skip.

- FuelStore: "fuelboard.bestValueIntroVersion", stored through the same
  saveString/loadString path as the onboarding flag and compared against a
  CODE-SIDE "currentBestValueIntroVersion". Deliberately not
  CFBundleShortVersionString: keying on the bundle version would re-announce on
  every bugfix release. Bump the constant to re-announce on purpose.
- FuelStore.shouldAnnounceBestValue(settings:hasSeen:onboardingDue:isSuppressed:)
  holds the rule, with every fact injectable so it is unit-tested instead of
  being driven through a simulator. It declines when onboarding is due or on
  screen, when this announcement version has been seen, and when the user has
  already configured Best Value themselves.
- BestValueAnnouncementHost wraps the app's content and owns the presentation
  state, so ContentView's change is a single wrapper and the whole feature lives
  in the new file. It reloads the stored settings on dismiss, because the page
  writes straight to the store rather than through ContentView's binding.
- The page is now ONE view, BestValueIntroContent, used by first-run onboarding
  (page 5 of 6) and by the announcement, so the copy, the sliders and the
  "declining is a decision" rule cannot drift. OnboardingView drops its private
  duplicate of the page, its bindings and its read-outs, and finish() marks the
  announcement seen so a new user never sees the page twice.

QA hooks (DEBUG): -showBestValueIntro forces the announcement, and -skipOnboarding
suppresses it. Worth knowing now that a returning install announces on a PLAIN
launch, so tab screenshots need -skipOnboarding.

Verified on the simulator (OCR of the rendered screens): a plain launch of an
install that has completed onboarding shows the single announcement page — Best
Value title, explanation, "Turn on Best Value", the "Settings, under Fuel
Savings" note and a "Not now" button, with no page dots; -skipOnboarding shows
no cover at all; -showBestValueIntro forces it. 180 tests, 0 failures (8 new).
2026-10-11 20:53:09 +01:00

314 lines
12 KiB
Swift

import SwiftUI
/// The Best Value page body: what the feature does, whether to switch it on, and
/// — only when it is 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).
///
/// Shared deliberately. First-run onboarding shows it as one of its pages, and
/// existing users who updated into the feature get the same page as a
/// once-per-announcement-version announcement (`BestValueIntroView`). Two copies
/// would drift the first time either was edited, so the copy, the controls and
/// the "declining is a decision" rule all live here.
///
/// Settings are written to the store as the controls move — the same
/// immediate-persist rule as Settings — so a host dismissed mid-page cannot lose
/// a choice the user has already made.
struct BestValueIntroContent: View {
@Binding var settings: FuelSavingsSettings
/// What the host's primary button should say. Declining is a first-class
/// answer: "Not now" reads as a decision where "Continue" reads as a skip.
static func primaryTitle(for settings: FuelSavingsSettings) -> String {
settings.enabled
? String(localized: "Continue", comment: "Best Value continue button")
: String(localized: "Not now", comment: "Best Value declined button")
}
var body: 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: "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: "Best Value explanation"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 28)
.padding(.top, 10)
card
.padding(.horizontal, 24)
.padding(.top, 16)
Spacer(minLength: 8)
}
}
private var card: some View {
VStack(spacing: 14) {
Toggle(isOn: enabledBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "Turn on Best Value", comment: "Best Value toggle"))
.font(.headline)
Text(String(localized: "Sort by what a station saves you, travel included.", comment: "Best Value toggle detail"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if settings.enabled {
slider(
title: String(localized: "Average fuel economy", comment: "Best Value MPG slider label"),
readOut: mpgText,
value: mpgBinding,
range: FuelSavingsCalculator.mpgSliderRange,
step: FuelSavingsCalculator.mpgSliderStep
)
Picker("Fill-up unit", selection: unitBinding) {
ForEach(FuelSavingsFillUnit.allCases) { unit in
Text(unit.displayName).tag(unit)
}
}
.pickerStyle(.segmented)
slider(
title: String(localized: "Typical fill-up", comment: "Best Value fill-up slider label"),
readOut: fillUpText,
value: amountBinding,
range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit),
step: FuelSavingsCalculator.fillAmountSliderStep(for: settings.preferredAmount.unit)
)
Text(String(localized: "Best Value uses these, and can be changed any time in Settings.", comment: "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: "Best Value declined note"))
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
}
/// 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 slider(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)
}
}
// MARK: Bindings (persist immediately, exactly like Settings)
private var enabledBinding: Binding<Bool> {
Binding(
get: { settings.enabled },
set: { settings.enabled = $0; persist() }
)
}
private var mpgBinding: Binding<Double> {
Binding(
get: { settings.mpg },
set: {
settings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG)
persist()
}
)
}
private var amountBinding: Binding<Double> {
Binding(
get: { settings.preferredAmount.value },
set: {
settings.preferredAmount.value = min(
max($0, 0),
FuelSavingsCalculator.maximumFillAmount(for: settings.preferredAmount.unit)
)
persist()
}
)
}
private var unitBinding: Binding<FuelSavingsFillUnit> {
Binding(
get: { settings.preferredAmount.unit },
set: { newUnit in
settings.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 !settings.preferredAmount.isValid {
settings.preferredAmount.value = newUnit == .pounds ? 40 : 30
}
persist()
}
)
}
private func persist() {
FuelStore.saveFuelSavingsSettings(settings)
}
/// "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 = settings.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 settings.preferredAmount.unit {
case .pounds: return String(format: "£%.0f", settings.preferredAmount.value)
case .litres: return String(format: "%.0f L", settings.preferredAmount.value)
}
}
}
/// The announcement existing users see once per announcement version: the Best
/// Value page on its own. Deliberately NOT the onboarding pager — no page dots
/// and no permission pages, because those users answered all of that long ago
/// and the full flow is mandatory with no skip.
struct BestValueIntroView: View {
var onFinish: () -> Void
@State private var settings = FuelStore.loadFuelSavingsSettings()
var body: some View {
VStack(spacing: 0) {
BestValueIntroContent(settings: $settings)
OnboardingPrimaryButton(title: BestValueIntroContent.primaryTitle(for: settings),
action: finish)
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
.background(
LinearGradient(
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
startPoint: .top, endPoint: .bottom
)
.ignoresSafeArea()
)
}
private func finish() {
// Marked on the way OUT, so a crash or force-quit mid-page re-shows the
// announcement instead of silently swallowing it.
FuelStore.markBestValueIntroSeen()
onFinish()
}
}
/// Presents the Best Value announcement over the app, exactly once per
/// announcement version. It wraps whatever it is given, so the whole feature —
/// state, gate and dismissal — lives in this file and ContentView only has to
/// install it.
///
/// The gate, in order:
/// - suppressed under `-skipOnboarding` (DEBUG), so screenshot runs can open a
/// tab without a cover interrupting the capture;
/// - forced by `-showBestValueIntro` (DEBUG);
/// - never while first-run onboarding is due or on screen — that flow contains
/// the same page, and `OnboardingView.finish` marks this seen;
/// - never when the stored announcement version is current;
/// - never for someone who has already configured Best Value themselves.
struct BestValueAnnouncementHost<Content: View>: View {
/// True while the first-run onboarding cover is up: the announcement must
/// not fight it for the screen.
var isSuppressed: Bool = false
var onDismiss: () -> Void = {}
@ViewBuilder var content: Content
@State private var presenting = false
var body: some View {
content
.fullScreenCover(isPresented: $presenting) {
BestValueIntroView {
presenting = false
}
}
// `task`, not `onAppear`: runs once when the app's content appears,
// after ContentView has made its onboarding decision.
.task {
guard !presenting, shouldAnnounce else { return }
presenting = true
}
.onChange(of: presenting) { _, showing in
guard !showing else { return }
// The page writes straight to the store — it is its own cover
// with its own copy of the settings — so the app's in-memory
// copy has to be pulled back in, or an announcement choice
// wouldn't apply until the next launch.
onDismiss()
}
}
private var shouldAnnounce: Bool {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
if args.contains("-skipOnboarding") { return false }
if args.contains("-showBestValueIntro") { return true }
#endif
// The rule itself lives in the store so it can be unit-tested — see
// `FuelStore.shouldAnnounceBestValue`.
return FuelStore.shouldAnnounceBestValue(
settings: FuelStore.loadFuelSavingsSettings(),
isSuppressed: isSuppressed
)
}
}
/// The prominent button both Best Value hosts use, so they can't drift apart.
struct OnboardingPrimaryButton: View {
var title: String
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
}
}