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).
314 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|