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 sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit() @State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() @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 alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var location: Coordinate? = { @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> { 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) FuelStore.refreshedFavourites(favourites, from: stations)
} }
@@ -127,7 +129,6 @@ struct ContentView: View {
selectedFuel: selectedFuel, selectedFuel: selectedFuel,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
favouriteIDs: favouriteIDs,
onToggleFavourite: toggleFavourite onToggleFavourite: toggleFavourite
) )
.tabItem { Label("Favourites", systemImage: "star.fill") } .tabItem { Label("Favourites", systemImage: "star.fill") }
@@ -222,11 +223,12 @@ struct ContentView: View {
} }
} }
private func toggleFavourite(_ station: FuelStation) { private func toggleFavourite(_ station: FuelStation, fuel: FuelType) {
if favouriteIDs.contains(station.id) { let key = FavouriteEntry(station: station, fuel: fuel)
favourites.removeAll { $0.id == station.id } if favourites.contains(where: { $0.id == key.id }) {
favourites.removeAll { $0.id == key.id }
} else { } else {
favourites.append(station) favourites.append(key)
} }
FuelStore.saveFavourites(favourites) FuelStore.saveFavourites(favourites)
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
+19 -15
View File
@@ -3,20 +3,21 @@ import SwiftUI
/// Favourites tab starred stations, split by fuel type. Each fuel type /// 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 /// 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. /// 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 { struct FavouritesView: View {
let favourites: [FuelStation] let favourites: [FavouriteEntry]
/// The app's currently selected fuel used only as the initial tab. /// The app's currently selected fuel used only as the initial tab.
let selectedFuel: FuelType let selectedFuel: FuelType
let location: Coordinate? let location: Coordinate?
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
let favouriteIDs: Set<String> var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onToggleFavourite: (FuelStation) -> Void = { _ in }
/// Fuel types that currently have at least one favourite selling them /// Fuel types that currently have at least one favourite these are the
/// these are the only tabs shown. /// only tabs shown (a fuel with no favourites gets no tab).
private var availableFuels: [FuelType] { private var availableFuels: [FuelType] {
FuelType.allCases.filter { fuel in 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) 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] { 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 return available.sorted { lhs, rhs in
let lPrice = lhs.prices[activeFuel]! let lPrice = lhs.prices[activeFuel]!
let rPrice = rhs.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 cheapest: FuelStation? { ranked.first }
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] } private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
init(favourites: [FuelStation], init(favourites: [FavouriteEntry],
selectedFuel: FuelType, selectedFuel: FuelType,
location: Coordinate?, location: Coordinate?,
distanceUnit: DistanceUnit, distanceUnit: DistanceUnit,
favouriteIDs: Set<String>, onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }) {
onToggleFavourite: @escaping (FuelStation) -> Void = { _ in }) {
self.favourites = favourites self.favourites = favourites
self.selectedFuel = selectedFuel self.selectedFuel = selectedFuel
self.location = location self.location = location
self.distanceUnit = distanceUnit self.distanceUnit = distanceUnit
self.favouriteIDs = favouriteIDs
self.onToggleFavourite = onToggleFavourite self.onToggleFavourite = onToggleFavourite
_fuel = State(initialValue: selectedFuel) _fuel = State(initialValue: selectedFuel)
} }
@@ -70,7 +74,7 @@ struct FavouritesView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("No favourites yet") Text("No favourites yet")
.font(.headline) .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) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -108,8 +112,8 @@ struct FavouritesView: View {
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
baselinePrice: cheapestPrice, baselinePrice: cheapestPrice,
isTopResult: index == 0, isTopResult: index == 0,
isFavourite: favouriteIDs.contains(station.id), isFavourite: activeFuelFavouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station) } onToggleFavourite: { onToggleFavourite(station, activeFuel) }
) )
} }
} }
+12 -6
View File
@@ -31,15 +31,20 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
fuel = FuelStore.loadSelectedFuel() fuel = FuelStore.loadSelectedFuel()
radiusKM = FuelStore.loadAlertsRadius() radiusKM = FuelStore.loadAlertsRadius()
stations = FuelStore.loadStations() stations = FuelStore.loadStations()
favourites = FuelStore.loadFavourites() favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
} }
/// Re-registers geofences. Call whenever stations/favourites/settings change. /// 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 // Fall back to the shared cache when called before the first fetch
// completes (launch, background region-event wake). // completes (launch, background region-event wake).
self.stations = stations.isEmpty ? FuelStore.loadStations() : stations 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.fuel = fuel
self.radiusKM = radiusKM self.radiusKM = radiusKM
@@ -51,8 +56,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
guard enabled else { return } guard enabled else { return }
// Favourites first (guaranteed slots), then closest stations, max 18. // Favourites first (guaranteed slots), then closest stations, max 18.
var candidates: [FuelStation] = favourites // `self.favourites` is already filtered to the monitored fuel.
let favIDs = Set(favourites.map(\.id)) var candidates: [FuelStation] = self.favourites
let favIDs = Set(self.favourites.map(\.id))
let location = FuelStore.loadLocation() let location = FuelStore.loadLocation()
let others = stations let others = stations
.filter { !favIDs.contains($0.id) } .filter { !favIDs.contains($0.id) }
@@ -88,7 +94,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
} else { } else {
requestPermissions() requestPermissions()
// Re-register geofences from restored cache immediately. // 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 topStationID: String?
let location: Coordinate? let location: Coordinate?
let favouriteIDs: Set<String> let favouriteIDs: Set<String>
var onToggleFavourite: (FuelStation) -> Void = { _ in } var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onRefresh: () async -> Void = {} var onRefresh: () async -> Void = {}
/// Pagination: one page = 10 rows, reset whenever the underlying list /// Pagination: one page = 10 rows, reset whenever the underlying list
@@ -107,7 +107,7 @@ struct StationsView: View {
baselinePrice: baselinePrice, baselinePrice: baselinePrice,
isTopResult: station.id == topStationID, isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id), isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station) } onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
) )
} }
@@ -217,18 +217,29 @@ final class FuelTypeLabelTests: XCTestCase {
final class FavouriteRefreshTests: XCTestCase { final class FavouriteRefreshTests: XCTestCase {
func testRefreshedFavouritesApplyFreshPrices() { func testRefreshedFavouritesApplyFreshPrices() {
let fav = FuelStation(id: "s1", name: "OLD NAME", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil) let fav = FavouriteEntry(station: FuelStation(id: "s1", name: "OLD NAME", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil), fuel: .e10)
let fresh = FuelStation(id: "s1", name: "Fresh Station", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 132.9], priceUpdated: nil) let fresh = FuelStation(id: "s1", name: "Fresh Station", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 132.9], priceUpdated: nil)
let updated = FuelStore.refreshedFavourites([fav], from: [fresh]) let updated = FuelStore.refreshedFavourites([fav], from: [fresh])
XCTAssertEqual(updated.count, 1) XCTAssertEqual(updated.count, 1)
XCTAssertEqual(updated[0].name, "Fresh Station") XCTAssertEqual(updated[0].station.name, "Fresh Station")
XCTAssertEqual(updated[0].prices[.e10], 132.9) XCTAssertEqual(updated[0].station.prices[.e10], 132.9)
XCTAssertEqual(updated[0].fuel, .e10, "fuel scoping survives refresh")
} }
func testRefreshedFavouritesKeepUnmatchedSnapshot() { func testRefreshedFavouritesKeepUnmatchedSnapshot() {
let fav = FuelStation(id: "s1", name: "Cached", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil) let fav = FavouriteEntry(station: FuelStation(id: "s1", name: "Cached", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil), fuel: .diesel)
let updated = FuelStore.refreshedFavourites([fav], from: []) let updated = FuelStore.refreshedFavourites([fav], from: [])
XCTAssertEqual(updated[0].name, "Cached", "unmatched favourite keeps its snapshot") XCTAssertEqual(updated[0].station.name, "Cached", "unmatched favourite keeps its snapshot")
XCTAssertEqual(updated[0].prices[.e10], 140.0) XCTAssertEqual(updated[0].station.prices[.e10], 140.0)
XCTAssertEqual(updated[0].fuel, .diesel, "fuel scoping survives unmatched refresh")
}
func testFavouriteEntryIDIsFuelScoped() {
let station = FuelStation(id: "s1", name: "X", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0, .diesel: 150.0], priceUpdated: nil)
let unleaded = FavouriteEntry(station: station, fuel: .e10)
let diesel = FavouriteEntry(station: station, fuel: .diesel)
XCTAssertNotEqual(unleaded.id, diesel.id, "same station favourited for two fuels is two distinct favourites")
XCTAssertTrue(unleaded.id.hasSuffix("|s1"))
XCTAssertTrue(diesel.id.hasPrefix("diesel|"))
} }
} }
+28 -17
View File
@@ -17,6 +17,16 @@ struct Coordinate: Equatable, Codable {
let lng: Double let lng: Double
} }
/// A favourite = a station pinned for ONE fuel type. Starring a row while
/// viewing Unleaded only creates an Unleaded favourite, so the same station
/// can be favourite for Diesel independently (or not at all).
struct FavouriteEntry: Identifiable, Codable, Equatable {
var station: FuelStation
let fuel: FuelType
var id: String { "\(fuel.rawValue)|\(station.id)" }
}
enum SortMode: String, Codable, CaseIterable, Identifiable { enum SortMode: String, Codable, CaseIterable, Identifiable {
case cheapest case cheapest
case closest case closest
@@ -234,7 +244,7 @@ struct FuelStore {
static let sortModeKey = "fuelboard.sortMode" // SortMode raw value static let sortModeKey = "fuelboard.sortMode" // SortMode raw value
static let stationLimitKey = "fuelboard.stationLimitMiles" // Int miles (5/10/15) static let stationLimitKey = "fuelboard.stationLimitMiles" // Int miles (5/10/15)
static let distanceUnitKey = "fuelboard.distanceUnit" // DistanceUnit raw value static let distanceUnitKey = "fuelboard.distanceUnit" // DistanceUnit raw value
static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON static let favouritesKey = "fuelboard.favourites" // [FavouriteEntry] JSON
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
@@ -348,24 +358,27 @@ struct FuelStore {
// MARK: Favourites // MARK: Favourites
static func loadFavourites() -> [FuelStation] { /// A favourite pins a station FOR ONE fuel type. Starring a row while
/// viewing Unleaded only creates an Unleaded favourite the station is
/// not automatically favourited for Premium or Diesel.
static func loadFavourites() -> [FavouriteEntry] {
if let data = keychainData(service: favouritesKey), if let data = keychainData(service: favouritesKey),
let favs = try? JSONDecoder().decode([FuelStation].self, from: data) { let entries = try? JSONDecoder().decode([FavouriteEntry].self, from: data) {
return favs.map { fav in return entries.map { entry in
var f = fav var e = entry
f.name = fav.name.sanitizedStationTitle e.station.name = entry.station.name.sanitizedStationTitle
return f return e
} }
} }
if let defaults = UserDefaults(suiteName: appGroupSuite), if let defaults = UserDefaults(suiteName: appGroupSuite),
let data = defaults.data(forKey: favouritesKey), let data = defaults.data(forKey: favouritesKey),
let favs = try? JSONDecoder().decode([FuelStation].self, from: data) { let entries = try? JSONDecoder().decode([FavouriteEntry].self, from: data) {
return favs return entries
} }
return [] return []
} }
static func saveFavourites(_ favourites: [FuelStation]) { static func saveFavourites(_ favourites: [FavouriteEntry]) {
if let data = try? JSONEncoder().encode(favourites) { if let data = try? JSONEncoder().encode(favourites) {
UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: favouritesKey) UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: favouritesKey)
writeKeychain(data: data, service: favouritesKey) writeKeychain(data: data, service: favouritesKey)
@@ -374,14 +387,12 @@ struct FuelStore {
/// Returns favourites with fresh prices applied from the given station list /// Returns favourites with fresh prices applied from the given station list
/// (favourites keep their cached snapshot when not in the current results). /// (favourites keep their cached snapshot when not in the current results).
static func refreshedFavourites(_ favourites: [FuelStation], from stations: [FuelStation]) -> [FuelStation] { /// The fuel scoping is preserved per entry.
var updated = favourites static func refreshedFavourites(_ favourites: [FavouriteEntry], from stations: [FuelStation]) -> [FavouriteEntry] {
for (i, fav) in favourites.enumerated() { favourites.map { entry in
if let fresh = stations.first(where: { $0.id == fav.id }) { guard let fresh = stations.first(where: { $0.id == entry.station.id }) else { return entry }
updated[i] = fresh return FavouriteEntry(station: fresh, fuel: entry.fuel)
}
} }
return updated
} }
// MARK: Alerts // MARK: Alerts