Rewrite the Settings screen in short British English and remove the duplicated paragraphs, keeping every control, binding and preference key as it was. - Fuel Savings: one-line descriptions for Best Value, average fuel economy, fill-up amount (worded for the unit in use) and minimum saving; one concise general explanation replaces the long footer; the orange "New comparison basis" notice is removed. - Units: "Station sign" becomes "Pence per litre"; the combined paragraph becomes two contextual descriptions. - Distances: "Accurate distances" becomes "Driving distances" with a short description; the route-request and search-radii paragraph is removed. - Tap action: "Open map"/"More info" become "Directions"/"Station details" with a short description. - Data checking: Standard and Smart summaries cut to one line each, one shared background-update note, duplicated footer removed. - Replay onboarding: shorter description. Support FuelBoard untouched. Copy and layout only: no calculations, ranking, refresh schedules, stored values or persistence changed. New and reworded literals live in FuelBoard/en.lproj/Localizable.strings.
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 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: "Fill-up amount", 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,
|
|
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)
|
|
}
|
|
}
|