Compare 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
FuelBoard Contributor 6ed4fe1210 How Best Value works: a Setup Best Value button, and a clean hand-off to Settings
The explanation sheet now carries a full-width button that closes it and the
sheet it was opened from, then lands on Settings — whose Best Value section is
the first thing in its list, so the controls are on screen on arrival. It uses
.controlSize(.large), the standard iOS button height.

Both the More info sheet and the Key sheet are presented by StationsView, and
FavouritesView presents its own More info sheet — not ContentView's monitor as
assumed — so each host closes whichever sheet it owns on the notification.
Before this, one of them stayed up over the Settings tab. The button now
announces itself half a second after its own dismissal, so the child and parent
dismissals cannot race and drop one of the two.
2026-10-16 20:06:48 +01:00
FuelBoard Contributor 9700e5bcb1 Station detail: one verdict capsule, the chosen price style, and the working explained
The Best Value card states its verdict once. The badge is now the large capsule form the sheet used to repeat on its own line, so the same headline no longer appears twice, and the comparison caption ("vs closest station" / "your baseline") sits under the capsule — on the card and on the listing rows alike. The row's size-class split, which put the caption beside the capsule at regular width, is gone, so the pair reads the same on every device.

The facts row's price now follows Settings -> Units -> Price display. It called FuelStore.priceText(price, style: .stationSign) with the style hardcoded, so a Pounds & pence user still saw the bare forecourt figure under the map. It now uses priceTextAttributed with the style passed down from the list that opened the sheet (ContentView, StationsView, FavouritesView) — the same call the listing row makes, at the same @ScaledMetric(relativeTo: .title2) 22pt so it still scales with Dynamic Type.

"How Best Value works" gains a step-by-step section in plain English: the baseline and why it is the closest station by journey, the return trip and why the extra return travel can read larger than the one-way distance, the extra driving cost, the fuel-price saving, the total and its inversion when the drive costs more than the price saves, and where the minimum saving is set. Every statement matches the arithmetic in FuelSavingsCalculator. The sheet opens at 72% instead of 42% to give it room.

No calculation, ranking, wording or data source changed: comparisonLabel, priceTextAttributed and the shared FuelSavingsResult are the same calls they were.
2026-10-16 17:15:11 +01:00
FuelBoard Contributor 05c76f21a9 Redesign the More info sheet
The sheet leads with the station name and address inside the card, above the map, then a two-column facts row (fuel price on the left, distance and return on the right, split by a hairline) and the two station actions divided the same way. The Best Value block becomes a tinted card: verdict badge, the two figures the estimate is made of, the working and the total. "How Best Value works" moves to its own row below it, using the shared HowBestValueRow the Key sheet already shows.

Presentation only: every figure still comes from the shared sources (FuelStore.priceText, stationDistanceDisplay, FuelSavingsResult), so the sheet cannot disagree with the listing row, the widget or the Live Activity, and no calculation, ranking or freshness rule changed.

StationDistanceDisplay.returnLabel(unit:) and FuelSavingsFormat.extraTravelDistanceLabel(...) are split out of their caption forms so a surface printing the return or extra-travel figure on its own line takes it from the same place, confidence marker included; their existing output is unchanged, pinned by tests. FuelSavingsSummaryView is deleted - the sheet's card supersedes it and nothing referenced it.

DEBUG-only QA hooks: -qaConfigureSavings configures a Fuel Savings plan (the Best Value card and the Worth the Drive sort are gated on it), -qaDetailBottom scrolls to that card (defaultScrollAnchor is a no-op on a List) and -qaCopyPostcode holds the copied confirmation long enough to screenshot.
2026-10-16 14:51:36 +01:00
14 changed files with 841 additions and 345 deletions
+47 -21
View File
@@ -31,8 +31,8 @@ struct ContentView: View {
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var priceDisplayStyle: PriceDisplayStyle = ContentView.qaPriceStyleOverride(default: FuelStore.loadPriceDisplayStyle())
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
@State private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount
@State private var fuelSavingsSettings: FuelSavingsSettings = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings())
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings()).preferredAmount
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@@ -175,6 +175,27 @@ struct ContentView: View {
)
}
/// QA hook: `-qaConfigureSavings` configures the Fuel Savings feature for the
/// session (the sheet's Best Value card, the Worth the Drive sort and the
/// Key sheet's Best Value row are ALL gated on `settings.isConfigured`), so
/// they can be screenshotted without walking Settings. It writes through the
/// store exactly as the Settings screen does and returns what it wrote, so
/// the app runs on the same stored values — not a stubbed, parallel one.
static func qaSavingsSettingsOverride(default value: FuelSavingsSettings) -> FuelSavingsSettings {
#if DEBUG
guard ProcessInfo.processInfo.arguments.contains("-qaConfigureSavings") else { return value }
let seeded = FuelSavingsSettings(
enabled: true,
mpg: value.mpg,
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
)
FuelStore.saveFuelSavingsSettings(seeded)
return seeded
#else
return value
#endif
}
private func installLocationUpdateHook() {
locationManager.onLocationUpdate = {
self.updateAlertMonitor()
@@ -814,6 +835,7 @@ struct ContentView: View {
station: station,
location: location,
distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
fuelSavings: savingsLookup(baseline: scaffold.baseline,
journeys: scaffold.journeys)(station),
fuelSavingsAmount: sessionFuelSavingsAmount,
@@ -843,6 +865,15 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in
roadDistanceDebugStatus = RoadDistanceService.currentDebugStatus()
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
// "Setup Best Value" in the How Best Value works sheet: close
// the More info sheet it was opened from (nil dismisses it —
// the sheet is item-driven) and switch to Settings. Tag 3 is
// the Settings tab; its Best Value section is the first item
// in the list, so the controls are on screen on arrival.
monitor.pendingDetailStation = nil
selectedTab = 3
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
let info = note.userInfo ?? [:]
if let id = info["stationID"] as? String,
@@ -2070,27 +2101,22 @@ struct StationRow: View {
/// The savings capsule plus the comparison it is measured against. The
/// capsule itself is the shared verdict view, so a row, the detail sheet and
/// the compact summary cannot drift apart. The comparison label sits beside
/// the capsule only where there is width for both.
/// the compact summary cannot drift apart. The comparison label sits UNDER
/// the capsule, trailing-aligned with it, at every width — beside it, a long
/// verdict pushed the caption off the row and the pair no longer read as one
/// statement.
@ViewBuilder
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
let comparison = Text(FuelSavingsFormat.comparisonLabel(status: result.status))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
// Same narrow column as the capsule above it, so the same floor:
// scaled copy, not a trimmed "vs closest st…".
.minimumScaleFactor(0.7)
if sizeClass == .regular {
HStack(spacing: 8) {
FuelSavingsCapsule(result: result)
comparison
}
} else {
VStack(alignment: .leading, spacing: 2) {
FuelSavingsCapsule(result: result)
comparison
}
VStack(alignment: .trailing, spacing: 2) {
FuelSavingsCapsule(result: result)
Text(FuelSavingsFormat.comparisonLabel(status: result.status))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
// Same narrow column as the capsule above it, so the same floor:
// scaled copy, not a trimmed "vs closest st…".
.minimumScaleFactor(0.7)
.multilineTextAlignment(.trailing)
}
}
+7
View File
@@ -211,6 +211,7 @@ struct FavouritesView: View {
station: station,
location: location,
distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
selectedFuel: activeFuel,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
@@ -221,6 +222,12 @@ struct FavouritesView: View {
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
)
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
// "Setup Best Value" from the explanation sheet, opened inside
// this view's More info sheet. That sheet closes itself; this one
// is presented from HERE and would otherwise stay over Settings.
detailStation = nil
}
.onAppear {
#if DEBUG
// QA hook: launch with `-showTrends` to open the sheet
+1 -199
View File
@@ -77,204 +77,6 @@ struct FuelSavingsAdjustmentView: View {
}
}
struct FuelSavingsSummaryView: View {
let availability: FuelSavingsAvailability?
let amount: FuelSavingsFillAmount
var baselineStationName: String? = nil
var compact: Bool = false
/// Sizes the "extra return travel" caption in the user's own unit (the
/// stored figure is miles) — used by both the row banner and the detail sheet.
var distanceUnit: DistanceUnit = .miles
var showInfoAction: (() -> Void)? = nil
private func comparisonLabel(for result: FuelSavingsResult) -> String {
FuelSavingsFormat.comparisonLabel(status: result.status)
}
var body: some View {
switch availability {
case .available(let result):
if compact {
// FULL-WIDTH banner under the row. Inline inside the middle
// column the capsule truncated ("Approx. £2.75 better off after
// tra…") because
// the column is squeezed by the price block — the verdict is the
// point of this sort mode, so it gets its own line with room.
// Colour carries the verdict: green save, red worse off, grey
// break even, blue baseline. lineLimit + minimumScaleFactor keep
// it to one line, so rows stay even.
VStack(alignment: .leading, spacing: 2) {
// The capsule gets the WHOLE line and the comparison caption
// sits beneath it. Side by side, the caption's layoutPriority
// squeezed the verdict until it truncated ("Approx. £2.75
// better off after tra…"), and in this sort mode the verdict
// is the point of the row.
capsule(for: result, compact: true)
.accessibilityLabel(accessibilityHeadline(for: result))
Text(comparisonLabel(for: result))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) {
Text(extraTravel)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
}
}
// Aligns the banner with the row's text column (46 pt logo + 12
// pt spacing) so it reads as part of the row, not the cell.
.padding(.leading, 58)
} else {
// Detail sheet: the SAME verdict capsule and the SAME caption as
// the listing rows (one implementation, so wording and colour
// cannot drift), followed by the breakdown only this view has
// room for. The old shape led with a status word + headline that
// matched neither the listing nor each other.
VStack(alignment: .leading, spacing: 10) {
// Same shape as the listing rows: the capsule takes the line
// and the comparison caption sits directly beneath it, so the
// verdict never competes for width with its own caption.
VStack(alignment: .leading, spacing: 4) {
capsule(for: result, compact: false)
.accessibilityLabel(accessibilityHeadline(for: result))
Text(comparisonLabel(for: result))
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
}
LabeledContent("Your fill-up", value: amountText)
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
.monospacedDigit()
if amount.unit == .pounds {
Text("You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund.")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) {
Text(extraTravel)
.font(.subheadline)
.foregroundStyle(.secondary)
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
}
if result.status == .baseline {
Text("Your closest station by journey distance is the baseline — every other saving is measured against it.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(baselineStationName.map { "Compared with \($0)." } ?? "Compared with your closest station.")
.font(.caption)
.foregroundStyle(.secondary)
LabeledContent(
result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference",
value: result.grossSavingGBP >= 0
? FuelSavingsFormat.money(result.grossSavingGBP)
: "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more"
)
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
Divider()
LabeledContent {
Text(result.headline())
.fontWeight(.semibold)
.foregroundStyle(capsuleColor(for: result.status))
.monospacedDigit()
} label: {
Text("Total")
.fontWeight(.semibold)
}
if result.journeyConfidence.isEstimated {
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
.font(.caption2)
.foregroundStyle(.secondary)
}
Text("Estimate covers fuel cost only. It excludes time, parking, tolls and wear.")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let showInfoAction {
Rectangle()
.fill(Color(.separator))
.frame(height: 0.5)
.padding(.vertical, 4)
Button(action: showInfoAction) {
HStack(spacing: 8) {
Text("How Best Value works")
Spacer(minLength: 8)
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
}
}
}
case .unavailable(let reason):
Text(compact ? "Savings unavailable" : "Best Value unavailable: \(reason)")
.font(compact ? .caption2 : .caption)
.foregroundStyle(.secondary)
case nil:
EmptyView()
}
}
private var amountText: String {
switch amount.unit {
case .pounds: return String(format: "£%.0f", amount.value)
case .litres: return String(format: "%.0f litres", amount.value)
}
}
/// VoiceOver form of the extra-travel caption ("Approximately 6.4 miles extra
/// return travel"): the abbreviation is spelled out and the unit is a word.
/// Falls back to the visible string if no spoken form exists.
private func spokenExtraTravel(for result: FuelSavingsResult, fallback: String) -> String {
FuelSavingsFormat.extraTravelSpokenLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) ?? fallback
}
/// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for
/// and leaves the plain words ("Break even", "Closest station") alone —
/// one source, so visible and spoken copy cannot drift.
private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
result.spokenHeadline()
}
/// The colour-coded verdict capsule lives in `FuelSavingsCapsule` — ONE
/// implementation for the listing rows, the detail sheet and this compact
/// summary, so the verdict reads identically everywhere; the sheet just
/// gets the larger font and padding.
private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View {
FuelSavingsCapsule(result: result, compact: compact)
}
/// Verdict colour lives in `FuelSavingsCapsule.color(for:)`.
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
FuelSavingsCapsule.color(for: status)
}
private func symbol(for status: FuelSavingsStatus) -> String {
FuelSavingsCapsule.symbol(for: status)
}
}
/// The colour-coded verdict capsule shared by the station listing rows, the
/// detail sheet and the compact summary.
///
@@ -327,4 +129,4 @@ struct FuelSavingsCapsule: View {
case .baseline: return "location.circle.fill"
}
}
}
}
+83 -1
View File
@@ -16,6 +16,50 @@ struct HowBestValueSheet: View {
VStack(alignment: .leading, spacing: 10) {
Text("Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
Text("Your fill-up amount and MPG are used to show whether you could save overall.")
// The whole point of the sheet is to explain the feature,
// so the way to switch it on lives here rather than only
// in Settings. Full-width: this is the card's one action.
Button {
openBestValueSettings()
} label: {
Text("Setup Best Value")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
}
// `.large` is the standard iOS button height (~50pt): the
// default `.borderedProminent` is considerably shorter and
// read as a compact control rather than a button.
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.top, 2)
}
.font(.subheadline)
.padding(.vertical, 4)
}
Section("The working, step by step") {
VStack(alignment: .leading, spacing: 12) {
step("1. The baseline",
"The closest station by journey distance. It is the trip you would make anyway, so everything else is measured against it.")
step("2. The return trip",
"For each station we work out the journey there and back. Where the roads have been measured we use the real driving route; until then we double the straight-line distance and mark the figure “Approx.”.")
step("3. Extra return travel",
"This station's return trip minus the baseline's. It is the extra you add on top of the journey you were already making — not the whole trip. Because both figures are return trips, it can read larger than the one-way distance shown above the map.")
step("4. Extra driving cost",
"What that extra distance costs in fuel: the extra miles, divided by your MPG, priced at the station you are driving to.")
step("5. Fuel-price saving",
"What the difference in price per litre is worth on your fill-up.")
step("6. Total",
"The fuel-price saving minus the extra driving cost. If the drive costs more than the price saves, the total is shown as an extra cost instead, and the station is not Best Value.")
step("7. Best Value",
"The total clears the minimum saving you set in Settings → Fuel Savings.")
}
.font(.subheadline)
.padding(.vertical, 4)
}
Section {
VStack(alignment: .leading, spacing: 10) {
Text("With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.")
Text("With Litres, every station is compared for the fixed quantity you selected.")
Text("Results are marked “Approx.” until road distances are available.")
@@ -33,8 +77,46 @@ struct HowBestValueSheet: View {
}
}
}
.presentationDetents([.fraction(0.42)])
.presentationDetents([.fraction(0.72)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
#if DEBUG
// QA hook: `-qaSetupBestValue` runs the Setup Best Value action without a
// tap (simctl cannot tap), so the tab switch can be screenshotted.
.task {
guard ProcessInfo.processInfo.arguments.contains("-qaSetupBestValue") else { return }
try? await Task.sleep(nanoseconds: 2_000_000_000)
openBestValueSettings()
}
#endif
}
/// Closes this sheet and asks the app to land on Settings → Best Value, whose
/// section is the first thing in its list and holds the fill-up amount, MPG
/// and minimum saving. This sheet sits two levels below the tab view — More
/// info sheet, or the Key sheet — so it cannot switch tabs itself: it closes
/// itself and posts, and ContentView does the rest.
private func openBestValueSettings() {
dismiss()
// Announce only once this sheet has animated away. The host then closes
// the sheet this one was presented from — the More info sheet, or the Key
// sheet — and switches to Settings. Asking for that while the child was
// still dismissing could drop one of the two dismissals, which is what
// left the Settings tab covered by a sheet.
Task { @MainActor in
try? await Task.sleep(nanoseconds: 500_000_000)
NotificationCenter.default.post(name: .fuelBoardOpenBestValueSettings, object: nil)
}
}
/// One numbered step: a lead-in, then the plain explanation. Same words
/// either way — this only sets the hierarchy.
private func step(_ title: String, _ explanation: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(explanation)
.foregroundStyle(.secondary)
}
}
}
+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 {
+283
View File
@@ -0,0 +1,283 @@
import SwiftUI
/// The More info sheet's facts row: the selected fuel's price on the left, the
/// distance on the right, separated by a hairline down the middle.
///
/// Every figure comes from the sheet's existing sources — the price through
/// `FuelStore.priceText` and the distance through
/// `FuelStore.stationDistanceDisplay(...)` — so this row can never disagree with
/// the listing row, the widget or the Live Activity, and both columns keep the
/// shared "Approx." confidence marking.
struct StationFactsRow: View {
let station: FuelStation
let selectedFuel: FuelType
let priceAge: FuelPriceFreshness.Assessment
let distance: StationDistanceDisplay?
let distanceUnit: DistanceUnit
/// The user's price display style, from the same Settings choice the listing
/// rows and the widgets read.
var priceStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
/// The price's point size, scaled with Dynamic Type: the price is built from
/// an explicit size (its "/L" suffix is sized relative to it inside
/// `priceTextAttributed`), so it needs a metric rather than a font style.
/// 22pt matches the listing row's price.
@ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22
var body: some View {
HStack(alignment: .top, spacing: 12) {
fuelColumn
StationFactsDivider()
distanceColumn
}
.padding(.vertical, 2)
}
private var fuelColumn: some View {
VStack(alignment: .leading, spacing: 3) {
Text(selectedFuel.displayName)
.font(.caption)
.foregroundStyle(.secondary)
priceView
StationPriceAgeView(assessment: priceAge, compact: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
private var distanceColumn: some View {
VStack(alignment: .leading, spacing: 3) {
Text("Distance")
.font(.caption)
.foregroundStyle(.secondary)
Text(distanceText)
.font(.title2.weight(.bold))
.monospacedDigit()
Text(returnText)
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
/// The selected grade's price, in the user's chosen display style — the same
/// `priceTextAttributed` the listing row uses, so the sheet prints the bare
/// forecourt figure ("167.9") in Station sign and the superscripted
/// "£1.67⁹/L" in Pounds & pence, and the two surfaces cannot disagree.
/// The returned Text carries its own fonts, so no `.font(...)` goes on top.
///
/// A missing grade shows an em dash rather than hiding the column, so the row
/// keeps its two-column shape.
@ViewBuilder
private var priceView: some View {
if let price = station.prices[selectedFuel] {
FuelStore.priceTextAttributed(price, style: priceStyle, size: priceSize, weight: .bold)
} else {
Text("—")
.font(.title2.weight(.bold))
.monospacedDigit()
}
}
/// One-way distance, with its confidence marker, from the shared display.
private var distanceText: String {
distance?.label(unit: distanceUnit) ?? "—"
}
/// Out-and-back distance, same source and same marker rule.
private var returnText: String {
distance?.returnLabel(unit: distanceUnit) ?? ""
}
}
/// A one-pixel vertical hairline between the facts row's two columns, drawn in
/// the List's own separator colour so it matches every other line on the sheet.
/// Decorative: VoiceOver reads each column as one phrase instead.
private struct StationFactsDivider: View {
var body: some View {
Rectangle()
.fill(Color(.separator))
.frame(width: 0.5)
.frame(maxHeight: .infinity)
.accessibilityHidden(true)
}
}
/// The More info sheet's Best Value card: the verdict, then the figures it is
/// built from.
///
/// Nothing is recomputed here — every number comes from the shared
/// `FuelSavingsResult`, the verdict capsule is the same `FuelSavingsCapsule` the
/// listing rows use, and the total is the shared `headline()`. Wording and
/// colour therefore cannot drift between the sheet and the list.
struct BestValueDetailCard: View {
let availability: FuelSavingsAvailability
let amount: FuelSavingsFillAmount
let baselineStationName: String?
let distanceUnit: DistanceUnit
var body: some View {
switch availability {
case .available(let result):
availableBody(result)
case .unavailable(let reason):
Text("Best Value unavailable: \(reason)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func availableBody(_ result: FuelSavingsResult) -> some View {
VStack(alignment: .leading, spacing: 14) {
header(result)
panels(result)
workings(result)
}
}
/// Card title on the left, the verdict on the right — ONE capsule, in the
/// same large form the sheet used to repeat on its own line above the
/// working. The comparison caption sits UNDER the capsule, trailing-aligned
/// with it, the same way the listing rows lay the pair out.
private func header(_ result: FuelSavingsResult) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("Best Value")
.font(.headline)
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 4) {
FuelSavingsCapsule(result: result, compact: false)
.accessibilityLabel(result.spokenHeadline())
comparison(result)
}
}
}
/// The compact comparison caption ("vs closest station" / "your baseline"),
/// under the capsule it belongs to.
private func comparison(_ result: FuelSavingsResult) -> some View {
Text(FuelSavingsFormat.comparisonLabel(status: result.status))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
/// The two figures the estimate is made OF, side by side: what the user
/// spends, and how much fuel that buys at this station.
private func panels(_ result: FuelSavingsResult) -> some View {
HStack(spacing: 10) {
panel(symbol: "fuelpump.fill",
title: "Your fill-up",
value: Self.amountText(amount))
panel(symbol: "drop.fill",
title: "Fuel you'll receive",
value: String(format: "%.2f litres", result.fillLitres))
}
}
private func panel(symbol: String, title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 5) {
Image(systemName: symbol)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Text(value)
.font(.title3.weight(.semibold))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.primary.opacity(0.06)))
.accessibilityElement(children: .combine)
}
/// The working: what the extra travel adds, what the pump saves, and the
/// total. A baseline station has nothing to save against itself, so it stops
/// after the panels — the capsule above already says "Closest station".
@ViewBuilder
private func workings(_ result: FuelSavingsResult) -> some View {
if result.status != .baseline {
Divider()
extraTravelRow(result)
savingRows(result)
Divider()
totalRow(result)
if let baselineStationName {
Text("Compared with \(baselineStationName).")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if result.journeyConfidence.isEstimated {
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
/// The extra driving a saving has to beat, named in the row label and
/// measured in the value — same figure and same "Approx." rule as the shared
/// caption, from the shared formatter.
@ViewBuilder
private func extraTravelRow(_ result: FuelSavingsResult) -> some View {
if let extraTravel = FuelSavingsFormat.extraTravelDistanceLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) {
LabeledContent("Extra return travel", value: extraTravel)
.monospacedDigit()
}
}
@ViewBuilder
private func savingRows(_ result: FuelSavingsResult) -> some View {
LabeledContent(
result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference",
value: result.grossSavingGBP >= 0
? FuelSavingsFormat.money(result.grossSavingGBP)
: "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more"
)
.monospacedDigit()
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
.monospacedDigit()
}
/// The Total row is the WORKINGS; the capsule above is the quick verdict.
/// Both take their wording and colour from the shared result.
private func totalRow(_ result: FuelSavingsResult) -> some View {
LabeledContent {
Text(result.headline())
.fontWeight(.semibold)
.foregroundStyle(FuelSavingsCapsule.color(for: result.status))
.monospacedDigit()
} label: {
Text("Total")
.fontWeight(.semibold)
}
}
/// "£60" or "60 litres" — the same strings the sheet printed before the
/// redesign, so the fill-up reads identically in both units.
static func amountText(_ amount: FuelSavingsFillAmount) -> String {
switch amount.unit {
case .pounds: return String(format: "£%.0f", amount.value)
case .litres: return String(format: "%.0f litres", amount.value)
}
}
}
/// "How Best Value works" lives in the shared `HowBestValueRow`
/// (HowBestValueSheet.swift) — the same row the Key sheet uses, so the sheet and
/// the Key cannot drift.
+159 -90
View File
@@ -17,6 +17,9 @@ struct StationDetailView: View {
let station: FuelStation
let location: Coordinate?
let distanceUnit: DistanceUnit
/// Price display style, from the same Settings choice the listing rows read,
/// so the sheet's price can never disagree with the row behind it.
var priceStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
/// Fuel whose price is being shown by the surrounding station list.
var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
var fuelSavings: FuelSavingsAvailability? = nil
@@ -63,12 +66,78 @@ struct StationDetailView: View {
FuelPriceFreshness.assessment(for: station)
}
/// The selected grade's current price, when present. Kept separate from
/// the price-age row so an old price remains visible rather than being
/// silently hidden.
private var selectedFuelPriceText: String {
guard let price = station.prices[selectedFuel] else { return "—" }
return FuelStore.priceText(price, style: .stationSign)
/// One-way and out-and-back distance from the current location, from the ONE
/// shared display helper — so this sheet's figures carry the same confidence
/// marker as the listing row, the widget and the Live Activity.
private var distanceDisplay: StationDistanceDisplay? {
guard let location else { return nil }
return FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
}
/// Header title: the station name over its address, as the mockup shows.
/// Tapping copies the postcode, and the "Postcode copied" capsule floats over
/// the address line it confirms.
///
/// The CARD'S FIRST ROW, above the map — deliberately not a toolbar
/// principal item: a principal item is clamped to one line, so a long
/// forecourt name ("Brighouse Bradford Rd Superstore - Petrol") showed an
/// ellipsis and `minimumScaleFactor` was not honoured. Inside the card it
/// wraps to two lines, and the confirmation capsule cannot be clipped by the
/// nav bar.
private var headerTitle: some View {
Button {
copyPostcode()
} label: {
VStack(alignment: .leading, spacing: 1) {
Text(station.name)
.font(.title3.weight(.semibold))
.multilineTextAlignment(.leading)
.lineLimit(2)
Text(addressLine)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.disabled(postcodeForCopying.isEmpty)
.accessibilityLabel(String(localized: "Address: \(addressLine)",
comment: "Accessibility label for the address in the More info sheet header"))
.accessibilityHint(postcodeForCopying.isEmpty
? ""
: String(localized: "Copies the postcode",
comment: "Accessibility hint for the address in the More info sheet header"))
.overlay(alignment: .center) { copiedConfirmation }
}
/// The transient "Postcode copied" capsule. An overlay, so nothing moves
/// while it is up, and hit testing stays off so the header keeps its tap.
@ViewBuilder
private var copiedConfirmation: some View {
if showingCopied {
HStack(spacing: 4) {
Image(systemName: "checkmark")
Text("Postcode copied")
}
.font(.caption.weight(.semibold))
.foregroundStyle(.green)
.padding(.horizontal, 8)
.padding(.vertical, 4)
// A material base keeps it legible where the tint alone (the
// notice-capsule recipe) washes out.
.background {
Capsule()
.fill(.regularMaterial)
.overlay(Capsule().fill(Color.green.opacity(0.22)))
}
.allowsHitTesting(false)
.accessibilityHidden(true)
.transition(.opacity)
}
}
/// Opens Apple Maps driving directions — the same destination (and the same
@@ -90,8 +159,18 @@ struct StationDetailView: View {
UINotificationFeedbackGenerator().notificationOccurred(.success)
withAnimation(.easeInOut(duration: 0.2)) { showingCopied = true }
copyResetTask?.cancel()
#if DEBUG
// QA hook: `-qaCopyPostcode` holds the confirmation up long enough for a
// screenshot. The production window (2s) cannot be hit reliably — the app
// takes several seconds to render, and simctl cannot tap.
let hold: UInt64 = ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode")
? 25_000_000_000
: 2_000_000_000
#else
let hold: UInt64 = 2_000_000_000
#endif
copyResetTask = Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
try? await Task.sleep(nanoseconds: hold)
guard !Task.isCancelled else { return }
withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false }
}
@@ -99,8 +178,18 @@ struct StationDetailView: View {
var body: some View {
NavigationStack {
// Wrapper exists only so the DEBUG scroll hook below can reach a
// section by id; it has no effect on layout or behaviour.
ScrollViewReader { proxy in
List {
Section {
// The sheet header is the CARD'S FIRST ROW, above the map:
// the station name over its address, both wrapping rather
// than truncating, on the card's own insets so it lines up
// with the map and the rows below. Tapping it copies the
// postcode. No hairline under it — the map follows.
headerTitle
.listRowSeparator(.hidden, edges: .bottom)
// Tapping anywhere on the map opens the same Apple Maps
// directions the Directions button below opens.
//
@@ -140,80 +229,27 @@ struct StationDetailView: View {
// clutter. The Address/Distance and Distance/actions lines
// stay.
.listRowSeparator(.hidden, edges: .bottom)
// The map, the address, the distance, any notices and the two
// station actions all share ONE card (the list's own row
// hairlines separate them), so no hand-rolled Divider here —
// adding one would double the line.
// The map, the facts row, any notices and the two station
// actions all share ONE card (the list's own row hairlines
// separate them), so no hand-rolled Divider here — adding one
// would double the line.
//
// The station name is NOT repeated: the sheet header already
// shows it.
//
// The whole row is a copy control: it puts JUST the postcode
// on the clipboard so it can be pasted into another
// navigation app. The copy glyph sits ON the address line,
// and the "Postcode copied" confirmation is a capsule that floats OVER
// THIS ROW — it confirms the address, so it must not hover
// over the map. It is an overlay, so nothing moves while it is
// up, and `allowsHitTesting(false)` keeps the row tappable
// through the capsule.
Button {
copyPostcode()
} label: {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("Address")
Spacer(minLength: 8)
Text(addressLine)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
// Stays the plain copy glyph — the capsule carries
// the confirmation, so swapping this to a checkmark
// would say it twice.
Image(systemName: "doc.on.doc")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.disabled(postcodeForCopying.isEmpty)
.accessibilityLabel(String(localized: "Address: \(addressLine)",
comment: "Accessibility label for the address row in the More info sheet"))
.accessibilityHint(postcodeForCopying.isEmpty
? ""
: String(localized: "Copies the postcode",
comment: "Accessibility hint for the address row in the More info sheet"))
// Horizontally centred over the address line itself.
.overlay(alignment: .center) {
if showingCopied {
HStack(spacing: 4) {
Image(systemName: "checkmark")
Text("Postcode copied")
}
.font(.footnote.weight(.semibold))
.foregroundStyle(.green)
.padding(.horizontal, 10)
.padding(.vertical, 6)
// A material base keeps it legible where the tint
// alone (the notice-capsule recipe) washes out.
.background {
Capsule()
.fill(.regularMaterial)
.overlay(Capsule().fill(Color.green.opacity(0.22)))
}
.allowsHitTesting(false)
.accessibilityHidden(true)
.transition(.opacity)
}
}
if let location {
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
.monospacedDigit()
}
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
.monospacedDigit()
StationPriceAgeView(assessment: priceFreshness)
// The station name AND the address live in the sheet header,
// which is also where a tap copies the postcode — see the
// principal toolbar item below.
// The price and the distance share one two-column row: each
// figure comes from the same source it did before (the price
// from FuelStore, the distance from the one shared display
// helper), so the row cannot disagree with the listing row,
// the widget or the Live Activity.
StationFactsRow(
station: station,
selectedFuel: selectedFuel,
priceAge: priceFreshness,
distance: distanceDisplay,
distanceUnit: distanceUnit,
priceStyle: priceStyle
)
ForEach(StationNotice.notices(for: station)) { notice in
Label(notice.title, systemImage: notice.symbolName)
.font(.subheadline.weight(.semibold))
@@ -222,15 +258,19 @@ struct StationDetailView: View {
.padding(.vertical, 6)
.background(Capsule().fill(Color.orange.opacity(0.14)))
}
// The two station actions are the LAST row of the card: half
// the width each, on one line, below Address and Distance.
HStack(spacing: 12) {
// Half the width each, on one line, with the list's hairline
// running between them.
HStack(spacing: 0) {
Button {
openDirections()
} label: {
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
.frame(maxWidth: .infinity)
}
Rectangle()
.fill(Color(.separator))
.frame(width: 0.5, height: 22)
.accessibilityHidden(true)
Button(role: .destructive) {
showingReportIssueConfirmation = true
} label: {
@@ -250,16 +290,23 @@ struct StationDetailView: View {
// is not configured — so the heading cannot be left dangling
// over an empty section.
if let fuelSavings {
// The card's own header row says "Best Value", so a section
// header would repeat it; the green tint is what marks this
// block as the savings card.
Section {
FuelSavingsSummaryView(
BestValueDetailCard(
availability: fuelSavings,
amount: fuelSavingsAmount,
baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit,
showInfoAction: { showBestValueInfo = true }
distanceUnit: distanceUnit
)
} header: {
Text("Best Value")
}
.listRowBackground(Color.green.opacity(0.08))
.id("bestValueCard")
Section {
// The shared row — same component the Key sheet shows.
HowBestValueRow { showBestValueInfo = true }
}
}
@@ -276,8 +323,27 @@ struct StationDetailView: View {
#endif
}
.navigationTitle(station.name)
.navigationBarTitleDisplayMode(.inline)
// No navigationTitle: the sheet's header row carries the name and
// address, and an inline title would print the name a second time.
#if DEBUG
// QA hook: `-qaDetailBottom` scrolls to the Best Value card so its
// lower half (Extra driving cost, Total, the estimate caveat) can be
// screenshotted. `simctl` cannot scroll, and `.defaultScrollAnchor`
// is a no-op on a List — only scrollTo moves it.
.task {
guard ProcessInfo.processInfo.arguments.contains("-qaDetailBottom") else { return }
try? await Task.sleep(nanoseconds: 3_000_000_000)
withAnimation { proxy.scrollTo("bestValueCard", anchor: .bottom) }
}
.task {
// QA hook: `-qaDetailShowBestValueInfo` opens the explanation from
// THIS sheet (the More info path), so the two-sheet dismissal can
// be screenshotted — simctl has no tap.
guard ProcessInfo.processInfo.arguments.contains("-qaDetailShowBestValueInfo") else { return }
try? await Task.sleep(nanoseconds: 1_500_000_000)
showBestValueInfo = true
}
#endif
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Report") {
@@ -319,6 +385,9 @@ struct StationDetailView: View {
copyPostcode()
}
#endif
}
// Closes the ScrollViewReader wrapped around the List: kept last so
// every List modifier above stays inside `proxy`'s scope.
}
}
}
+17 -2
View File
@@ -16,10 +16,25 @@ import SwiftUI
struct StationPriceAgeView: View {
let assessment: FuelPriceFreshness.Assessment
/// The More info sheet's facts row prints this under the fuel price inside a
/// half-width column, where a `LabeledContent`'s label + value pair cannot
/// fit on one line. Compact keeps the SAME words in one caption line.
var compact: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
LabeledContent("Price updated", value: assessment.updateAgeLabel)
.monospacedDigit()
if compact {
Text("Price updated \(assessment.updateAgeLabel)")
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.75)
.accessibilityLabel("Price updated \(assessment.updateAgeLabel)")
} else {
LabeledContent("Price updated", value: assessment.updateAgeLabel)
.monospacedDigit()
}
if let notice = assessment.outdatedNotice {
Text(notice)
.font(.caption2)
+10
View File
@@ -218,6 +218,15 @@ struct StationsView: View {
}
#endif
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
// "Setup Best Value" from the explanation sheet. That sheet closes
// itself and ContentView switches to Settings, but both of the
// sheets this view can have open are presented from HERE and each
// survived the tab switch, floating over Settings: the Key sheet
// and the More info sheet. Close whichever is up.
showKey = false
detailStation = nil
}
.task(id: stations.count) {
#if DEBUG
// QA hook: `-showDetail` opens the More info sheet for the first
@@ -254,6 +263,7 @@ struct StationsView: View {
station: station,
location: location,
distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
selectedFuel: selectedFuel,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
+5
View File
@@ -4,6 +4,11 @@ import WatchConnectivity
extension Notification.Name {
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
/// Posted by the "How Best Value works" sheet's Setup Best Value button, so
/// the app can close that sheet's stack and land on Settings — whose Best
/// Value section is the first thing in its list. The sheet cannot switch
/// tabs itself: it is presented two levels below the tab view.
static let fuelBoardOpenBestValueSettings = Notification.Name("FuelBoardOpenBestValueSettings")
/// Posted when a road-distance fill finishes, so the app's rows re-read the
/// distance they are showing — those reads happen inside the row views, so
/// nothing else would invalidate them.
@@ -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.
@@ -787,6 +849,12 @@ final class RoadDistanceCacheTests: XCTestCase {
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.8 km return)")
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles")
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.2 miles return")
// The return figure split out on its own — a surface that prints the
// one-way and the return distance on separate lines still gets BOTH from
// this type, marker included.
XCTAssertEqual(confirmed.returnLabel(unit: .miles), "4.2 mi return")
XCTAssertEqual(confirmed.returnLabel(unit: .kilometers), "6.8 km return")
XCTAssertEqual(confirmed.spokenReturnLabel(unit: .miles), "4.2 miles return")
let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1)
XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one")
@@ -828,6 +896,10 @@ final class RoadDistanceCacheTests: XCTestCase {
XCTAssertFalse(display.returnIsApproximate)
XCTAssertEqual(display.labelWithReturn(unit: .miles), "2.9 mi (5.9 mi return)")
XCTAssertEqual(display.spokenLabelWithReturn(unit: .miles), "2.9 miles, 5.9 miles return")
// The split-out return figure must use the INDEPENDENT return route
// (9.47 km), not a doubled one-way (2 × 4.65 = 9.30 km → 5.8 mi).
XCTAssertEqual(display.returnLabel(unit: .miles), "5.9 mi return")
XCTAssertEqual(display.spokenReturnLabel(unit: .miles), "5.9 miles return")
}
/// The marker tracks the cache on its own: same station, bare figure once its
@@ -640,6 +640,29 @@ final class FuelSavingsEstimateTests: XCTestCase {
)
}
/// The same figure on its own, for the sheet's row form ("Extra return
/// travel | 6.4 mi"): identical value and identical marker rule, so the row
/// and the caption can never disagree about being an estimate. Below the
/// 0.05-mile floor both forms return nil together.
func testExtraTravelDistanceLabelMatchesTheCaption() {
XCTAssertEqual(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .miles),
"6.4 mi"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
"Approx. 6.4 mi"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .kilometers),
"10.3 km"
)
XCTAssertNil(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 0.04, unit: .miles),
"below the floor there is no extra travel to name"
)
}
/// VoiceOver gets words, not abbreviations: "Approx." → "Approximately" and
/// "mi"/"km" → "miles"/"kilometres".
func testSpokenLabelsSpellOutTheAbbreviations() {
+17 -3
View File
@@ -136,11 +136,25 @@ enum FuelSavingsFormat {
static func extraTravelLabel(extraDistanceMiles: Double,
unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? {
guard let distanceText = extraTravelDistanceLabel(extraDistanceMiles: extraDistanceMiles,
unit: unit,
confidence: confidence) else { return nil }
return "\(distanceText) extra return travel"
}
/// The extra-travel FIGURE on its own, with its confidence marker: "4.3 mi"
/// or "Approx. 4.3 mi".
///
/// A row form ("Extra return travel | 4.3 mi") names the journey in its own
/// label, so this is the same figure and the same "Approx." rule as
/// `extraTravelLabel`, from the same inputs — never rebuilt at a call site,
/// or one surface marks an estimate while another drops the marker.
static func extraTravelDistanceLabel(extraDistanceMiles: Double,
unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? {
guard extraDistanceMiles > 0.05 else { return nil }
let distanceText = distance(extraDistanceMiles, unit: unit)
return confidence == .estimated
? "Approx. \(distanceText) extra return travel"
: "\(distanceText) extra return travel"
return confidence == .estimated ? "Approx. \(distanceText)" : distanceText
}
/// Compact comparison copy kept outside the coloured verdict capsule.
+47 -5
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,
@@ -1806,9 +1833,24 @@ func label(unit: DistanceUnit) -> String {
/// Row/sheet label that keeps the one-way station distance primary, then
/// adds the independently measured or estimated out-and-back distance.
func labelWithReturn(unit: DistanceUnit) -> String {
let returnText = unit.format(returnKM)
let returnLabel = returnIsApproximate ? "Approx. \(returnText)" : returnText
return "\(label(unit: unit)) (\(returnLabel) return)"
"\(label(unit: unit)) (\(returnLabel(unit: unit)))"
}
/// The out-and-back figure on its own: "5.0 mi return" / "Approx. 5.0 mi return".
///
/// Split out of `labelWithReturn` for surfaces that print the one-way and the
/// return figure on SEPARATE lines (the More info sheet's distance column). Both
/// figures still come from this one type, so a routed value can never be marked
/// "Approx." while its return figure is not — or the other way round.
func returnLabel(unit: DistanceUnit) -> String {
let text = unit.format(returnKM)
return returnIsApproximate ? "Approx. \(text) return" : "\(text) return"
}
/// VoiceOver form of `returnLabel(unit:)`.
func spokenReturnLabel(unit: DistanceUnit) -> String {
let text = unit.spoken(returnKM)
return returnIsApproximate ? "Approximately \(text) return" : "\(text) return"
}
/// VoiceOver form of `label(unit:)`.