diff --git a/FuelBoard/BestValueIntroView.swift b/FuelBoard/BestValueIntroView.swift new file mode 100644 index 0000000..b71a79f --- /dev/null +++ b/FuelBoard/BestValueIntroView.swift @@ -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, + range: ClosedRange, + 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 { + Binding( + get: { settings.enabled }, + set: { settings.enabled = $0; persist() } + ) + } + + private var mpgBinding: Binding { + Binding( + get: { settings.mpg }, + set: { + settings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG) + persist() + } + ) + } + + private var amountBinding: Binding { + Binding( + get: { settings.preferredAmount.value }, + set: { + settings.preferredAmount.value = min( + max($0, 0), + FuelSavingsCalculator.maximumFillAmount(for: settings.preferredAmount.unit) + ) + persist() + } + ) + } + + private var unitBinding: Binding { + 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: 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) + } +} diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index e1160ff..04c3ef1 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -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 diff --git a/FuelBoard/OnboardingView.swift b/FuelBoard/OnboardingView.swift index a046c68..eb5bec1 100644 --- a/FuelBoard/OnboardingView.swift +++ b/FuelBoard/OnboardingView.swift @@ -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, - range: ClosedRange, - 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 { - Binding( - get: { fuelSavings.enabled }, - set: { fuelSavings.enabled = $0; persistFuelSavings() } - ) - } - - private var bestValueMPGBinding: Binding { - Binding( - get: { fuelSavings.mpg }, - set: { - fuelSavings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG) - persistFuelSavings() - } - ) - } - - private var bestValueAmountBinding: Binding { - Binding( - get: { fuelSavings.preferredAmount.value }, - set: { - fuelSavings.preferredAmount.value = min( - max($0, 0), - FuelSavingsCalculator.maximumFillAmount(for: fuelSavings.preferredAmount.unit) - ) - persistFuelSavings() - } - ) - } - - private var bestValueUnitBinding: Binding { - 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: 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: View { enum Motion { case gentle case locationArrow diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/BestValueAnnouncementTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/BestValueAnnouncementTests.swift new file mode 100644 index 0000000..315fd6a --- /dev/null +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/BestValueAnnouncementTests.swift @@ -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) + } +} diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index a4cd888..aa47b74 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -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