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:
@@ -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()
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -217,18 +217,29 @@ final class FuelTypeLabelTests: XCTestCase {
|
||||
|
||||
final class FavouriteRefreshTests: XCTestCase {
|
||||
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 updated = FuelStore.refreshedFavourites([fav], from: [fresh])
|
||||
XCTAssertEqual(updated.count, 1)
|
||||
XCTAssertEqual(updated[0].name, "Fresh Station")
|
||||
XCTAssertEqual(updated[0].prices[.e10], 132.9)
|
||||
XCTAssertEqual(updated[0].station.name, "Fresh Station")
|
||||
XCTAssertEqual(updated[0].station.prices[.e10], 132.9)
|
||||
XCTAssertEqual(updated[0].fuel, .e10, "fuel scoping survives refresh")
|
||||
}
|
||||
|
||||
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: [])
|
||||
XCTAssertEqual(updated[0].name, "Cached", "unmatched favourite keeps its snapshot")
|
||||
XCTAssertEqual(updated[0].prices[.e10], 140.0)
|
||||
XCTAssertEqual(updated[0].station.name, "Cached", "unmatched favourite keeps its snapshot")
|
||||
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
@@ -17,6 +17,16 @@ struct Coordinate: Equatable, Codable {
|
||||
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 {
|
||||
case cheapest
|
||||
case closest
|
||||
@@ -234,7 +244,7 @@ struct FuelStore {
|
||||
static let sortModeKey = "fuelboard.sortMode" // SortMode raw value
|
||||
static let stationLimitKey = "fuelboard.stationLimitMiles" // Int miles (5/10/15)
|
||||
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 alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
||||
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
||||
@@ -348,24 +358,27 @@ struct FuelStore {
|
||||
|
||||
// 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),
|
||||
let favs = try? JSONDecoder().decode([FuelStation].self, from: data) {
|
||||
return favs.map { fav in
|
||||
var f = fav
|
||||
f.name = fav.name.sanitizedStationTitle
|
||||
return f
|
||||
let entries = try? JSONDecoder().decode([FavouriteEntry].self, from: data) {
|
||||
return entries.map { entry in
|
||||
var e = entry
|
||||
e.station.name = entry.station.name.sanitizedStationTitle
|
||||
return e
|
||||
}
|
||||
}
|
||||
if let defaults = UserDefaults(suiteName: appGroupSuite),
|
||||
let data = defaults.data(forKey: favouritesKey),
|
||||
let favs = try? JSONDecoder().decode([FuelStation].self, from: data) {
|
||||
return favs
|
||||
let entries = try? JSONDecoder().decode([FavouriteEntry].self, from: data) {
|
||||
return entries
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func saveFavourites(_ favourites: [FuelStation]) {
|
||||
static func saveFavourites(_ favourites: [FavouriteEntry]) {
|
||||
if let data = try? JSONEncoder().encode(favourites) {
|
||||
UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: favouritesKey)
|
||||
writeKeychain(data: data, service: favouritesKey)
|
||||
@@ -374,15 +387,13 @@ struct FuelStore {
|
||||
|
||||
/// Returns favourites with fresh prices applied from the given station list
|
||||
/// (favourites keep their cached snapshot when not in the current results).
|
||||
static func refreshedFavourites(_ favourites: [FuelStation], from stations: [FuelStation]) -> [FuelStation] {
|
||||
var updated = favourites
|
||||
for (i, fav) in favourites.enumerated() {
|
||||
if let fresh = stations.first(where: { $0.id == fav.id }) {
|
||||
updated[i] = fresh
|
||||
/// The fuel scoping is preserved per entry.
|
||||
static func refreshedFavourites(_ favourites: [FavouriteEntry], from stations: [FuelStation]) -> [FavouriteEntry] {
|
||||
favourites.map { entry in
|
||||
guard let fresh = stations.first(where: { $0.id == entry.station.id }) else { return entry }
|
||||
return FavouriteEntry(station: fresh, fuel: entry.fuel)
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
// MARK: Alerts
|
||||
|
||||
|
||||
Reference in New Issue
Block a user