Files
fuelboard/FuelBoard/FavouritesView.swift
T

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 closest eligible 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,
referencePricePencePerLitre: cheapestPrice
)
} 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"))
}
}
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,
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
}
}
}
}