Compare commits
2
Commits
6ed4fe1210
...
d2d4179ab6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2d4179ab6 | ||
|
|
965a506a43 |
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user