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:
+20
-191
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user