Favourites are fuel-scoped: starring pins the station for one fuel only

Fixing the bug where favouriting an Unleaded entry also created a Diesel
favourite. Favourites are now [FavouriteEntry] = (station, fuel) pairs:
- Starring a row pins it only for the fuel being viewed
- Favourites tabs show only fuels that actually have favourites
- Geofence monitor gives priority slots only to favourites matching the
  monitored fuel
- Tests updated + new fuel-scoping test (31/31 passing)
This commit is contained in:
FuelBoard Contributor
2026-08-12 08:31:08 +01:00
parent 0dbf88ee7d
commit 35e0adaf0b
6 changed files with 88 additions and 54 deletions
+10 -8
View File
@@ -10,7 +10,7 @@ struct ContentView: View {
@State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var favourites: [FuelStation] = FuelStore.loadFavourites()
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var location: Coordinate? = {
@@ -95,11 +95,13 @@ struct ContentView: View {
}
}
/// Station IDs favourited for the CURRENTLY selected fuel the star on a
/// Stations-tab row reflects the fuel being viewed (fuel-scoped favourites).
private var favouriteIDs: Set<String> {
Set(favourites.map(\.id))
Set(favourites.filter { $0.fuel == selectedFuel }.map(\.station.id))
}
private var refreshedFavourites: [FuelStation] {
private var refreshedFavourites: [FavouriteEntry] {
FuelStore.refreshedFavourites(favourites, from: stations)
}
@@ -127,7 +129,6 @@ struct ContentView: View {
selectedFuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
favouriteIDs: favouriteIDs,
onToggleFavourite: toggleFavourite
)
.tabItem { Label("Favourites", systemImage: "star.fill") }
@@ -222,11 +223,12 @@ struct ContentView: View {
}
}
private func toggleFavourite(_ station: FuelStation) {
if favouriteIDs.contains(station.id) {
favourites.removeAll { $0.id == station.id }
private func toggleFavourite(_ station: FuelStation, fuel: FuelType) {
let key = FavouriteEntry(station: station, fuel: fuel)
if favourites.contains(where: { $0.id == key.id }) {
favourites.removeAll { $0.id == key.id }
} else {
favourites.append(station)
favourites.append(key)
}
FuelStore.saveFavourites(favourites)
WidgetCenter.shared.reloadAllTimelines()
+19 -15
View File
@@ -3,20 +3,21 @@ import SwiftUI
/// Favourites tab starred stations, split by fuel type. Each fuel type
/// with at least one favourite appears as its own tab; that tab's favourites
/// are ranked cheapest-first for that fuel, with the cheapest called out.
/// 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: [FuelStation]
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 favouriteIDs: Set<String>
var onToggleFavourite: (FuelStation) -> Void = { _ in }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Fuel types that currently have at least one favourite selling them
/// these are the only tabs shown.
/// 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.prices[fuel] != nil }
favourites.contains { $0.fuel == fuel }
}
}
@@ -28,9 +29,9 @@ struct FavouritesView: View {
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
}
/// Favourites that sell the active fuel, cheapest first (distance tiebreak).
/// Stations favourited for the active fuel, cheapest first (distance tiebreak).
private var ranked: [FuelStation] {
let available = favourites.filter { $0.prices[activeFuel] != nil }
let available = favourites.filter { $0.fuel == activeFuel }.map(\.station)
return available.sorted { lhs, rhs in
let lPrice = lhs.prices[activeFuel]!
let rPrice = rhs.prices[activeFuel]!
@@ -41,20 +42,23 @@ struct FavouritesView: View {
}
}
/// 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))
}
private var cheapest: FuelStation? { ranked.first }
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
init(favourites: [FuelStation],
init(favourites: [FavouriteEntry],
selectedFuel: FuelType,
location: Coordinate?,
distanceUnit: DistanceUnit,
favouriteIDs: Set<String>,
onToggleFavourite: @escaping (FuelStation) -> Void = { _ in }) {
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }) {
self.favourites = favourites
self.selectedFuel = selectedFuel
self.location = location
self.distanceUnit = distanceUnit
self.favouriteIDs = favouriteIDs
self.onToggleFavourite = onToggleFavourite
_fuel = State(initialValue: selectedFuel)
}
@@ -70,7 +74,7 @@ struct FavouritesView: View {
.foregroundStyle(.secondary)
Text("No favourites yet")
.font(.headline)
Text("Tap the star on any station in the Stations tab to pin it here, ranked by which is cheapest.")
Text("Tap the star on any station in the Stations tab to pin it here, ranked by which is cheapest for that fuel.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -108,8 +112,8 @@ struct FavouritesView: View {
distanceUnit: distanceUnit,
baselinePrice: cheapestPrice,
isTopResult: index == 0,
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station) }
isFavourite: activeFuelFavouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
)
}
}
+12 -6
View File
@@ -31,15 +31,20 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
fuel = FuelStore.loadSelectedFuel()
radiusKM = FuelStore.loadAlertsRadius()
stations = FuelStore.loadStations()
favourites = FuelStore.loadFavourites()
favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
}
/// Re-registers geofences. Call whenever stations/favourites/settings change.
func update(stations: [FuelStation], favourites: [FuelStation], fuel: FuelType, radiusKM: Double) {
/// Favourites are fuel-scoped entries; only entries for the monitored fuel
/// get priority slots (a Diesel favourite must not grab a slot while
/// monitoring Unleaded).
func update(stations: [FuelStation], favourites: [FavouriteEntry], fuel: FuelType, radiusKM: Double) {
// Fall back to the shared cache when called before the first fetch
// completes (launch, background region-event wake).
self.stations = stations.isEmpty ? FuelStore.loadStations() : stations
self.favourites = favourites.isEmpty ? FuelStore.loadFavourites() : favourites
self.favourites = favourites.isEmpty
? FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
: favourites.filter { $0.fuel == fuel }.map(\.station)
self.fuel = fuel
self.radiusKM = radiusKM
@@ -51,8 +56,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
guard enabled else { return }
// Favourites first (guaranteed slots), then closest stations, max 18.
var candidates: [FuelStation] = favourites
let favIDs = Set(favourites.map(\.id))
// `self.favourites` is already filtered to the monitored fuel.
var candidates: [FuelStation] = self.favourites
let favIDs = Set(self.favourites.map(\.id))
let location = FuelStore.loadLocation()
let others = stations
.filter { !favIDs.contains($0.id) }
@@ -88,7 +94,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
} else {
requestPermissions()
// Re-register geofences from restored cache immediately.
update(stations: stations, favourites: favourites, fuel: fuel, radiusKM: radiusKM)
update(stations: stations, favourites: FuelStore.loadFavourites(), fuel: fuel, radiusKM: radiusKM)
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ struct StationsView: View {
let topStationID: String?
let location: Coordinate?
let favouriteIDs: Set<String>
var onToggleFavourite: (FuelStation) -> Void = { _ in }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onRefresh: () async -> Void = {}
/// Pagination: one page = 10 rows, reset whenever the underlying list
@@ -107,7 +107,7 @@ struct StationsView: View {
baselinePrice: baselinePrice,
isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station) }
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
)
}