diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index b8b6804..1dc53cd 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -73,6 +73,13 @@ struct SettingsView: View { /// "Accurate distances" — read once on appear and written straight through to /// the shared store, which is what every display surface actually consults. @State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled + /// Slider drafts — the value under the thumb while it is down. `nil` means + /// "not dragging", in which case the stored setting is shown, so an external + /// change (a unit switch, the in-list control, a QA hook) is picked up with + /// no re-seeding. Dragging writes only the draft; the setting is saved once, + /// when the drag ends — see `commitMPG` / `commitFillUp`. + @State private var mpgDraft: Double? + @State private var fillUpDraft: Double? @State private var versionTapCount = 0 @State private var lastVersionTap = Date.distantPast /// Widget diagnostics: the extension's last makeEntry beacon PER intent @@ -132,9 +139,15 @@ struct SettingsView: View { .monospacedDigit() .foregroundStyle(.secondary) } - Slider(value: fuelSavingsMPGBinding, + // Draft-bound: the thumb moves a local value and the + // setting is written once, when the drag ends. Writing + // durable app-wide state on every step is what made this + // slider stutter. + Slider(value: mpgSliderValue, in: FuelSavingsCalculator.mpgSliderRange, - step: FuelSavingsCalculator.mpgSliderStep) + step: FuelSavingsCalculator.mpgSliderStep) { editing in + if !editing { commitMPG() } + } .accessibilityLabel("Average fuel economy") .accessibilityValue(mpgText) Picker("Fill-up unit", selection: fuelSavingsUnitBinding) { @@ -150,9 +163,11 @@ struct SettingsView: View { .monospacedDigit() .foregroundStyle(.secondary) } - Slider(value: fuelSavingsAmountBinding, + Slider(value: fillUpSliderValue, in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit), - step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit)) + step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit)) { editing in + if !editing { commitFillUp() } + } .accessibilityLabel("Typical fill-up") .accessibilityValue(fillUpText) Picker("Minimum saving", selection: $bestValueThresholdGBP) { @@ -710,27 +725,56 @@ struct SettingsView: View { private var fuelSavingsEnabledBinding: Binding { Binding( get: { fuelSavingsSettings.enabled }, - set: { fuelSavingsSettings.enabled = $0; persistFuelSavingsSettings() } + set: { fuelSavingsSettings.enabled = $0; saveFuelSavingsField(.enabled) } ) } - private var fuelSavingsMPGBinding: Binding { + /// Slider values, draft-first: while the thumb is down the draft is what the + /// control and its read-out show; with no drag in progress the store's value + /// shows, so any external change (unit switch, in-list control, QA hook) is + /// reflected without re-seeding anything. + private var mpgSliderValue: Binding { Binding( - get: { fuelSavingsSettings.mpg }, - set: { fuelSavingsSettings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG); persistFuelSavingsSettings() } + get: { mpgDraft ?? fuelSavingsSettings.mpg }, + set: { mpgDraft = $0 } ) } - private var fuelSavingsAmountBinding: Binding { + private var fillUpSliderValue: Binding { Binding( - get: { fuelSavingsSettings.preferredAmount.value }, - set: { - fuelSavingsSettings.preferredAmount.value = min(max($0, 0), FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit)) - persistFuelSavingsSettings() - } + get: { fillUpDraft ?? fuelSavingsSettings.preferredAmount.value }, + set: { fillUpDraft = $0 } ) } + /// The drag has ended: write the value once, and only if it actually moved. + /// The draft is cleared either way, so the control falls back to the stored + /// setting and shows what was saved — the clamps can lower it. + private func commitMPG() { + defer { mpgDraft = nil } + guard let draft = mpgDraft else { return } + let mpg = min(max(draft, 0), FuelSavingsCalculator.maximumMPG) + guard mpg != fuelSavingsSettings.mpg else { return } + fuelSavingsSettings.mpg = mpg + saveFuelSavingsField(.mpg) + } + + private func commitFillUp() { + defer { fillUpDraft = nil } + guard let draft = fillUpDraft else { return } + let amount = min(max(draft, 0), + FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit)) + guard amount != fuelSavingsSettings.preferredAmount.value else { return } + fuelSavingsSettings.preferredAmount.value = amount + saveFuelSavingsField(.amount) + } + + /// Persists the one field that changed. Saving the whole settings object + /// rewrites all four keys, which is only right when more than one has moved. + private func saveFuelSavingsField(_ field: FuelStore.FuelSavingsField) { + FuelStore.saveFuelSavingsField(field, from: fuelSavingsSettings) + } + private var fuelSavingsUnitBinding: Binding { Binding( get: { fuelSavingsSettings.preferredAmount.unit }, @@ -739,30 +783,32 @@ struct SettingsView: View { fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount( afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit) settingsUnitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up." - persistFuelSavingsSettings() + // The unit and the amount move together, so both keys are written. + saveFuelSavingsField(.unit) + saveFuelSavingsField(.amount) + // A drag in progress on the old scale no longer maps to this one. + fillUpDraft = nil } ) } /// "45 MPG" / "45.5 MPG". The slider is the only control, so this read-out - /// is the one place the figure is stated. + /// is the one place the figure is stated. It follows the draft while the + /// thumb is down. private var mpgText: String { - let mpg = fuelSavingsSettings.mpg + let mpg = mpgDraft ?? fuelSavingsSettings.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. + /// "£40" / "40 L" — the fill-up slider's read-out, draft-first like the MPG one. private var fillUpText: String { + let value = fillUpDraft ?? fuelSavingsSettings.preferredAmount.value switch fuelSavingsSettings.preferredAmount.unit { - case .pounds: return String(format: "£%.0f", fuelSavingsSettings.preferredAmount.value) - case .litres: return String(format: "%.0f L", fuelSavingsSettings.preferredAmount.value) + case .pounds: return String(format: "£%.0f", value) + case .litres: return String(format: "%.0f L", value) } } - private func persistFuelSavingsSettings() { - FuelStore.saveFuelSavingsSettings(fuelSavingsSettings) - } - /// "API" when the relay is serving the official Fuel Finder API, "CSV" /// when it fell back to the public mirror, "—" when unknown/never fetched. private var connectionText: String {