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 says. ONE label for both answers: the /// toggle already shows whether Best Value is being switched on, so a /// separate "Not now" for the declined case was second-guessing a choice the /// user could see on screen. static var primaryTitle: String { String(localized: "Continue", comment: "Best Value continue 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, 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) } }