Calculation audit (brief config: 30 MPG imperial, £60 fill-up, baseline
Rontec Shelf 171.9p at 0.4 mi):
- One mile<->kilometre constant pair (kilometresPerMile 1.609344,
metresPerMile 1609.344) replaces the mixed 1609.34 / 0.621371 / 1.60934
used in the savings path. Effect ~2 ppm (invisible), but the conversions
are now auditable in one place.
- New FuelSavingsCalculationAuditTests (20 deterministic tests, no live
MKDirections) pins the brief's three stations - Tesco 170.9p/2.0 mi,
MFG Hare & Hounds 172.9p/2.8 mi, PGG Napoleon 171.9p/2.8 mi - plus
imperial-vs-US gallon, MPG and budget scaling, the break-even tolerance
boundary, the nearer-candidate clamp, route-unavailable, mixed-basis
estimates and display-rounding boundaries.
- Formula unchanged, and verified correct: fixed quantity (budget / baseline
price), pump saving, round-trip extra distance clamped at zero, travel fuel
priced at the candidate station, +/-5p break even, baseline labelled
"Closest station", sorting on the unrounded net saving. The brief's "£0.18"
for Tesco is the rounded 1 dp extra-distance caption (2.02-2.05 mi reads as
"2.0 mi"), not a rounding bug in the money.
UI (uncommitted since d7a0ecb):
- Done bar drawn in the bottom safe area from keyboard notifications:
.toolbar(placement: .keyboard) is device-dependent (present on the 15 Pro,
absent on the SE on the same iOS build).
- Favourite star in the More-info heading, wired to the same fuel-scoped
toggle as the listing rows at all three call sites.
- CHEAPEST/TOP badge moved above the price so it reads as part of it.
- Listing rows carry the real session fill-up amount instead of a hard zero
(the compact banner does not print it today; a future row layout would).
153 tests, 0 failures. Simulator and device builds succeed.
240 lines
12 KiB
Swift
240 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 nearest eligible station —
|
|
/// the same baseline and definition the Stations list uses.
|
|
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
|
var savingsBaselineStationName: String?
|
|
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 },
|
|
savingsBaselineStationName: String? = 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.savingsBaselineStationName = savingsBaselineStationName
|
|
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,
|
|
referencePricePencePerLitre: cheapestPrice
|
|
)
|
|
} footer: {
|
|
Text(String(localized: "Savings below are estimated against the nearest station with a price for this fuel — 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),
|
|
topBadgeText: "CHEAPEST",
|
|
tapAction: stationTapAction,
|
|
fuelSavings: savingsForStation(station),
|
|
fuelSavingsAmount: fuelSavingsAmount,
|
|
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,
|
|
fuelSavings: savingsForStation(station),
|
|
fuelSavingsAmount: fuelSavingsAmount,
|
|
savingsBaselineStationName: savingsBaselineStationName,
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
}
|