Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2d4179ab6 | ||
|
|
965a506a43 | ||
|
|
6ed4fe1210 | ||
|
|
9700e5bcb1 | ||
|
|
05c76f21a9 |
+47
-21
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ struct SettingsView: View {
|
|||||||
/// "Accurate distances" — read once on appear and written straight through to
|
/// "Accurate distances" — read once on appear and written straight through to
|
||||||
/// the shared store, which is what every display surface actually consults.
|
/// the shared store, which is what every display surface actually consults.
|
||||||
@State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled
|
@State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled
|
||||||
|
/// Slider drafts — the value under the thumb while it is down. `nil` means
|
||||||
|
/// "not dragging", in which case the stored setting is shown, so an external
|
||||||
|
/// change (a unit switch, the in-list control, a QA hook) is picked up with
|
||||||
|
/// no re-seeding. Dragging writes only the draft; the setting is saved once,
|
||||||
|
/// when the drag ends — see `commitMPG` / `commitFillUp`.
|
||||||
|
@State private var mpgDraft: Double?
|
||||||
|
@State private var fillUpDraft: Double?
|
||||||
@State private var versionTapCount = 0
|
@State private var versionTapCount = 0
|
||||||
@State private var lastVersionTap = Date.distantPast
|
@State private var lastVersionTap = Date.distantPast
|
||||||
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
||||||
@@ -132,9 +139,15 @@ struct SettingsView: View {
|
|||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Slider(value: fuelSavingsMPGBinding,
|
// Draft-bound: the thumb moves a local value and the
|
||||||
|
// setting is written once, when the drag ends. Writing
|
||||||
|
// durable app-wide state on every step is what made this
|
||||||
|
// slider stutter.
|
||||||
|
Slider(value: mpgSliderValue,
|
||||||
in: FuelSavingsCalculator.mpgSliderRange,
|
in: FuelSavingsCalculator.mpgSliderRange,
|
||||||
step: FuelSavingsCalculator.mpgSliderStep)
|
step: FuelSavingsCalculator.mpgSliderStep) { editing in
|
||||||
|
if !editing { commitMPG() }
|
||||||
|
}
|
||||||
.accessibilityLabel("Average fuel economy")
|
.accessibilityLabel("Average fuel economy")
|
||||||
.accessibilityValue(mpgText)
|
.accessibilityValue(mpgText)
|
||||||
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
|
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
|
||||||
@@ -150,9 +163,11 @@ struct SettingsView: View {
|
|||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Slider(value: fuelSavingsAmountBinding,
|
Slider(value: fillUpSliderValue,
|
||||||
in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit),
|
in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit),
|
||||||
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit))
|
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit)) { editing in
|
||||||
|
if !editing { commitFillUp() }
|
||||||
|
}
|
||||||
.accessibilityLabel("Typical fill-up")
|
.accessibilityLabel("Typical fill-up")
|
||||||
.accessibilityValue(fillUpText)
|
.accessibilityValue(fillUpText)
|
||||||
Picker("Minimum saving", selection: $bestValueThresholdGBP) {
|
Picker("Minimum saving", selection: $bestValueThresholdGBP) {
|
||||||
@@ -710,27 +725,56 @@ struct SettingsView: View {
|
|||||||
private var fuelSavingsEnabledBinding: Binding<Bool> {
|
private var fuelSavingsEnabledBinding: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.enabled },
|
get: { fuelSavingsSettings.enabled },
|
||||||
set: { fuelSavingsSettings.enabled = $0; persistFuelSavingsSettings() }
|
set: { fuelSavingsSettings.enabled = $0; saveFuelSavingsField(.enabled) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fuelSavingsMPGBinding: Binding<Double> {
|
/// Slider values, draft-first: while the thumb is down the draft is what the
|
||||||
|
/// control and its read-out show; with no drag in progress the store's value
|
||||||
|
/// shows, so any external change (unit switch, in-list control, QA hook) is
|
||||||
|
/// reflected without re-seeding anything.
|
||||||
|
private var mpgSliderValue: Binding<Double> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.mpg },
|
get: { mpgDraft ?? fuelSavingsSettings.mpg },
|
||||||
set: { fuelSavingsSettings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG); persistFuelSavingsSettings() }
|
set: { mpgDraft = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fuelSavingsAmountBinding: Binding<Double> {
|
private var fillUpSliderValue: Binding<Double> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.preferredAmount.value },
|
get: { fillUpDraft ?? fuelSavingsSettings.preferredAmount.value },
|
||||||
set: {
|
set: { fillUpDraft = $0 }
|
||||||
fuelSavingsSettings.preferredAmount.value = min(max($0, 0), FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
|
|
||||||
persistFuelSavingsSettings()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The drag has ended: write the value once, and only if it actually moved.
|
||||||
|
/// The draft is cleared either way, so the control falls back to the stored
|
||||||
|
/// setting and shows what was saved — the clamps can lower it.
|
||||||
|
private func commitMPG() {
|
||||||
|
defer { mpgDraft = nil }
|
||||||
|
guard let draft = mpgDraft else { return }
|
||||||
|
let mpg = min(max(draft, 0), FuelSavingsCalculator.maximumMPG)
|
||||||
|
guard mpg != fuelSavingsSettings.mpg else { return }
|
||||||
|
fuelSavingsSettings.mpg = mpg
|
||||||
|
saveFuelSavingsField(.mpg)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commitFillUp() {
|
||||||
|
defer { fillUpDraft = nil }
|
||||||
|
guard let draft = fillUpDraft else { return }
|
||||||
|
let amount = min(max(draft, 0),
|
||||||
|
FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
|
||||||
|
guard amount != fuelSavingsSettings.preferredAmount.value else { return }
|
||||||
|
fuelSavingsSettings.preferredAmount.value = amount
|
||||||
|
saveFuelSavingsField(.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists the one field that changed. Saving the whole settings object
|
||||||
|
/// rewrites all four keys, which is only right when more than one has moved.
|
||||||
|
private func saveFuelSavingsField(_ field: FuelStore.FuelSavingsField) {
|
||||||
|
FuelStore.saveFuelSavingsField(field, from: fuelSavingsSettings)
|
||||||
|
}
|
||||||
|
|
||||||
private var fuelSavingsUnitBinding: Binding<FuelSavingsFillUnit> {
|
private var fuelSavingsUnitBinding: Binding<FuelSavingsFillUnit> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.preferredAmount.unit },
|
get: { fuelSavingsSettings.preferredAmount.unit },
|
||||||
@@ -739,30 +783,32 @@ struct SettingsView: View {
|
|||||||
fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
|
fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
|
||||||
afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
|
afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
|
||||||
settingsUnitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
|
settingsUnitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
|
||||||
persistFuelSavingsSettings()
|
// The unit and the amount move together, so both keys are written.
|
||||||
|
saveFuelSavingsField(.unit)
|
||||||
|
saveFuelSavingsField(.amount)
|
||||||
|
// A drag in progress on the old scale no longer maps to this one.
|
||||||
|
fillUpDraft = nil
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "45 MPG" / "45.5 MPG". The slider is the only control, so this read-out
|
/// "45 MPG" / "45.5 MPG". The slider is the only control, so this read-out
|
||||||
/// is the one place the figure is stated.
|
/// is the one place the figure is stated. It follows the draft while the
|
||||||
|
/// thumb is down.
|
||||||
private var mpgText: String {
|
private var mpgText: String {
|
||||||
let mpg = fuelSavingsSettings.mpg
|
let mpg = mpgDraft ?? fuelSavingsSettings.mpg
|
||||||
return mpg.rounded() == mpg ? String(format: "%.0f MPG", mpg) : String(format: "%.1f MPG", mpg)
|
return mpg.rounded() == mpg ? String(format: "%.0f MPG", mpg) : String(format: "%.1f MPG", mpg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "£40" / "40 L" — the fill-up slider's read-out.
|
/// "£40" / "40 L" — the fill-up slider's read-out, draft-first like the MPG one.
|
||||||
private var fillUpText: String {
|
private var fillUpText: String {
|
||||||
|
let value = fillUpDraft ?? fuelSavingsSettings.preferredAmount.value
|
||||||
switch fuelSavingsSettings.preferredAmount.unit {
|
switch fuelSavingsSettings.preferredAmount.unit {
|
||||||
case .pounds: return String(format: "£%.0f", fuelSavingsSettings.preferredAmount.value)
|
case .pounds: return String(format: "£%.0f", value)
|
||||||
case .litres: return String(format: "%.0f L", fuelSavingsSettings.preferredAmount.value)
|
case .litres: return String(format: "%.0f L", value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistFuelSavingsSettings() {
|
|
||||||
FuelStore.saveFuelSavingsSettings(fuelSavingsSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
||||||
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
||||||
private var connectionText: String {
|
private var connectionText: String {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+47
-5
@@ -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:)`.
|
||||||
|
|||||||
Reference in New Issue
Block a user