Compare commits

..
2 Commits
Author SHA1 Message Date
FuelBoard Contributor d2d4179ab6 Settings sliders: commit on release instead of writing on every step
Both sliders now move a local draft (mpgDraft / fillUpDraft) and write once,
when the drag ends, via Slider's onEditingChanged. While the thumb is down only
this screen redraws: no store write, no fuelSavingsSettings mutation, and so no
app-wide recompute — ContentView re-ran its body, rebuilt the savings lookup,
and passed all 8,130 stations through the alert monitor on every step.

The drafts are Double?, where nil means "not dragging" and the stored setting is
shown. That falls back naturally after a commit, so the control shows what was
actually saved (the clamps can lower it) and anything changing the setting from
outside — a unit switch, the in-list control, a QA hook — is reflected without
any re-seeding.

The MPG and fill-up read-outs follow the draft, so the figure still tracks the
thumb.

The keychain write deliberately stays on the main thread: it is the
authoritative mirror on read, so an async write could hand a relaunch or a
widget a stale value. One write per gesture is cheap enough not to need it.
2026-10-16 20:33:10 +01:00
FuelBoard Contributor 965a506a43 Store: save one Fuel Savings field per edit, and update keychain items in place
The Settings sliders called persistFuelSavingsSettings() on every value change,
which rewrote all four keys — four UserDefaults writes and four keychain round
trips per step, on the main thread, up to 90 times per drag. That is what made
them stutter.

saveFuelSavingsField(_:from:) writes just the field that moved, so a slider
gesture touches one key.

writeKeychain now uses SecItemUpdate and only falls back to SecItemAdd on
errSecItemNotFound. Delete-then-add cost two round trips to securityd and
briefly left the key holding no value at all — a window where the keychain, the
authoritative mirror on read, could disagree with the app-group defaults.

Tests pin the store half: a single-field save leaves the other three keys alone,
and the whole-settings save still writes all four.
2026-10-16 20:33:10 +01:00
3 changed files with 161 additions and 26 deletions
+70 -24
View File
@@ -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<Bool> {
Binding(
get: { fuelSavingsSettings.enabled },
set: { fuelSavingsSettings.enabled = $0; persistFuelSavingsSettings() }
set: { fuelSavingsSettings.enabled = $0; saveFuelSavingsField(.enabled) }
)
}
private var fuelSavingsMPGBinding: Binding<Double> {
/// 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<Double> {
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<Double> {
private var fillUpSliderValue: Binding<Double> {
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<FuelSavingsFillUnit> {
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 {
@@ -357,6 +357,68 @@ final class AlertsFuelTests: XCTestCase {
}
}
/// The Settings sliders save ONE field per gesture instead of rewriting all four
/// keys (four UserDefaults writes plus four keychain round trips per drag is what
/// made them stutter). These pin the store half of that: a single-field save must
/// leave the other three keys exactly as they were.
final class FuelSavingsFieldSaveTests: XCTestCase {
private func seed() {
FuelStore.saveFuelSavingsSettings(FuelSavingsSettings(
enabled: true,
mpg: 40,
preferredAmount: FuelSavingsFillAmount(value: 50, unit: .pounds)
))
}
func testSavingMPGLeavesTheOtherFieldsAlone() {
seed()
var edited = FuelStore.loadFuelSavingsSettings()
edited.mpg = 62
FuelStore.saveFuelSavingsField(.mpg, from: edited)
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertEqual(reloaded.mpg, 62, "the edited field is written")
XCTAssertTrue(reloaded.enabled, "enabled survives an MPG save")
XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an MPG save")
XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives an MPG save")
}
func testSavingFillAmountLeavesMPGAndUnitAlone() {
seed()
var edited = FuelStore.loadFuelSavingsSettings()
edited.preferredAmount.value = 75
FuelStore.saveFuelSavingsField(.amount, from: edited)
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertEqual(reloaded.preferredAmount.value, 75, "the edited field is written")
XCTAssertEqual(reloaded.mpg, 40, "MPG survives a fill-up save")
XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives a fill-up save")
}
func testSavingEnabledLeavesTheNumbersAlone() {
seed()
var edited = FuelStore.loadFuelSavingsSettings()
edited.enabled = false
FuelStore.saveFuelSavingsField(.enabled, from: edited)
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertFalse(reloaded.enabled, "the edited field is written")
XCTAssertEqual(reloaded.mpg, 40, "MPG survives an enabled save")
XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an enabled save")
}
func testWholeSettingsSaveStillWritesEveryField() {
seed()
FuelStore.saveFuelSavingsSettings(FuelSavingsSettings(
enabled: false,
mpg: 55,
preferredAmount: FuelSavingsFillAmount(value: 30, unit: .litres)
))
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertFalse(reloaded.enabled)
XCTAssertEqual(reloaded.mpg, 55)
XCTAssertEqual(reloaded.preferredAmount.value, 30)
XCTAssertEqual(reloaded.preferredAmount.unit, .litres)
}
}
final class AlertRadiusFollowTests: XCTestCase {
func testAlertRadiusOptionsAreMileFriendly() {
// 1–2 city, 3 town, 5 default, 8 motorway — the geofence ceiling.
+29 -2
View File
@@ -850,6 +850,26 @@ struct FuelStore {
saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
}
/// One field of the Fuel Savings settings. A control that edits a single
/// value saves just that field, so moving the MPG slider rewrites the MPG key
/// instead of all four — four UserDefaults writes and four keychain round
/// trips per gesture is what made those sliders stutter.
enum FuelSavingsField {
case enabled
case mpg
case amount
case unit
}
static func saveFuelSavingsField(_ field: FuelSavingsField, from settings: FuelSavingsSettings) {
switch field {
case .enabled: saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey)
case .mpg: saveString(String(settings.mpg), service: fuelSavingsMPGKey)
case .amount: saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey)
case .unit: saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
}
}
/// Superscript digit glyphs for the pounds & pence format's small raised
/// third digit (the forecourt style: "£1.29⁹").
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
@@ -1719,11 +1739,18 @@ struct FuelStore {
}
private static func writeKeychain(data: Data, service: String) {
let deleteQuery: [String: Any] = [
// Update in place when the item already exists. Delete-then-add cost two
// round trips to securityd and briefly left the key holding no value at
// all, which is half of why a setting could disagree between launches.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
SecItemDelete(deleteQuery as CFDictionary)
let status = SecItemUpdate(query as CFDictionary,
[kSecValueData as String: data] as CFDictionary)
guard status == errSecItemNotFound else { return }
// Nothing stored under this service yet — the first write after the app
// was deleted, or a key added since. Add it.
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,