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:
@@ -0,0 +1,313 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -408,7 +408,22 @@ struct ContentView: View {
|
||||
|
||||
private var presentedContent: AnyView {
|
||||
AnyView(
|
||||
mainContent
|
||||
// The Best Value announcement for users who updated into the feature
|
||||
// (see `BestValueAnnouncementHost`): a one-page cover, shown once per
|
||||
// announcement version, never while first-run onboarding is due.
|
||||
BestValueAnnouncementHost(
|
||||
isSuppressed: showOnboarding,
|
||||
onDismiss: {
|
||||
// The page writes straight to the store — it is its own cover
|
||||
// with its own copy of the settings — so pull the stored
|
||||
// values back in, or the choice wouldn't take effect until
|
||||
// the next launch.
|
||||
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
||||
}
|
||||
) {
|
||||
mainContent
|
||||
}
|
||||
.fullScreenCover(isPresented: $showOnboarding) {
|
||||
OnboardingView {
|
||||
showOnboarding = false
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import XCTest
|
||||
@testable import FuelBoardShared
|
||||
|
||||
/// The Best Value announcement gate — shown to users who updated into the
|
||||
/// feature after finishing onboarding, once per announcement version.
|
||||
///
|
||||
/// Every fact the rule depends on is injectable, so the whole truth table is
|
||||
/// checked here instead of driving a simulator through four combinations (which
|
||||
/// is how a rule like this quietly rots).
|
||||
final class BestValueAnnouncementTests: XCTestCase {
|
||||
|
||||
/// Nothing set up: the case that should be announced to.
|
||||
private var unconfigured: FuelSavingsSettings { FuelSavingsSettings.defaults }
|
||||
|
||||
/// Enabled with the default 45 MPG / £40, which is a valid configuration.
|
||||
private var configured: FuelSavingsSettings {
|
||||
var settings = FuelSavingsSettings.defaults
|
||||
settings.enabled = true
|
||||
return settings
|
||||
}
|
||||
|
||||
func testAnnouncesToAnExistingUserWhoHasNotSeenIt() {
|
||||
XCTAssertTrue(
|
||||
FuelStore.shouldAnnounceBestValue(settings: unconfigured,
|
||||
hasSeen: false,
|
||||
onboardingDue: false,
|
||||
isSuppressed: false),
|
||||
"an existing user, nothing configured, announcement unseen: show it"
|
||||
)
|
||||
}
|
||||
|
||||
func testNeverAnnouncesTwice() {
|
||||
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
|
||||
hasSeen: true,
|
||||
onboardingDue: false))
|
||||
}
|
||||
|
||||
func testNeverWhileOnboardingIsDue() {
|
||||
// A new install runs the full onboarding, which already contains this
|
||||
// page — showing both would be the obvious first bug.
|
||||
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
|
||||
hasSeen: false,
|
||||
onboardingDue: true))
|
||||
}
|
||||
|
||||
func testNeverWhileOnboardingIsOnScreen() {
|
||||
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
|
||||
hasSeen: false,
|
||||
onboardingDue: false,
|
||||
isSuppressed: true))
|
||||
}
|
||||
|
||||
func testNeverForSomebodyWhoAlreadyConfiguredIt() {
|
||||
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: configured,
|
||||
hasSeen: false,
|
||||
onboardingDue: false),
|
||||
"never announce a feature the user set up themselves")
|
||||
}
|
||||
|
||||
func testStillAnnouncesWhenEnabledButNotActuallyConfigured() {
|
||||
// Enabled with an out-of-range MPG is NOT configured, so the
|
||||
// announcement still has something useful to offer.
|
||||
var settings = FuelSavingsSettings.defaults
|
||||
settings.enabled = true
|
||||
settings.mpg = 2 // below the validity floor
|
||||
XCTAssertTrue(FuelStore.shouldAnnounceBestValue(settings: settings,
|
||||
hasSeen: false,
|
||||
onboardingDue: false))
|
||||
}
|
||||
|
||||
func testMarkingSeenIsWhatStopsTheNextLaunch() {
|
||||
// The stored contract the gate reads.
|
||||
FuelStore.markBestValueIntroSeen()
|
||||
XCTAssertTrue(FuelStore.hasSeenBestValueIntro())
|
||||
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
|
||||
onboardingDue: false))
|
||||
}
|
||||
|
||||
func testTheStoredValueIsTheAnnouncementVersionNotTheAppVersion() {
|
||||
// Keyed on the announcement version so a bugfix release cannot
|
||||
// re-announce anything. Bump `currentBestValueIntroVersion` only when
|
||||
// Best Value changes in a way worth re-explaining — this assertion is
|
||||
// the reminder that doing so re-shows the page to everyone.
|
||||
XCTAssertEqual(FuelStore.currentBestValueIntroVersion, 1)
|
||||
}
|
||||
}
|
||||
@@ -1206,6 +1206,60 @@ struct FuelStore {
|
||||
return !loadHasCompletedOnboarding() || fresh
|
||||
}
|
||||
|
||||
// MARK: Best Value announcement (existing users who updated into it)
|
||||
|
||||
/// Users who finished onboarding before Best Value existed are
|
||||
/// `completed == true, fresh == false` for good, so the first-run gate above
|
||||
/// never fires again for them and the feature would stay invisible unless
|
||||
/// they went looking in Settings. This flag drives a single-page
|
||||
/// announcement instead of replaying the whole flow (which would re-run
|
||||
/// permission pages they have already answered).
|
||||
///
|
||||
/// Stores the announcement version SEEN, compared against
|
||||
/// `currentBestValueIntroVersion` below — deliberately not the app version,
|
||||
/// so a bugfix release never re-announces anything.
|
||||
static let bestValueIntroVersionKey = "fuelboard.bestValueIntroVersion"
|
||||
|
||||
/// Bump this when the Best Value announcement should be shown again to
|
||||
/// everyone — e.g. the feature changes in a way worth re-explaining. Anyone
|
||||
/// whose stored value differs (including someone who dismissed an earlier
|
||||
/// announcement) sees it once more on the next launch.
|
||||
static let currentBestValueIntroVersion = 1
|
||||
|
||||
static func hasSeenBestValueIntro() -> Bool {
|
||||
(loadString(service: bestValueIntroVersionKey) ?? "")
|
||||
== String(currentBestValueIntroVersion)
|
||||
}
|
||||
|
||||
static func markBestValueIntroSeen() {
|
||||
saveString(String(currentBestValueIntroVersion), service: bestValueIntroVersionKey)
|
||||
}
|
||||
|
||||
/// Whether the standalone Best Value announcement should be offered.
|
||||
///
|
||||
/// The rule lives here rather than inside the view so it can be unit-tested:
|
||||
/// a view-embedded gate can only be checked by driving a simulator through
|
||||
/// every combination, which is exactly how a rule like this rots. The facts
|
||||
/// it depends on are injectable for the same reason.
|
||||
///
|
||||
/// It declines in four cases, each for a different reason:
|
||||
/// - `isSuppressed` — the first-run onboarding cover is up and must not be
|
||||
/// fought for the screen;
|
||||
/// - `onboardingDue` — a new install is about to run the full flow, which
|
||||
/// already contains this page (`OnboardingView.finish` also marks it seen);
|
||||
/// - `hasSeen` — this announcement version has already been shown;
|
||||
/// - the user has Best Value configured — never announce to someone who set
|
||||
/// it up themselves.
|
||||
static func shouldAnnounceBestValue(settings: FuelSavingsSettings,
|
||||
hasSeen: Bool = hasSeenBestValueIntro(),
|
||||
onboardingDue: Bool = shouldShowOnboarding(),
|
||||
isSuppressed: Bool = false) -> Bool {
|
||||
guard !isSuppressed else { return false }
|
||||
guard !onboardingDue else { return false }
|
||||
guard !hasSeen else { return false }
|
||||
return !settings.isConfigured
|
||||
}
|
||||
|
||||
// MARK: Road distances (Apple-Maps-matched, computed by the app)
|
||||
|
||||
/// Cached road/routed distances (metres) keyed by station ID, computed by
|
||||
|
||||
Reference in New Issue
Block a user