Compare commits

..
Author SHA1 Message Date
FuelBoard Contributor d2143d255f Settings: keep the Replay onboarding description inside its card
The Replay onboarding section rendered its description as a section footer, so
the text sat below the card while every other control card shows its description
inside the card above a hairline. Move the same copy into the section body as a
footnote/secondary row. Wording, the replay action and its behaviour are
unchanged; only the Fuel Savings general explanation and the Support tip note
remain section footers.
2026-10-17 11:33:13 +01:00
FuelBoard Contributor 31eb441d0e Settings: keep the Tap action description inside its card
The Tap action section rendered its description as a section footer, so the
text sat below the card while Units, Distances and Data checking show their
descriptions inside the card, separated from the control above by a hairline.
Move the same copy into the section body as a footnote/secondary row so the
card matches its neighbours. Wording is unchanged and no behavioural or
persistence code is touched.
2026-10-17 11:23:12 +01:00
FuelBoard Contributor e5a2b2ebdb Settings: concise copy and tighter hierarchy
Rewrite the Settings screen in short British English and remove the
duplicated paragraphs, keeping every control, binding and preference key as
it was.

- Fuel Savings: one-line descriptions for Best Value, average fuel economy,
  fill-up amount (worded for the unit in use) and minimum saving; one concise
  general explanation replaces the long footer; the orange "New comparison
  basis" notice is removed.
- Units: "Station sign" becomes "Pence per litre"; the combined paragraph
  becomes two contextual descriptions.
- Distances: "Accurate distances" becomes "Driving distances" with a short
  description; the route-request and search-radii paragraph is removed.
- Tap action: "Open map"/"More info" become "Directions"/"Station details"
  with a short description.
- Data checking: Standard and Smart summaries cut to one line each, one shared
  background-update note, duplicated footer removed.
- Replay onboarding: shorter description. Support FuelBoard untouched.

Copy and layout only: no calculations, ranking, refresh schedules, stored
values or persistence changed. New and reworded literals live in
FuelBoard/en.lproj/Localizable.strings.
2026-10-17 10:50:59 +01:00
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
18 changed files with 910 additions and 386 deletions
+1 -1
View File
@@ -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),
+47 -21
View File
@@ -31,8 +31,8 @@ struct ContentView: View {
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() @State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var priceDisplayStyle: PriceDisplayStyle = ContentView.qaPriceStyleOverride(default: FuelStore.loadPriceDisplayStyle()) @State private var priceDisplayStyle: PriceDisplayStyle = ContentView.qaPriceStyleOverride(default: FuelStore.loadPriceDisplayStyle())
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction() @State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
@State private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings() @State private var fuelSavingsSettings: FuelSavingsSettings = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings())
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount @State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings()).preferredAmount
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites() @State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled() @State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @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() { private func installLocationUpdateHook() {
locationManager.onLocationUpdate = { locationManager.onLocationUpdate = {
self.updateAlertMonitor() self.updateAlertMonitor()
@@ -814,6 +835,7 @@ struct ContentView: View {
station: station, station: station,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
fuelSavings: savingsLookup(baseline: scaffold.baseline, fuelSavings: savingsLookup(baseline: scaffold.baseline,
journeys: scaffold.journeys)(station), journeys: scaffold.journeys)(station),
fuelSavingsAmount: sessionFuelSavingsAmount, fuelSavingsAmount: sessionFuelSavingsAmount,
@@ -843,6 +865,15 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in .onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in
roadDistanceDebugStatus = RoadDistanceService.currentDebugStatus() 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 .onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
let info = note.userInfo ?? [:] let info = note.userInfo ?? [:]
if let id = info["stationID"] as? String, 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 /// 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 /// 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 compact summary cannot drift apart. The comparison label sits UNDER
/// the capsule only where there is width for both. /// 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 @ViewBuilder
private func savingsCapsule(_ result: FuelSavingsResult) -> some View { private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
let comparison = Text(FuelSavingsFormat.comparisonLabel(status: result.status)) VStack(alignment: .trailing, spacing: 2) {
.font(.caption2) FuelSavingsCapsule(result: result)
.foregroundStyle(.secondary) Text(FuelSavingsFormat.comparisonLabel(status: result.status))
.lineLimit(1) .font(.caption2)
// Same narrow column as the capsule above it, so the same floor: .foregroundStyle(.secondary)
// scaled copy, not a trimmed "vs closest st…". .lineLimit(1)
.minimumScaleFactor(0.7) // Same narrow column as the capsule above it, so the same floor:
if sizeClass == .regular { // scaled copy, not a trimmed "vs closest st…".
HStack(spacing: 8) { .minimumScaleFactor(0.7)
FuelSavingsCapsule(result: result) .multilineTextAlignment(.trailing)
comparison
}
} else {
VStack(alignment: .leading, spacing: 2) {
FuelSavingsCapsule(result: result)
comparison
}
} }
} }
+7
View File
@@ -211,6 +211,7 @@ struct FavouritesView: View {
station: station, station: station,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
selectedFuel: activeFuel, selectedFuel: activeFuel,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount, fuelSavingsAmount: fuelSavingsAmount,
@@ -221,6 +222,12 @@ struct FavouritesView: View {
onToggleFavourite: { onToggleFavourite(station, activeFuel) } 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 { .onAppear {
#if DEBUG #if DEBUG
// QA hook: launch with `-showTrends` to open the sheet // QA hook: launch with `-showTrends` to open the sheet
-198
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 /// The colour-coded verdict capsule shared by the station listing rows, the
/// detail sheet and the compact summary. /// detail sheet and the compact summary.
/// ///
+83 -1
View File
@@ -16,6 +16,50 @@ struct HowBestValueSheet: View {
VStack(alignment: .leading, spacing: 10) { 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("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.") 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 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("With Litres, every station is compared for the fixed quantity you selected.")
Text("Results are marked “Approx.” until road distances are available.") 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)) .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)
}
} }
} }
+1 -1
View File
@@ -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
View File
@@ -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"
+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 station: FuelStation
let location: Coordinate? let location: Coordinate?
let distanceUnit: DistanceUnit 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. /// Fuel whose price is being shown by the surrounding station list.
var selectedFuel: FuelType = FuelStore.loadSelectedFuel() var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
var fuelSavings: FuelSavingsAvailability? = nil var fuelSavings: FuelSavingsAvailability? = nil
@@ -63,12 +66,78 @@ struct StationDetailView: View {
FuelPriceFreshness.assessment(for: station) FuelPriceFreshness.assessment(for: station)
} }
/// The selected grade's current price, when present. Kept separate from /// One-way and out-and-back distance from the current location, from the ONE
/// the price-age row so an old price remains visible rather than being /// shared display helper — so this sheet's figures carry the same confidence
/// silently hidden. /// marker as the listing row, the widget and the Live Activity.
private var selectedFuelPriceText: String { private var distanceDisplay: StationDistanceDisplay? {
guard let price = station.prices[selectedFuel] else { return "—" } guard let location else { return nil }
return FuelStore.priceText(price, style: .stationSign) 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 /// Opens Apple Maps driving directions — the same destination (and the same
@@ -90,8 +159,18 @@ struct StationDetailView: View {
UINotificationFeedbackGenerator().notificationOccurred(.success) UINotificationFeedbackGenerator().notificationOccurred(.success)
withAnimation(.easeInOut(duration: 0.2)) { showingCopied = true } withAnimation(.easeInOut(duration: 0.2)) { showingCopied = true }
copyResetTask?.cancel() 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 { copyResetTask = Task {
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(nanoseconds: hold)
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false } withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false }
} }
@@ -99,8 +178,18 @@ struct StationDetailView: View {
var body: some View { var body: some View {
NavigationStack { 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 { List {
Section { 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 // Tapping anywhere on the map opens the same Apple Maps
// directions the Directions button below opens. // directions the Directions button below opens.
// //
@@ -140,80 +229,27 @@ struct StationDetailView: View {
// clutter. The Address/Distance and Distance/actions lines // clutter. The Address/Distance and Distance/actions lines
// stay. // stay.
.listRowSeparator(.hidden, edges: .bottom) .listRowSeparator(.hidden, edges: .bottom)
// The map, the address, the distance, any notices and the two // The map, the facts row, any notices and the two station
// station actions all share ONE card (the list's own row // actions all share ONE card (the list's own row hairlines
// hairlines separate them), so no hand-rolled Divider here — // separate them), so no hand-rolled Divider here — adding one
// adding one would double the line. // would double the line.
// //
// The station name is NOT repeated: the sheet header already // The station name AND the address live in the sheet header,
// shows it. // which is also where a tap copies the postcode — see the
// // principal toolbar item below.
// The whole row is a copy control: it puts JUST the postcode // The price and the distance share one two-column row: each
// on the clipboard so it can be pasted into another // figure comes from the same source it did before (the price
// navigation app. The copy glyph sits ON the address line, // from FuelStore, the distance from the one shared display
// and the "Postcode copied" confirmation is a capsule that floats OVER // helper), so the row cannot disagree with the listing row,
// THIS ROW — it confirms the address, so it must not hover // the widget or the Live Activity.
// over the map. It is an overlay, so nothing moves while it is StationFactsRow(
// up, and `allowsHitTesting(false)` keeps the row tappable station: station,
// through the capsule. selectedFuel: selectedFuel,
Button { priceAge: priceFreshness,
copyPostcode() distance: distanceDisplay,
} label: { distanceUnit: distanceUnit,
HStack(alignment: .firstTextBaseline, spacing: 8) { priceStyle: priceStyle
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)
ForEach(StationNotice.notices(for: station)) { notice in ForEach(StationNotice.notices(for: station)) { notice in
Label(notice.title, systemImage: notice.symbolName) Label(notice.title, systemImage: notice.symbolName)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
@@ -222,15 +258,19 @@ struct StationDetailView: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Capsule().fill(Color.orange.opacity(0.14))) .background(Capsule().fill(Color.orange.opacity(0.14)))
} }
// The two station actions are the LAST row of the card: half // Half the width each, on one line, with the list's hairline
// the width each, on one line, below Address and Distance. // running between them.
HStack(spacing: 12) { HStack(spacing: 0) {
Button { Button {
openDirections() openDirections()
} label: { } label: {
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill") Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
Rectangle()
.fill(Color(.separator))
.frame(width: 0.5, height: 22)
.accessibilityHidden(true)
Button(role: .destructive) { Button(role: .destructive) {
showingReportIssueConfirmation = true showingReportIssueConfirmation = true
} label: { } label: {
@@ -250,16 +290,23 @@ struct StationDetailView: View {
// is not configured — so the heading cannot be left dangling // is not configured — so the heading cannot be left dangling
// over an empty section. // over an empty section.
if let fuelSavings { 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 { Section {
FuelSavingsSummaryView( BestValueDetailCard(
availability: fuelSavings, availability: fuelSavings,
amount: fuelSavingsAmount, amount: fuelSavingsAmount,
baselineStationName: savingsBaselineStationName, baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit, distanceUnit: distanceUnit
showInfoAction: { showBestValueInfo = true }
) )
} 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 #endif
} }
.navigationTitle(station.name) // No navigationTitle: the sheet's header row carries the name and
.navigationBarTitleDisplayMode(.inline) // 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) { .alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
Button("Cancel", role: .cancel) { } Button("Cancel", role: .cancel) { }
Button("Report") { Button("Report") {
@@ -319,6 +385,9 @@ struct StationDetailView: View {
copyPostcode() copyPostcode()
} }
#endif #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 { struct StationPriceAgeView: View {
let assessment: FuelPriceFreshness.Assessment 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 { var body: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
LabeledContent("Price updated", value: assessment.updateAgeLabel) if compact {
.monospacedDigit() 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 { if let notice = assessment.outdatedNotice {
Text(notice) Text(notice)
.font(.caption2) .font(.caption2)
+10
View File
@@ -218,6 +218,15 @@ struct StationsView: View {
} }
#endif #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) { .task(id: stations.count) {
#if DEBUG #if DEBUG
// QA hook: `-showDetail` opens the More info sheet for the first // QA hook: `-showDetail` opens the More info sheet for the first
@@ -254,6 +263,7 @@ struct StationsView: View {
station: station, station: station,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
selectedFuel: selectedFuel, selectedFuel: selectedFuel,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount, fuelSavingsAmount: fuelSavingsAmount,
+5
View File
@@ -4,6 +4,11 @@ import WatchConnectivity
extension Notification.Name { extension Notification.Name {
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested") static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail") 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 /// 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 /// distance they are showing — those reads happen inside the row views, so
/// nothing else would invalidate them. /// nothing else would invalidate them.
+15 -3
View File
@@ -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.
@@ -787,6 +849,12 @@ final class RoadDistanceCacheTests: XCTestCase {
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.8 km return)") XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.8 km return)")
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles") XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles")
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.2 miles return") 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) let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1)
XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one") XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one")
@@ -828,6 +896,10 @@ final class RoadDistanceCacheTests: XCTestCase {
XCTAssertFalse(display.returnIsApproximate) XCTAssertFalse(display.returnIsApproximate)
XCTAssertEqual(display.labelWithReturn(unit: .miles), "2.9 mi (5.9 mi return)") XCTAssertEqual(display.labelWithReturn(unit: .miles), "2.9 mi (5.9 mi return)")
XCTAssertEqual(display.spokenLabelWithReturn(unit: .miles), "2.9 miles, 5.9 miles 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 /// 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 /// VoiceOver gets words, not abbreviations: "Approx." → "Approximately" and
/// "mi"/"km" → "miles"/"kilometres". /// "mi"/"km" → "miles"/"kilometres".
func testSpokenLabelsSpellOutTheAbbreviations() { func testSpokenLabelsSpellOutTheAbbreviations() {
+2 -2
View File
@@ -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."
} }
} }
} }
+17 -3
View File
@@ -136,11 +136,25 @@ enum FuelSavingsFormat {
static func extraTravelLabel(extraDistanceMiles: Double, static func extraTravelLabel(extraDistanceMiles: Double,
unit: DistanceUnit, unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? { 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 } guard extraDistanceMiles > 0.05 else { return nil }
let distanceText = distance(extraDistanceMiles, unit: unit) let distanceText = distance(extraDistanceMiles, unit: unit)
return confidence == .estimated return confidence == .estimated ? "Approx. \(distanceText)" : distanceText
? "Approx. \(distanceText) extra return travel"
: "\(distanceText) extra return travel"
} }
/// Compact comparison copy kept outside the coloured verdict capsule. /// Compact comparison copy kept outside the coloured verdict capsule.
+50 -8
View File
@@ -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,
@@ -1806,9 +1833,24 @@ func label(unit: DistanceUnit) -> String {
/// Row/sheet label that keeps the one-way station distance primary, then /// Row/sheet label that keeps the one-way station distance primary, then
/// adds the independently measured or estimated out-and-back distance. /// adds the independently measured or estimated out-and-back distance.
func labelWithReturn(unit: DistanceUnit) -> String { func labelWithReturn(unit: DistanceUnit) -> String {
let returnText = unit.format(returnKM) "\(label(unit: unit)) (\(returnLabel(unit: unit)))"
let returnLabel = returnIsApproximate ? "Approx. \(returnText)" : returnText }
return "\(label(unit: unit)) (\(returnLabel) return)"
/// 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:)`. /// VoiceOver form of `label(unit:)`.