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
|
/// "Accurate distances" — read once on appear and written straight through to
|
||||||
/// the shared store, which is what every display surface actually consults.
|
/// the shared store, which is what every display surface actually consults.
|
||||||
@State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled
|
@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 versionTapCount = 0
|
||||||
@State private var lastVersionTap = Date.distantPast
|
@State private var lastVersionTap = Date.distantPast
|
||||||
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
||||||
@@ -132,9 +139,15 @@ struct SettingsView: View {
|
|||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.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,
|
in: FuelSavingsCalculator.mpgSliderRange,
|
||||||
step: FuelSavingsCalculator.mpgSliderStep)
|
step: FuelSavingsCalculator.mpgSliderStep) { editing in
|
||||||
|
if !editing { commitMPG() }
|
||||||
|
}
|
||||||
.accessibilityLabel("Average fuel economy")
|
.accessibilityLabel("Average fuel economy")
|
||||||
.accessibilityValue(mpgText)
|
.accessibilityValue(mpgText)
|
||||||
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
|
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
|
||||||
@@ -150,9 +163,11 @@ struct SettingsView: View {
|
|||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Slider(value: fuelSavingsAmountBinding,
|
Slider(value: fillUpSliderValue,
|
||||||
in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit),
|
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")
|
.accessibilityLabel("Typical fill-up")
|
||||||
.accessibilityValue(fillUpText)
|
.accessibilityValue(fillUpText)
|
||||||
Picker("Minimum saving", selection: $bestValueThresholdGBP) {
|
Picker("Minimum saving", selection: $bestValueThresholdGBP) {
|
||||||
@@ -710,27 +725,56 @@ struct SettingsView: View {
|
|||||||
private var fuelSavingsEnabledBinding: Binding<Bool> {
|
private var fuelSavingsEnabledBinding: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.enabled },
|
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(
|
Binding(
|
||||||
get: { fuelSavingsSettings.mpg },
|
get: { mpgDraft ?? fuelSavingsSettings.mpg },
|
||||||
set: { fuelSavingsSettings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG); persistFuelSavingsSettings() }
|
set: { mpgDraft = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fuelSavingsAmountBinding: Binding<Double> {
|
private var fillUpSliderValue: Binding<Double> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.preferredAmount.value },
|
get: { fillUpDraft ?? fuelSavingsSettings.preferredAmount.value },
|
||||||
set: {
|
set: { fillUpDraft = $0 }
|
||||||
fuelSavingsSettings.preferredAmount.value = min(max($0, 0), FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
|
|
||||||
persistFuelSavingsSettings()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
private var fuelSavingsUnitBinding: Binding<FuelSavingsFillUnit> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.preferredAmount.unit },
|
get: { fuelSavingsSettings.preferredAmount.unit },
|
||||||
@@ -739,30 +783,32 @@ struct SettingsView: View {
|
|||||||
fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
|
fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
|
||||||
afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
|
afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
|
||||||
settingsUnitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
|
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
|
/// "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 {
|
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)
|
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 {
|
private var fillUpText: String {
|
||||||
|
let value = fillUpDraft ?? fuelSavingsSettings.preferredAmount.value
|
||||||
switch fuelSavingsSettings.preferredAmount.unit {
|
switch fuelSavingsSettings.preferredAmount.unit {
|
||||||
case .pounds: return String(format: "£%.0f", fuelSavingsSettings.preferredAmount.value)
|
case .pounds: return String(format: "£%.0f", value)
|
||||||
case .litres: return String(format: "%.0f L", fuelSavingsSettings.preferredAmount.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"
|
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
||||||
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
||||||
private var connectionText: String {
|
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 {
|
final class AlertRadiusFollowTests: XCTestCase {
|
||||||
func testAlertRadiusOptionsAreMileFriendly() {
|
func testAlertRadiusOptionsAreMileFriendly() {
|
||||||
// 1–2 city, 3 town, 5 default, 8 motorway — the geofence ceiling.
|
// 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)
|
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
|
/// Superscript digit glyphs for the pounds & pence format's small raised
|
||||||
/// third digit (the forecourt style: "£1.29⁹").
|
/// third digit (the forecourt style: "£1.29⁹").
|
||||||
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||||
@@ -1719,11 +1739,18 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func writeKeychain(data: Data, service: String) {
|
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,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
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] = [
|
let addQuery: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
|
|||||||
Reference in New Issue
Block a user