Best Value: explain price age per surface instead of one blanket warning

- Station detail now separates the station's own report age from the
  selected fuel's update time, so a recent station-wide report no longer
  reads as an old price.
- Warning copy names the fuel and says which side is weak: the station's
  price is not individually dated, or the baseline's is. The misleading
  "unverified or older than 7 days" line is gone.
- Wording stays "Closest" throughout; new copy uses Unleaded / Premium /
  Diesel rather than the internal grade codes.
- Localisation: new and updated keys for the added copy.
This commit is contained in:
FuelBoard Contributor
2026-10-15 21:37:35 +01:00
parent 7c2eeb2604
commit 9e77ee2947
8 changed files with 222 additions and 78 deletions
+47 -23
View File
@@ -401,28 +401,27 @@ struct ContentView: View {
/// (computing a journey for each) and `savingsLookup` immediately scanned them /// (computing a journey for each) and `savingsLookup` immediately scanned them
/// all AGAIN to fill the per-station map. Both now share one result. /// all AGAIN to fill the per-station map. Both now share one result.
/// ///
/// The baseline is chosen with EXACTLY the loop and guards of /// The baseline is chosen with the same journey and selected-fuel freshness
/// `FuelSavingsEstimator.baselineStation`, so the row labelled "Closest /// guards as `FuelSavingsEstimator.bestValueBaselineStation` when configured;
/// station" cannot move — and it is still selected when the feature is /// with the feature off, the ordinary Closest station still names that row.
/// unconfigured (it names that row), while the maps are only built when the
/// labels will actually be shown.
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) { private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
guard let location else { return (nil, [:]) } guard let location else { return (nil, [:]) }
let configured = fuelSavingsSettings.isConfigured guard fuelSavingsSettings.isConfigured else {
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
savingsJourneyMiles(for: $0, location: location)
}
return (baseline, [:])
}
var journeys: [String: FuelSavingsJourney] = [:] var journeys: [String: FuelSavingsJourney] = [:]
var bestStation: FuelStation?
var bestMiles = Double.infinity
for station in stations where station.prices[selectedFuel] != nil { for station in stations where station.prices[selectedFuel] != nil {
guard let journey = savingsJourneyMiles(for: station, location: location) else { continue } if let journey = savingsJourneyMiles(for: station, location: location) {
if configured { journeys[station.id] = journey } journeys[station.id] = journey
let miles = journey.miles
guard miles.isFinite, miles >= 0 else { continue }
if miles < bestMiles {
bestMiles = miles
bestStation = station
} }
} }
return (bestStation, configured ? journeys : [:]) let baseline = FuelSavingsEstimator.bestValueBaselineStation(stations, fuel: selectedFuel) {
journeys[$0.id]
}
return (baseline, journeys)
} }
/// Row/detail savings lookup with the comparison baseline and the per-station /// Row/detail savings lookup with the comparison baseline and the per-station
@@ -455,15 +454,15 @@ struct ContentView: View {
} }
} }
/// The nearest station that sells the selected fuel — the Best Value /// The Closest station with an eligible selected-fuel price — the Best Value
/// comparison baseline. It is chosen with the SAME journey values used by /// comparison baseline. Ordinary Closest sorting still includes stale stations.
/// the savings calculation, so a cached road route can change the baseline /// It uses the same journey values as the savings calculation, so a cached
/// and keep the "extra return travel" labels synchronized with the /// road route can change the baseline and keep the "extra return travel"
/// cost calculation. When no road route is cached, the journey falls back to /// labels synchronized with the cost calculation. Without a cached route,
/// straight-line out-and-back, and the result is marked as an estimate. /// the journey falls back to straight-line and the result is an estimate.
private var nearestSavingsBaseline: FuelStation? { private var nearestSavingsBaseline: FuelStation? {
guard let location else { return nil } guard let location else { return nil }
return FuelSavingsEstimator.baselineStation( return FuelSavingsEstimator.bestValueBaselineStation(
stations, stations,
fuel: selectedFuel, fuel: selectedFuel,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) } journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
@@ -803,6 +802,10 @@ struct ContentView: View {
fuelSavings: savingsLookup(baseline: scaffold.baseline, fuelSavings: savingsLookup(baseline: scaffold.baseline,
journeys: scaffold.journeys)(station), journeys: scaffold.journeys)(station),
fuelSavingsAmount: sessionFuelSavingsAmount, fuelSavingsAmount: sessionFuelSavingsAmount,
savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence.assess(
station: station, fuel: selectedFuel, baseline: scaffold.baseline),
savingsPriceWarning: FuelPriceFreshness.comparisonWarning(
station: station, fuel: selectedFuel, baseline: scaffold.baseline),
savingsBaselineStationName: scaffold.baseline?.name, savingsBaselineStationName: scaffold.baseline?.name,
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline), journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
isFavourite: favouriteIDs.contains(station.id), isFavourite: favouriteIDs.contains(station.id),
@@ -1119,6 +1122,11 @@ struct ContentView: View {
} }
} }
.onChange(of: fuelSavingsSettings) { old, new in .onChange(of: fuelSavingsSettings) { old, new in
// An explicit Settings default edit supersedes the temporary slider
// override. Changes to MPG/enabled alone leave the session fill intact.
if old.preferredAmount != new.preferredAmount {
sessionFuelSavingsAmount = new.preferredAmount
}
// Fuel Savings changed in Settings while the app was running: the // Fuel Savings changed in Settings while the app was running: the
// effective sort follows automatically (no stored sort is touched). // effective sort follows automatically (no stored sort is touched).
#if DEBUG #if DEBUG
@@ -1524,6 +1532,12 @@ struct ContentView: View {
fuelSavingsAmount: $sessionFuelSavingsAmount, fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys), savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) }, journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
savingsPriceConfidenceForStation: { station in
FuelPriceFreshness.ComparisonConfidence.assess(station: station, fuel: selectedFuel, baseline: baselineStation)
},
savingsPriceWarningForStation: { station in
FuelPriceFreshness.comparisonWarning(station: station, fuel: selectedFuel, baseline: baselineStation)
},
onToggleFavourite: toggleFavourite, onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) } onRefresh: { await refresh(force: true) }
) )
@@ -1545,6 +1559,12 @@ struct ContentView: View {
fuelSavingsAmount: $sessionFuelSavingsAmount, fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys), savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) }, journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
savingsPriceConfidenceForStation: { station in
FuelPriceFreshness.ComparisonConfidence.assess(station: station, fuel: selectedFuel, baseline: baselineStation)
},
savingsPriceWarningForStation: { station in
FuelPriceFreshness.comparisonWarning(station: station, fuel: selectedFuel, baseline: baselineStation)
},
onToggleFavourite: toggleFavourite, onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites, onReorder: reorderFavourites,
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } }, onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
@@ -1782,6 +1802,8 @@ struct StationRow: View {
/// compact banner does not print it today, but a row that carried a hard /// compact banner does not print it today, but a row that carried a hard
/// zero would print "for £0" the moment it did — pass the real figure. /// zero would print "for £0" the moment it did — pass the real figure.
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var savingsPriceWarning: String? = nil
var onToggleFavourite: () -> Void = {} var onToggleFavourite: () -> Void = {}
var onShowDetails: (FuelStation) -> Void = { _ in } var onShowDetails: (FuelStation) -> Void = { _ in }
@@ -1942,6 +1964,8 @@ struct StationRow: View {
FuelSavingsSummaryView( FuelSavingsSummaryView(
availability: fuelSavings, availability: fuelSavings,
amount: fuelSavingsAmount, amount: fuelSavingsAmount,
priceConfidence: savingsPriceConfidence,
priceWarning: savingsPriceWarning,
compact: true, compact: true,
distanceUnit: distanceUnit distanceUnit: distanceUnit
) )
+12 -4
View File
@@ -25,6 +25,8 @@ struct FavouritesView: View {
/// journey distance — the same baseline and definition the Stations list uses. /// journey distance — the same baseline and definition the Stations list uses.
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil } var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil } var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var savingsPriceConfidenceForStation: (FuelStation) -> FuelPriceFreshness.ComparisonConfidence
var savingsPriceWarningForStation: (FuelStation) -> String? = { _ in nil }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop). /// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in } var onReorder: ([FavouriteEntry]) -> Void = { _ in }
@@ -84,6 +86,8 @@ struct FavouritesView: View {
fuelSavingsAmount: Binding<FuelSavingsFillAmount>, fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil }, savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }, journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
savingsPriceConfidenceForStation: @escaping (FuelStation) -> FuelPriceFreshness.ComparisonConfidence = { _ in .ineligible },
savingsPriceWarningForStation: @escaping (FuelStation) -> String? = { _ in nil },
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }, onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }, onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
onHistoryUnavailable: (() -> Void)? = nil, onHistoryUnavailable: (() -> Void)? = nil,
@@ -98,6 +102,8 @@ struct FavouritesView: View {
_fuelSavingsAmount = fuelSavingsAmount _fuelSavingsAmount = fuelSavingsAmount
self.savingsForStation = savingsForStation self.savingsForStation = savingsForStation
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
self.savingsPriceConfidenceForStation = savingsPriceConfidenceForStation
self.savingsPriceWarningForStation = savingsPriceWarningForStation
self.onToggleFavourite = onToggleFavourite self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder self.onReorder = onReorder
self.onHistoryUnavailable = onHistoryUnavailable self.onHistoryUnavailable = onHistoryUnavailable
@@ -144,10 +150,7 @@ struct FavouritesView: View {
if fuelSavingsSettings.isConfigured { if fuelSavingsSettings.isConfigured {
Section { Section {
FuelSavingsAdjustmentView( FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
amount: $fuelSavingsAmount,
referencePricePencePerLitre: cheapestPrice
)
} footer: { } footer: {
Text(String(localized: "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation")) Text(String(localized: "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
} }
@@ -168,6 +171,8 @@ struct FavouritesView: View {
tapAction: stationTapAction, tapAction: stationTapAction,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount, fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
onToggleFavourite: { onToggleFavourite(station, activeFuel) }, onToggleFavourite: { onToggleFavourite(station, activeFuel) },
onShowDetails: { detailStation = $0 } onShowDetails: { detailStation = $0 }
) )
@@ -216,8 +221,11 @@ struct FavouritesView: View {
station: station, station: station,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
selectedFuel: activeFuel,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount, fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
journeyDiagnostics: journeyDiagnosticsForStation(station), journeyDiagnostics: journeyDiagnosticsForStation(station),
// Every station listed here IS a favourite for the tab's // Every station listed here IS a favourite for the tab's
// fuel, so the star starts filled; tapping it unstars. // fuel, so the star starts filled; tapping it unstars.
+36 -9
View File
@@ -2,7 +2,8 @@ import SwiftUI
struct FuelSavingsAdjustmentView: View { struct FuelSavingsAdjustmentView: View {
@Binding var amount: FuelSavingsFillAmount @Binding var amount: FuelSavingsFillAmount
let referencePricePencePerLitre: Double?
@State private var unitChangeNotice: String?
private var range: ClosedRange<Double> { private var range: ClosedRange<Double> {
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit) FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
@@ -44,6 +45,14 @@ struct FuelSavingsAdjustmentView: View {
Text("Changes the estimate for this session only — your Settings default stays unchanged.") Text("Changes the estimate for this session only — your Settings default stays unchanged.")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("£ compares the litres your budget buys at each station; Litres compares one fixed quantity.")
.font(.caption2)
.foregroundStyle(.secondary)
if let unitChangeNotice {
Text(unitChangeNotice)
.font(.caption2)
.foregroundStyle(.orange)
}
} }
} }
@@ -52,12 +61,10 @@ struct FuelSavingsAdjustmentView: View {
get: { amount.unit }, get: { amount.unit },
set: { newUnit in set: { newUnit in
guard newUnit != amount.unit else { return } guard newUnit != amount.unit else { return }
if let referencePricePencePerLitre, // No one litre figure equals a budget at every candidate price.
let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) { // A unit change starts a new, explicitly labelled comparison.
amount = converted amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
} else { unitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: newUnit)
}
} }
) )
} }
@@ -73,6 +80,9 @@ struct FuelSavingsAdjustmentView: View {
struct FuelSavingsSummaryView: View { struct FuelSavingsSummaryView: View {
let availability: FuelSavingsAvailability? let availability: FuelSavingsAvailability?
let amount: FuelSavingsFillAmount let amount: FuelSavingsFillAmount
/// Candidate/baseline price confidence for every Best Value result.
var priceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var priceWarning: String? = nil
var baselineStationName: String? = nil var baselineStationName: String? = nil
var compact: Bool = false var compact: Bool = false
/// Sizes the "extra return travel" caption in the user's own unit (the /// Sizes the "extra return travel" caption in the user's own unit (the
@@ -107,6 +117,12 @@ struct FuelSavingsSummaryView: View {
.layoutPriority(1) .layoutPriority(1)
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))") .accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
} }
if let warning = priceWarning ?? priceConfidence.warningText {
Text(warning)
.font(.caption2)
.foregroundStyle(.orange)
.accessibilityLabel(warning)
}
if let extraTravel = FuelSavingsFormat.extraTravelLabel( if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles, extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit, unit: distanceUnit,
@@ -143,6 +159,17 @@ struct FuelSavingsSummaryView: View {
LabeledContent("Your fill-up", value: amountText) LabeledContent("Your fill-up", value: amountText)
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres)) LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
.monospacedDigit() .monospacedDigit()
if amount.unit == .pounds {
Text("You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund.")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let warning = priceWarning ?? priceConfidence.warningText {
Text(warning)
.font(.caption2)
.foregroundStyle(.orange)
.accessibilityLabel(warning)
}
if let extraTravel = FuelSavingsFormat.extraTravelLabel( if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles, extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit, unit: distanceUnit,
@@ -158,7 +185,7 @@ struct FuelSavingsSummaryView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
Text("Compared with \(baselineStationName ?? "your closest station").") Text(baselineStationName.map { "Compared with \($0)." } ?? "Compared with your closest eligible station.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
LabeledContent( LabeledContent(
@@ -233,7 +260,7 @@ struct FuelSavingsSummaryView: View {
} }
/// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for /// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for
/// estimated comparisons and leaves the plain words ("Break even", "Closest /// and leaves the plain words ("Break even", "Closest eligible
/// station") alone — one source, so visible and spoken copy cannot drift. /// station") alone — one source, so visible and spoken copy cannot drift.
private func accessibilityHeadline(for result: FuelSavingsResult) -> String { private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
result.spokenHeadline() result.spokenHeadline()
+2
View File
@@ -16,6 +16,8 @@ struct HowBestValueSheet: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("Best Value compares each station with the closest eligible 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 eligible 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.")
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 eligible station, then deduct extra journey fuel. You do not receive cash back.")
Text("With Litres, every station is compared for the fixed quantity you selected.")
Text("Results are marked “Approx.” until road distances are available.") Text("Results are marked “Approx.” until road distances are available.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
+10 -4
View File
@@ -79,6 +79,7 @@ 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 {
@@ -148,6 +149,11 @@ struct SettingsView: View {
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit)) step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit))
.accessibilityLabel("Typical fill-up") .accessibilityLabel("Typical fill-up")
.accessibilityValue(fillUpText) .accessibilityValue(fillUpText)
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(.caption)
@@ -709,10 +715,10 @@ struct SettingsView: View {
Binding( Binding(
get: { fuelSavingsSettings.preferredAmount.unit }, get: { fuelSavingsSettings.preferredAmount.unit },
set: { newUnit in set: { newUnit in
fuelSavingsSettings.preferredAmount.unit = newUnit guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return }
if !fuelSavingsSettings.preferredAmount.isValid { fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
fuelSavingsSettings.preferredAmount.value = newUnit == .pounds ? 40 : 30 afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
} settingsUnitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
persistFuelSavingsSettings() persistFuelSavingsSettings()
} }
) )
+32
View File
@@ -17,8 +17,12 @@ struct StationDetailView: View {
let station: FuelStation let station: FuelStation
let location: Coordinate? let location: Coordinate?
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
/// Fuel whose price is being shown by the surrounding station list.
var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
var fuelSavings: FuelSavingsAvailability? = nil var fuelSavings: FuelSavingsAvailability? = nil
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var savingsPriceWarning: String? = nil
var savingsBaselineStationName: String? = nil var savingsBaselineStationName: String? = nil
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
/// Whether this station is starred for the fuel currently being viewed, and /// Whether this station is starred for the fuel currently being viewed, and
@@ -57,6 +61,18 @@ struct StationDetailView: View {
station.postcode.trimmingCharacters(in: .whitespacesAndNewlines) station.postcode.trimmingCharacters(in: .whitespacesAndNewlines)
} }
private var priceFreshness: FuelPriceFreshness.Assessment {
FuelPriceFreshness.assessment(for: station, fuel: selectedFuel)
}
/// The selected grade's current price, when present. Kept separate from
/// the freshness label so a stale price remains visible rather than being
/// silently hidden.
private var selectedFuelPriceText: String {
guard let price = station.prices[selectedFuel] else { return "—" }
return FuelStore.priceText(price, style: .stationSign)
}
/// Opens Apple Maps driving directions — the same destination (and the same /// Opens Apple Maps driving directions — the same destination (and the same
/// URL) the Directions button below uses, so the map and the button can /// URL) the Directions button below uses, so the map and the button can
/// never point at two different places. /// never point at two different places.
@@ -197,6 +213,20 @@ struct StationDetailView: View {
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit)) LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
.monospacedDigit() .monospacedDigit()
} }
if let reportAge = priceFreshness.stationReportAgeLabel {
LabeledContent("Station price report", value: reportAge)
.monospacedDigit()
}
LabeledContent("\(selectedFuel.displayName) update time", value: priceFreshness.selectedFuelUpdateLabel)
.monospacedDigit()
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
.monospacedDigit()
if priceFreshness.warning {
Label("Price freshness is not confirmed for \(selectedFuel.displayName)",
systemImage: "exclamationmark.triangle.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
}
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))
@@ -237,6 +267,8 @@ struct StationDetailView: View {
FuelSavingsSummaryView( FuelSavingsSummaryView(
availability: fuelSavings, availability: fuelSavings,
amount: fuelSavingsAmount, amount: fuelSavingsAmount,
priceConfidence: savingsPriceConfidence,
priceWarning: savingsPriceWarning,
baselineStationName: savingsBaselineStationName, baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
showInfoAction: { showBestValueInfo = true } showInfoAction: { showBestValueInfo = true }
+56 -35
View File
@@ -20,6 +20,8 @@ struct StationsView: View {
@Binding var fuelSavingsAmount: FuelSavingsFillAmount @Binding var fuelSavingsAmount: FuelSavingsFillAmount
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil } var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil } var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var savingsPriceConfidenceForStation: (FuelStation) -> FuelPriceFreshness.ComparisonConfidence
var savingsPriceWarningForStation: (FuelStation) -> String?
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onRefresh: () async -> Void = {} var onRefresh: () async -> Void = {}
@@ -67,7 +69,7 @@ struct StationsView: View {
} }
if location != nil { if location != nil {
if sortMode == .closest { if sortMode == .closest {
return "\(mode) \(fuel) stations — closest first, best value within \(miles) \(unit) · \(ratio) stations updated" return "\(mode) \(fuel) stations — closest first, lowest price within \(miles) \(unit) · \(ratio) stations updated"
} }
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)" return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
} }
@@ -120,10 +122,12 @@ struct StationsView: View {
// stale stored sort mode must not keep the control on screen // stale stored sort mode must not keep the control on screen
// for a switched-off feature. // for a switched-off feature.
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured { if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
FuelSavingsAdjustmentView( FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
amount: $fuelSavingsAmount, if !stations.isEmpty && stations.allSatisfy({ savingsPriceConfidenceForStation($0) == .ineligible }) {
referencePricePencePerLitre: baselinePrice Text("Best Value unavailable: no eligible price comparison. Stations and prices remain visible below.")
) .font(.caption)
.foregroundStyle(.orange)
}
} }
if isLoading && stations.isEmpty { if isLoading && stations.isEmpty {
@@ -148,22 +152,7 @@ struct StationsView: View {
.padding(.vertical, 4) .padding(.vertical, 4)
} else { } else {
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
StationRow( stationRow(station)
station: station,
fuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice,
isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id),
topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
tapAction: stationTapAction,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
onShowDetails: { detailStation = $0 }
)
} }
if visibleCount < stations.count { if visibleCount < stations.count {
@@ -259,6 +248,31 @@ struct StationsView: View {
} }
} }
.sheet(isPresented: $showKey) { .sheet(isPresented: $showKey) {
keySheet
}
.sheet(item: $detailStation) { station in
detailSheet(for: station)
}
}
}
private func detailSheet(for station: FuelStation) -> some View {
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit,
selectedFuel: selectedFuel,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
journeyDiagnostics: journeyDiagnosticsForStation(station),
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
)
}
private var keySheet: some View {
NavigationStack { NavigationStack {
List { List {
Section { Section {
@@ -339,20 +353,27 @@ struct StationsView: View {
} }
.presentationDetents([.fraction(0.6)]) .presentationDetents([.fraction(0.6)])
.presentationBackground(Color(UIColor.systemGroupedBackground)) .presentationBackground(Color(UIColor.systemGroupedBackground))
} }
.sheet(item: $detailStation) { station in
StationDetailView( private func stationRow(_ station: FuelStation) -> some View {
station: station, StationRow(
location: location, station: station,
distanceUnit: distanceUnit, fuel: selectedFuel,
fuelSavings: savingsForStation(station), location: location,
fuelSavingsAmount: fuelSavingsAmount, distanceUnit: distanceUnit,
journeyDiagnostics: journeyDiagnosticsForStation(station), priceDisplayStyle: priceDisplayStyle,
isFavourite: favouriteIDs.contains(station.id), baselinePrice: baselinePrice,
onToggleFavourite: { onToggleFavourite(station, selectedFuel) } isTopResult: station.id == topStationID,
) isFavourite: favouriteIDs.contains(station.id),
} topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
} tapAction: stationTapAction,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
onShowDetails: { detailStation = $0 }
)
} }
private var availableSortModes: [SortMode] { private var availableSortModes: [SortMode] {
+27 -3
View File
@@ -12,7 +12,7 @@
"TOP" = "TOP"; "TOP" = "TOP";
/* Stations tab */ /* Stations tab */
"Closest %@ stations — closest first, best value within %lld %@ · %lld stations" = "Closest %@ stations — closest first, best value within %lld %@ · %lld stations"; "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations";
"Cheapest %@ within %lld %@ — %lld stations · tap a station for directions." = "Cheapest %@ within %lld %@ — %lld stations · tap a station for directions."; "Cheapest %@ within %lld %@ — %lld stations · tap a station for directions." = "Cheapest %@ within %lld %@ — %lld stations · tap a station for directions.";
"%@ %@ — tap a station for directions." = "%@ %@ — tap a station for directions."; "%@ %@ — tap a station for directions." = "%@ %@ — tap a station for directions.";
"Fuel type" = "Fuel type"; "Fuel type" = "Fuel type";
@@ -36,7 +36,10 @@
"Remove from favourites" = "Remove from favourites"; "Remove from favourites" = "Remove from favourites";
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list."; "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
"How Best Value works" = "How Best Value works"; "How Best Value works" = "How Best Value works";
"Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey."; "Best Value compares each station with the closest eligible 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." = "Best Value compares each station with the closest eligible 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.";
"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 eligible station, then deduct extra journey fuel. You do not receive cash back." = "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 eligible station, then deduct extra journey fuel. You do not receive cash back.";
"Your closest eligible station by journey distance is the baseline — every other saving is measured against it." = "Your closest eligible station by journey distance is the baseline — every other saving is measured against it.";
"Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list." = "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.";
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall."; "Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available."; "Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
@@ -70,7 +73,6 @@
"More info" = "More info"; "More info" = "More info";
"Name" = "Name"; "Name" = "Name";
"Address" = "Address"; "Address" = "Address";
"Distance" = "Distance";
"Copies the postcode" = "Copies the postcode"; "Copies the postcode" = "Copies the postcode";
"Postcode copied" = "Postcode copied"; "Postcode copied" = "Postcode copied";
"Address: %@" = "Address: %@"; "Address: %@" = "Address: %@";
@@ -217,3 +219,25 @@
"A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle." = "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle."; "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle." = "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.";
"Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires." = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires."; "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires." = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.";
"%@ — %@" = "%@ — %@"; "%@ — %@" = "%@ — %@";
/* Best Value calculation and price confidence */
"£ compares the litres your budget buys at each station; Litres compares one fixed quantity." = "£ compares the litres your budget buys at each station; Litres compares one fixed quantity.";
"You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund." = "You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund.";
"Price age" = "Price age";
"Best Value unavailable · price confidence insufficient" = "Best Value unavailable · price confidence insufficient";
"Best Value unavailable · location needed" = "Best Value unavailable · location needed";
"price order · Best Value unavailable" = "price order · Best Value unavailable";
"your order" = "your order";
"Price not individually dated — saving is an estimate." = "Price not individually dated — saving is an estimate.";
"Price age unavailable or too old — not a Best Value recommendation." = "Price age unavailable or too old — not a Best Value recommendation.";
"%@ ago" = "%@ ago";
"Not provided" = "Not provided";
"Station price report" = "Station price report";
"%@ update time" = "%@ update time";
"Estimated saving · %@ price not individually dated" = "Estimated saving · %@ price not individually dated";
"Estimated saving · closest eligible station's %@ price not individually dated" = "Estimated saving · closest eligible station's %@ price not individually dated";
"Estimated saving · %@ price over 7 days old" = "Estimated saving · %@ price over 7 days old";
"Estimated saving · closest eligible station's %@ price over 7 days old" = "Estimated saving · closest eligible station's %@ price over 7 days old";
"Fuel you'll receive" = "Fuel you'll receive";
"Best Value unavailable: no eligible price comparison. Stations and prices remain visible below." = "Best Value unavailable: no eligible price comparison. Stations and prices remain visible below.";
"Compared with your closest eligible station." = "Compared with your closest eligible station.";