Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2143d255f | ||
|
|
31eb441d0e | ||
|
|
e5a2b2ebdb | ||
|
|
d2d4179ab6 | ||
|
|
965a506a43 |
@@ -83,7 +83,7 @@ struct BestValueIntroContent: View {
|
|||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
slider(
|
slider(
|
||||||
title: String(localized: "Typical fill-up", comment: "Best Value fill-up slider label"),
|
title: String(localized: "Fill-up amount", comment: "Best Value fill-up slider label"),
|
||||||
readOut: fillUpText,
|
readOut: fillUpText,
|
||||||
value: amountBinding,
|
value: amountBinding,
|
||||||
range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit),
|
range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit),
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ enum RoadDistanceService {
|
|||||||
// visible to show for it: no pass, no requests, no widget reloads.
|
// visible to show for it: no pass, no requests, no widget reloads.
|
||||||
guard FuelStore.accurateDistancesEnabled else {
|
guard FuelStore.accurateDistancesEnabled else {
|
||||||
await updateDebug(baseDebugStatus(state: .gated,
|
await updateDebug(baseDebugStatus(state: .gated,
|
||||||
reason: "Accurate distances off",
|
reason: "Driving distances off",
|
||||||
stations: stations,
|
stations: stations,
|
||||||
lat: lat,
|
lat: lat,
|
||||||
lng: lng,
|
lng: lng,
|
||||||
|
|||||||
+117
-55
@@ -70,9 +70,16 @@ struct SettingsView: View {
|
|||||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||||
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
||||||
/// "Accurate distances" — read once on appear and written straight through to
|
/// "Driving 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
|
||||||
@@ -85,7 +92,6 @@ struct SettingsView: View {
|
|||||||
/// Return key, so the keyboard's Done button clears this to dismiss AND
|
/// Return key, so the keyboard's Done button clears this to dismiss AND
|
||||||
/// commit the typed value. Both Fuel Savings controls are sliders now, so
|
/// commit the typed value. Both Fuel Savings controls are sliders now, so
|
||||||
/// nothing sets it — it stays for any numeric field this screen takes again.
|
/// nothing sets it — it stays for any numeric field this screen takes again.
|
||||||
@State private var settingsUnitChangeNotice: String?
|
|
||||||
@FocusState private var focusedSavingsField: SavingsField?
|
@FocusState private var focusedSavingsField: SavingsField?
|
||||||
|
|
||||||
private enum SavingsField: Hashable {
|
private enum SavingsField: Hashable {
|
||||||
@@ -117,6 +123,9 @@ struct SettingsView: View {
|
|||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Best Value", isOn: fuelSavingsEnabledBinding)
|
Toggle("Best Value", isOn: fuelSavingsEnabledBinding)
|
||||||
|
Text("Find stations that could save you money, even after the cost of driving there.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
// The feature's own controls follow its toggle: switched
|
// The feature's own controls follow its toggle: switched
|
||||||
// off they grey out but stay on screen (same rule as the
|
// off they grey out but stay on screen (same rule as the
|
||||||
// distance picker in Closest mode) and keep their values, so
|
// distance picker in Closest mode) and keep their values, so
|
||||||
@@ -132,11 +141,20 @@ 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)
|
||||||
|
Text("How many miles your car travels per gallon.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
|
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
|
||||||
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||||
Text(unit.displayName).tag(unit)
|
Text(unit.displayName).tag(unit)
|
||||||
@@ -144,17 +162,22 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
HStack {
|
HStack {
|
||||||
Text("Typical fill-up")
|
Text("Fill-up amount")
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(fillUpText)
|
Text(fillUpText)
|
||||||
.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
|
||||||
.accessibilityLabel("Typical fill-up")
|
if !editing { commitFillUp() }
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Fill-up amount")
|
||||||
.accessibilityValue(fillUpText)
|
.accessibilityValue(fillUpText)
|
||||||
|
Text(fillUpAmountDescription)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
Picker("Minimum saving", selection: $bestValueThresholdGBP) {
|
Picker("Minimum saving", selection: $bestValueThresholdGBP) {
|
||||||
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
|
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
|
||||||
Text(FuelSavingsFormat.money(value)).tag(value)
|
Text(FuelSavingsFormat.money(value)).tag(value)
|
||||||
@@ -166,17 +189,12 @@ struct SettingsView: View {
|
|||||||
// needs no handler of its own. The store snaps to the
|
// needs no handler of its own. The store snaps to the
|
||||||
// nearest option, so the picker and the stored value
|
// nearest option, so the picker and the stored value
|
||||||
// cannot disagree.
|
// cannot disagree.
|
||||||
Text("A saving worth driving for: the net saving left after the extra travel cost. Stations above it are labelled BEST VALUE in the station list, and Best Value alerts use the same figure.")
|
Text("The smallest saving you'd consider worth the extra drive.")
|
||||||
.font(.caption)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if let settingsUnitChangeNotice {
|
|
||||||
Text(settingsUnitChangeNotice)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
}
|
|
||||||
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
|
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
|
||||||
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
|
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
|
||||||
.font(.caption)
|
.font(.footnote)
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +202,7 @@ struct SettingsView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Fuel Savings")
|
Text("Fuel Savings")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Best Value estimates fuel cost only, using UK imperial MPG, your typical purchase amount, fuel prices and Apple Maps road distances. Real-world average MPG is better than manufacturer figures; the fill-up amount is your usual purchase, not tank capacity.")
|
Text("FuelBoard compares fuel prices and travel costs to find stations that could save you money. Results are estimates based on your settings and driving distance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -199,6 +217,9 @@ struct SettingsView: View {
|
|||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
WatchSyncManager.shared.pushSnapshot()
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
}
|
}
|
||||||
|
Text("Choose how distances are displayed throughout FuelBoard.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
Picker("Price display", selection: $priceDisplayStyle) {
|
Picker("Price display", selection: $priceDisplayStyle) {
|
||||||
ForEach(PriceDisplayStyle.allCases) { style in
|
ForEach(PriceDisplayStyle.allCases) { style in
|
||||||
Text(style.displayName).tag(style)
|
Text(style.displayName).tag(style)
|
||||||
@@ -210,24 +231,26 @@ struct SettingsView: View {
|
|||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
WatchSyncManager.shared.pushSnapshot()
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
}
|
}
|
||||||
|
Text("Show prices as 129.9p or £1.299 per litre.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Units")
|
Text("Units")
|
||||||
} footer: {
|
|
||||||
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle("Accurate distances", isOn: $accurateDistances)
|
Toggle("Driving distances", isOn: $accurateDistances)
|
||||||
.onChange(of: accurateDistances) { _, newValue in
|
.onChange(of: accurateDistances) { _, newValue in
|
||||||
FuelStore.setAccurateDistancesEnabled(newValue)
|
FuelStore.setAccurateDistancesEnabled(newValue)
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
NotificationCenter.default.post(name: .fuelBoardAccurateDistancesChanged,
|
NotificationCenter.default.post(name: .fuelBoardAccurateDistancesChanged,
|
||||||
object: nil)
|
object: nil)
|
||||||
}
|
}
|
||||||
|
Text("Use actual driving distances instead of straight-line estimates. Routes are calculated while the app is open.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Distances")
|
Text("Distances")
|
||||||
} footer: {
|
|
||||||
Text("Off (default): distances are straight-line estimates marked Approx. and the app makes no route requests. On: Apple Maps driving distances for every station in range, filled only while this app is open. Search radii then mean miles of driving.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -240,10 +263,11 @@ struct SettingsView: View {
|
|||||||
.onChange(of: stationTapAction) { _, newValue in
|
.onChange(of: stationTapAction) { _, newValue in
|
||||||
FuelStore.saveStationTapAction(newValue)
|
FuelStore.saveStationTapAction(newValue)
|
||||||
}
|
}
|
||||||
|
Text("Choose what happens when you tap a station: get directions or view its details.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Tap action")
|
Text("Tap action")
|
||||||
} footer: {
|
|
||||||
Text("Tapping a station either opens directions straight away or shows its details first.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -260,14 +284,12 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(dataRefreshMode.summary)
|
Text(dataRefreshMode.summary)
|
||||||
Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.")
|
Text("Background updates depend on when your iPhone allows them.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Data checking")
|
Text("Data checking")
|
||||||
} footer: {
|
|
||||||
Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -276,8 +298,9 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Show introduction", systemImage: "arrow.clockwise.circle")
|
Label("Show introduction", systemImage: "arrow.clockwise.circle")
|
||||||
}
|
}
|
||||||
} footer: {
|
Text("View the welcome screens and set up permissions again.")
|
||||||
Text("Replay the welcome screen, including the location and notification permission prompts.")
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if debugMode {
|
if debugMode {
|
||||||
@@ -454,7 +477,7 @@ struct SettingsView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Road distance fill")
|
Text("Road distance fill")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Shows whether Accurate distances is running or gated. Distances stay Approx. until Ready is yes for the current origin; gated reasons explain why no fill is happening.")
|
Text("Shows whether Driving distances is running or gated. Distances stay Approx. until Ready is yes for the current origin; gated reasons explain why no fill is happening.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -710,27 +733,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 },
|
||||||
@@ -738,29 +790,39 @@ struct SettingsView: View {
|
|||||||
guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return }
|
guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return }
|
||||||
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."
|
// The unit and the amount move together, so both keys are written.
|
||||||
persistFuelSavingsSettings()
|
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
|
/// Describes the fill-up amount in the unit the user picked, so the copy stays
|
||||||
/// is the one place the figure is stated.
|
/// true whether the amount is a budget in pounds or a volume in litres.
|
||||||
private var mpgText: String {
|
private var fillUpAmountDescription: String {
|
||||||
let mpg = 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.
|
|
||||||
private var fillUpText: String {
|
|
||||||
switch fuelSavingsSettings.preferredAmount.unit {
|
switch fuelSavingsSettings.preferredAmount.unit {
|
||||||
case .pounds: return String(format: "£%.0f", fuelSavingsSettings.preferredAmount.value)
|
case .pounds: return "How much you plan to spend on fuel."
|
||||||
case .litres: return String(format: "%.0f L", fuelSavingsSettings.preferredAmount.value)
|
case .litres: return "How many litres you usually buy."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistFuelSavingsSettings() {
|
/// "45 MPG" / "45.5 MPG". The slider is the only control, so this read-out
|
||||||
FuelStore.saveFuelSavingsSettings(fuelSavingsSettings)
|
/// is the one place the figure is stated. It follows the draft while the
|
||||||
|
/// thumb is down.
|
||||||
|
private var mpgText: String {
|
||||||
|
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, 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", value)
|
||||||
|
case .litres: return String(format: "%.0f L", value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
||||||
|
|||||||
@@ -67,10 +67,11 @@
|
|||||||
/* Settings tab */
|
/* Settings tab */
|
||||||
"Units" = "Units";
|
"Units" = "Units";
|
||||||
"Price display" = "Price display";
|
"Price display" = "Price display";
|
||||||
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
|
"Choose how distances are displayed throughout FuelBoard." = "Choose how distances are displayed throughout FuelBoard.";
|
||||||
|
"Show prices as 129.9p or £1.299 per litre." = "Show prices as 129.9p or £1.299 per litre.";
|
||||||
"Tap station" = "Tap station";
|
"Tap station" = "Tap station";
|
||||||
"Tap action" = "Tap action";
|
"Tap action" = "Tap action";
|
||||||
"Tapping a station either opens directions straight away or shows its details first." = "Tapping a station either opens directions straight away or shows its details first.";
|
"Choose what happens when you tap a station: get directions or view its details." = "Choose what happens when you tap a station: get directions or view its details.";
|
||||||
"Open map" = "Open map";
|
"Open map" = "Open map";
|
||||||
"More info" = "More info";
|
"More info" = "More info";
|
||||||
"Name" = "Name";
|
"Name" = "Name";
|
||||||
@@ -81,7 +82,18 @@
|
|||||||
"Directions to %@" = "Directions to %@";
|
"Directions to %@" = "Directions to %@";
|
||||||
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
|
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
|
||||||
"Show introduction" = "Replay onboarding";
|
"Show introduction" = "Replay onboarding";
|
||||||
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
|
"View the welcome screens and set up permissions again." = "View the welcome screens and set up permissions again.";
|
||||||
|
|
||||||
|
/* Settings — refined copy (Fuel Savings, Distances, Data checking) */
|
||||||
|
"Fill-up amount" = "Fill-up amount";
|
||||||
|
"Find stations that could save you money, even after the cost of driving there." = "Find stations that could save you money, even after the cost of driving there.";
|
||||||
|
"How many miles your car travels per gallon." = "How many miles your car travels per gallon.";
|
||||||
|
"How much you plan to spend on fuel." = "How much you plan to spend on fuel.";
|
||||||
|
"How many litres you usually buy." = "How many litres you usually buy.";
|
||||||
|
"The smallest saving you'd consider worth the extra drive." = "The smallest saving you'd consider worth the extra drive.";
|
||||||
|
"FuelBoard compares fuel prices and travel costs to find stations that could save you money. Results are estimates based on your settings and driving distance." = "FuelBoard compares fuel prices and travel costs to find stations that could save you money. Results are estimates based on your settings and driving distance.";
|
||||||
|
"Use actual driving distances instead of straight-line estimates. Routes are calculated while the app is open." = "Use actual driving distances instead of straight-line estimates. Routes are calculated while the app is open.";
|
||||||
|
"Background updates depend on when your iPhone allows them." = "Background updates depend on when your iPhone allows them.";
|
||||||
"Test alert notification (real data)" = "Test alert notification (real data)";
|
"Test alert notification (real data)" = "Test alert notification (real data)";
|
||||||
"Send plain test notification" = "Send plain test notification";
|
"Send plain test notification" = "Send plain test notification";
|
||||||
"Debug" = "Debug";
|
"Debug" = "Debug";
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ enum DataRefreshMode: String, CaseIterable, Identifiable {
|
|||||||
var summary: String {
|
var summary: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .standard:
|
case .standard:
|
||||||
return "Saves battery. Refreshes price data up to twice daily."
|
return "Saves battery by checking for new prices up to twice a day."
|
||||||
case .smart:
|
case .smart:
|
||||||
return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
|
return "Checks for new prices more often, keeping your data fresher."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-5
@@ -236,7 +236,7 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .stationSign: return "Station sign"
|
case .stationSign: return "Pence per litre"
|
||||||
case .poundsPence: return "Pounds & pence"
|
case .poundsPence: return "Pounds & pence"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,8 +255,8 @@ enum StationTapAction: String, Codable, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .openMap: return "Open map"
|
case .openMap: return "Directions"
|
||||||
case .showDetails: return "More info"
|
case .showDetails: return "Station details"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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