The Best Value card states its verdict once. The badge is now the large capsule form the sheet used to repeat on its own line, so the same headline no longer appears twice, and the comparison caption ("vs closest station" / "your baseline") sits under the capsule — on the card and on the listing rows alike. The row's size-class split, which put the caption beside the capsule at regular width, is gone, so the pair reads the same on every device.
The facts row's price now follows Settings -> Units -> Price display. It called FuelStore.priceText(price, style: .stationSign) with the style hardcoded, so a Pounds & pence user still saw the bare forecourt figure under the map. It now uses priceTextAttributed with the style passed down from the list that opened the sheet (ContentView, StationsView, FavouritesView) — the same call the listing row makes, at the same @ScaledMetric(relativeTo: .title2) 22pt so it still scales with Dynamic Type.
"How Best Value works" gains a step-by-step section in plain English: the baseline and why it is the closest station by journey, the return trip and why the extra return travel can read larger than the one-way distance, the extra driving cost, the fuel-price saving, the total and its inversion when the drive costs more than the price saves, and where the minimum saving is set. Every statement matches the arithmetic in FuelSavingsCalculator. The sheet opens at 72% instead of 42% to give it room.
No calculation, ranking, wording or data source changed: comparisonLabel, priceTextAttributed and the shared FuelSavingsResult are the same calls they were.
237 lines
12 KiB
Swift
237 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
/// Favourites tab — starred stations, split by fuel type. Each fuel type
|
|
/// with at least one favourite appears as its own tab. The list shows the
|
|
/// USER'S manual order (drag to reorder — Edit button in the toolbar): the
|
|
/// FIRST station in each fuel's list is the one used as "the favourite" in
|
|
/// single widgets. Favourites are fuel-scoped entries — starring a station
|
|
/// only pins it for the fuel you were viewing, so an Unleaded favourite
|
|
/// never shows as Diesel.
|
|
struct FavouritesView: View {
|
|
let favourites: [FavouriteEntry]
|
|
/// The app's currently selected fuel — used only as the initial tab.
|
|
let selectedFuel: FuelType
|
|
let location: Coordinate?
|
|
let distanceUnit: DistanceUnit
|
|
let priceDisplayStyle: PriceDisplayStyle
|
|
let stationTapAction: StationTapAction
|
|
/// Fuel Savings config. When it is configured this tab shows the SAME
|
|
/// fill-up control and per-row estimate as the Stations list: one session
|
|
/// amount is shared, so adjusting it in either list updates both, and it
|
|
/// still never touches the Settings default.
|
|
let fuelSavingsSettings: FuelSavingsSettings
|
|
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
|
/// Estimated saving for a favourite, against the closest station by
|
|
/// journey distance — the same baseline and definition the Stations list uses.
|
|
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
|
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
|
/// Persists a reordered favourites array (after drag-and-drop).
|
|
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
|
/// Propagated from ContentView — Trends' history failure with no data
|
|
/// raises the global connection banner; recovery clears it.
|
|
var onHistoryUnavailable: (() -> Void)? = nil
|
|
var onHistoryRecovered: (() -> Void)? = nil
|
|
|
|
/// Fuel types that currently have at least one favourite — these are the
|
|
/// only tabs shown (a fuel with no favourites gets no tab).
|
|
private var availableFuels: [FuelType] {
|
|
FuelType.allCases.filter { fuel in
|
|
favourites.contains { $0.fuel == fuel }
|
|
}
|
|
}
|
|
|
|
/// The active tab. Falls back if the chosen fuel ever loses all its
|
|
/// favourites (e.g. the last one is un-starred while viewing it).
|
|
@State private var fuel: FuelType = .e10
|
|
|
|
/// Trends sheet (price history chart) presentation state.
|
|
@State private var showTrends = false
|
|
|
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
|
@State private var detailStation: FuelStation?
|
|
|
|
private var activeFuel: FuelType {
|
|
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
|
}
|
|
|
|
/// Stations favourited for the active fuel, in the USER'S stored order —
|
|
/// NOT price-sorted. The first row is the favourite used by single
|
|
/// widgets (see footer note).
|
|
private var ordered: [FuelStation] {
|
|
favourites.filter { $0.fuel == activeFuel }.map(\.station)
|
|
}
|
|
|
|
/// Cheapest favourited station for the active fuel — the RAG baseline and
|
|
/// the "cheapest favourite" callout, independent of the manual order.
|
|
private var cheapest: FuelStation? {
|
|
ordered.min { ($0.prices[activeFuel] ?? .infinity) < ($1.prices[activeFuel] ?? .infinity) }
|
|
}
|
|
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
|
|
|
|
/// IDs favourited for the active fuel — the star state on each row.
|
|
private var activeFuelFavouriteIDs: Set<String> {
|
|
Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id))
|
|
}
|
|
|
|
init(favourites: [FavouriteEntry],
|
|
selectedFuel: FuelType,
|
|
location: Coordinate?,
|
|
distanceUnit: DistanceUnit,
|
|
priceDisplayStyle: PriceDisplayStyle,
|
|
stationTapAction: StationTapAction,
|
|
fuelSavingsSettings: FuelSavingsSettings = .defaults,
|
|
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
|
|
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
|
|
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
|
|
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
|
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
|
onHistoryUnavailable: (() -> Void)? = nil,
|
|
onHistoryRecovered: (() -> Void)? = nil) {
|
|
self.favourites = favourites
|
|
self.selectedFuel = selectedFuel
|
|
self.location = location
|
|
self.distanceUnit = distanceUnit
|
|
self.priceDisplayStyle = priceDisplayStyle
|
|
self.stationTapAction = stationTapAction
|
|
self.fuelSavingsSettings = fuelSavingsSettings
|
|
_fuelSavingsAmount = fuelSavingsAmount
|
|
self.savingsForStation = savingsForStation
|
|
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
|
|
self.onToggleFavourite = onToggleFavourite
|
|
self.onReorder = onReorder
|
|
self.onHistoryUnavailable = onHistoryUnavailable
|
|
self.onHistoryRecovered = onHistoryRecovered
|
|
_fuel = State(initialValue: selectedFuel)
|
|
}
|
|
|
|
/// Drag-and-drop reorder (List edit mode): the active fuel's entries are
|
|
/// reordered within the global array; the fuel's block keeps its original
|
|
/// position and other fuels keep their relative order (FuelStore helper).
|
|
private func moveFavourite(fromOffsets source: IndexSet, toOffset destination: Int) {
|
|
onReorder(FuelStore.reorderedFavourites(
|
|
favourites, fuel: activeFuel,
|
|
fromOffsets: source, toOffset: destination
|
|
))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
if favourites.isEmpty {
|
|
Section {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "star")
|
|
.font(.system(size: 40))
|
|
.foregroundStyle(.secondary)
|
|
Text(String(localized: "No favourites yet", comment: "Favourites empty-state title"))
|
|
.font(.headline)
|
|
Text(String(localized: "Tap the star on any station in the Stations tab to pin it here. The first station in each list becomes the favourite used in single widgets — drag to put your pick first.", comment: "Favourites empty-state explanation"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 24)
|
|
}
|
|
} else {
|
|
Section(String(localized: "Fuel type", comment: "Fuel type section title")) {
|
|
FuelTypeSegmentedPicker(
|
|
selection: $fuel,
|
|
fuels: availableFuels
|
|
)
|
|
}
|
|
|
|
if fuelSavingsSettings.isConfigured {
|
|
Section {
|
|
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
|
} footer: {
|
|
Text(String(localized: "Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
|
}
|
|
}
|
|
|
|
Section {
|
|
ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
|
|
StationRow(
|
|
station: station,
|
|
fuel: activeFuel,
|
|
location: location,
|
|
distanceUnit: distanceUnit,
|
|
priceDisplayStyle: priceDisplayStyle,
|
|
baselinePrice: cheapestPrice,
|
|
isTopResult: station.id == cheapest?.id,
|
|
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
|
tapAction: stationTapAction,
|
|
fuelSavings: savingsForStation(station),
|
|
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
|
onShowDetails: { detailStation = $0 }
|
|
)
|
|
}
|
|
.onMove(perform: moveFavourite)
|
|
} header: {
|
|
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
|
|
} footer: {
|
|
Text(String(localized: "The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.", comment: "Favourites reorder explanation"))
|
|
}
|
|
|
|
if let cheapest = cheapest {
|
|
Section {
|
|
Text(String(format: String(localized: "%@ is your cheapest %@ favourite. Favourites are monitored automatically — switch to the Alerts tab and enable alerts to get pinged when you approach one that's the cheapest within the radius.", comment: "Cheapest favourite explanation"), cheapest.name, activeFuel.displayName))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
|
|
.toolbar {
|
|
if !favourites.isEmpty {
|
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
|
Button {
|
|
showTrends = true
|
|
} label: {
|
|
Image(systemName: "chart.xyaxis.line")
|
|
.accessibilityLabel(String(localized: "Trends", comment: "Trends button accessibility label"))
|
|
}
|
|
EditButton()
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showTrends) {
|
|
TrendsView(
|
|
favourites: favourites,
|
|
selectedFuel: activeFuel,
|
|
priceDisplayStyle: priceDisplayStyle,
|
|
onHistoryUnavailable: onHistoryUnavailable,
|
|
onHistoryRecovered: onHistoryRecovered
|
|
)
|
|
}
|
|
.sheet(item: $detailStation) { station in
|
|
StationDetailView(
|
|
station: station,
|
|
location: location,
|
|
distanceUnit: distanceUnit,
|
|
priceStyle: priceDisplayStyle,
|
|
selectedFuel: activeFuel,
|
|
fuelSavings: savingsForStation(station),
|
|
fuelSavingsAmount: fuelSavingsAmount,
|
|
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
|
// Every station listed here IS a favourite for the tab's
|
|
// fuel, so the star starts filled; tapping it unstars.
|
|
isFavourite: true,
|
|
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
|
)
|
|
}
|
|
.onAppear {
|
|
#if DEBUG
|
|
// QA hook: launch with `-showTrends` to open the sheet
|
|
// without a tap (same pattern as -showKeySheet).
|
|
if ProcessInfo.processInfo.arguments.contains("-showTrends") {
|
|
showTrends = true
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
}
|