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).
This commit is contained in:
FuelBoard Contributor
2026-10-11 20:53:09 +01:00
parent a69ee14e07
commit bfa9209d68
5 changed files with 489 additions and 192 deletions
+20 -191
View File
@@ -10,6 +10,11 @@ import Network
/// 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
@@ -17,9 +22,9 @@ struct OnboardingView: View {
@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, 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.
/// 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
@@ -213,90 +218,10 @@ 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.
/// 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 {
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)
}
BestValueIntroContent(settings: $fuelSavings)
}
private var donePage: some View {
@@ -326,96 +251,6 @@ 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
@@ -455,26 +290,14 @@ struct OnboardingView: View {
// 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 }
primaryButton(BestValueIntroContent.primaryTitle(for: fuelSavings)) { 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)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
OnboardingPrimaryButton(title: title, action: action)
}
private func featureRow(icon: String, text: String) -> some View {
@@ -518,6 +341,10 @@ struct OnboardingView: View {
}
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()
}
@@ -556,7 +383,9 @@ struct OnboardingView: View {
}
}
private struct AnimatedOnboardingIcon<Content: View>: View {
/// 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