Favourites: drag-and-drop reorder per fuel; stored order drives single-widget favourite

This commit is contained in:
FuelBoard Contributor
2026-08-14 10:48:53 +01:00
parent aa95d214e8
commit fbf8c79feb
5 changed files with 139 additions and 43 deletions
+13 -1
View File
@@ -159,7 +159,8 @@ struct ContentView: View {
selectedFuel: selectedFuel, selectedFuel: selectedFuel,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
onToggleFavourite: toggleFavourite onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites
) )
.tabItem { Label("Favourites", systemImage: "star.fill") } .tabItem { Label("Favourites", systemImage: "star.fill") }
@@ -395,6 +396,17 @@ struct ContentView: View {
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
} }
/// Drag-and-drop reorder from the Favourites tab persists the new order
/// and refreshes every surface that consumes it (widgets + geofence
/// priority, which follows the stored order).
private func reorderFavourites(_ newOrder: [FavouriteEntry]) {
favourites = newOrder
FuelStore.saveFavourites(favourites)
WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
}
/// Fetches fresh prices, but only when the cache is stale unless /// Fetches fresh prices, but only when the cache is stale unless
/// `force` is true (pull-to-refresh is the manual override). /// `force` is true (pull-to-refresh is the manual override).
private func refresh(force: Bool = false) async { private func refresh(force: Bool = false) async {
+46 -22
View File
@@ -1,10 +1,12 @@
import SwiftUI 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. The list shows the
/// are ranked cheapest-first for that fuel, with the cheapest called out. /// USER'S manual order (drag to reorder Edit button in the toolbar): the
/// Favourites are fuel-scoped entries starring a station only pins it for /// FIRST station in each fuel's list is the one used as "the favourite" in
/// the fuel you were viewing, so an Unleaded favourite never shows as Diesel. /// 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 { struct FavouritesView: View {
let favourites: [FavouriteEntry] 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.
@@ -12,6 +14,8 @@ struct FavouritesView: View {
let location: Coordinate? let location: Coordinate?
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
/// Fuel types that currently have at least one favourite these are the /// Fuel types that currently have at least one favourite these are the
/// only tabs shown (a fuel with no favourites gets no tab). /// only tabs shown (a fuel with no favourites gets no tab).
@@ -29,40 +33,50 @@ struct FavouritesView: View {
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10) availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
} }
/// Stations favourited for the active fuel, cheapest first (distance tiebreak). /// Stations favourited for the active fuel, in the USER'S stored order
private var ranked: [FuelStation] { /// NOT price-sorted. The first row is the favourite used by single
let available = favourites.filter { $0.fuel == activeFuel }.map(\.station) /// widgets (see footer note).
return available.sorted { lhs, rhs in private var ordered: [FuelStation] {
let lPrice = lhs.prices[activeFuel]! favourites.filter { $0.fuel == activeFuel }.map(\.station)
let rPrice = rhs.prices[activeFuel]!
if lPrice != rPrice { return lPrice < rPrice }
guard let location else { return false }
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
rhs.distanceKM(to: location.lat, lng2: location.lng)
}
} }
/// 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. /// IDs favourited for the active fuel the star state on each row.
private var activeFuelFavouriteIDs: Set<String> { private var activeFuelFavouriteIDs: Set<String> {
Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id)) Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id))
} }
private var cheapest: FuelStation? { ranked.first }
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
init(favourites: [FavouriteEntry], init(favourites: [FavouriteEntry],
selectedFuel: FuelType, selectedFuel: FuelType,
location: Coordinate?, location: Coordinate?,
distanceUnit: DistanceUnit, distanceUnit: DistanceUnit,
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }) { onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> 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.onToggleFavourite = onToggleFavourite self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder
_fuel = State(initialValue: selectedFuel) _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 { var body: some View {
NavigationStack { NavigationStack {
List { List {
@@ -74,7 +88,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 for that fuel.") Text("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.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -90,8 +104,8 @@ struct FavouritesView: View {
) )
} }
Section("\(activeFuel.displayName) favourites — cheapest first") { Section {
ForEach(Array(ranked.enumerated()), id: \.element.id) { index, station in ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
StationRow( StationRow(
station: station, station: station,
fuel: activeFuel, fuel: activeFuel,
@@ -103,6 +117,11 @@ struct FavouritesView: View {
onToggleFavourite: { onToggleFavourite(station, activeFuel) } onToggleFavourite: { onToggleFavourite(station, activeFuel) }
) )
} }
.onMove(perform: moveFavourite)
} header: {
Text("\(activeFuel.displayName) favourites")
} footer: {
Text("The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.")
} }
if let cheapest = cheapest { if let cheapest = cheapest {
@@ -115,6 +134,11 @@ struct FavouritesView: View {
} }
} }
.navigationTitle("Favourites") .navigationTitle("Favourites")
.toolbar {
if !favourites.isEmpty {
EditButton()
}
}
} }
} }
} }
@@ -292,6 +292,40 @@ final class FavouriteRefreshTests: XCTestCase {
} }
} }
final class FavouriteReorderTests: XCTestCase {
private func entry(_ id: String, _ fuel: FuelType) -> FavouriteEntry {
FavouriteEntry(station: FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [fuel: 140.0], priceUpdated: nil), fuel: fuel)
}
func testReorderMovesWithinFuelAndKeepsOthersRelativeOrder() {
// e10: a, b, c | diesel: d
let list = [entry("a", .e10), entry("b", .e10), entry("c", .e10), entry("d", .diesel)]
// Move c (index 2) to the front (offset 0) of the e10 list.
let moved = FuelStore.reorderedFavourites(list, fuel: .e10, fromOffsets: [2], toOffset: 0)
XCTAssertEqual(moved.map(\.station.id), ["c", "a", "b", "d"], "c moves to front; diesel stays last")
}
func testReorderPreservesFuelBlockPosition() {
// diesel first block, then e10 block: d, e | a, b, c
let list = [entry("d", .diesel), entry("e", .diesel), entry("a", .e10), entry("b", .e10), entry("c", .e10)]
// Move a (e10 index 0) to the e10 block end (offset 3 within e10).
let moved = FuelStore.reorderedFavourites(list, fuel: .e10, fromOffsets: [0], toOffset: 3)
XCTAssertEqual(moved.map(\.station.id), ["d", "e", "b", "c", "a"], "e10 block stays in place after diesel block")
}
func testReorderFirstBecomesSingleWidgetFavourite() {
let list = [entry("x", .e10), entry("y", .e10), entry("z", .e10)]
let moved = FuelStore.reorderedFavourites(list, fuel: .e10, fromOffsets: [2], toOffset: 0)
XCTAssertEqual(moved.first?.station.id, "z", "first stored favourite = single-widget favourite")
}
func testReorderMultipleFuelsUntouched() {
let list = [entry("a", .e10), entry("b", .e10), entry("d", .diesel), entry("p", .e5)]
let moved = FuelStore.reorderedFavourites(list, fuel: .e10, fromOffsets: [0], toOffset: 2)
XCTAssertEqual(moved.map(\.station.id), ["b", "a", "d", "p"], "e10 reorder leaves other fuels' relative order intact")
}
}
final class AlertsFuelTests: XCTestCase { final class AlertsFuelTests: XCTestCase {
func testAlertsFuelRoundTrips() { func testAlertsFuelRoundTrips() {
FuelStore.saveAlertsFuel(.diesel) FuelStore.saveAlertsFuel(.diesel)
+15 -20
View File
@@ -208,16 +208,17 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
let favourites = FuelStore.loadFavourites() let favourites = FuelStore.loadFavourites()
.filter { $0.station.prices[$0.fuel] != nil } .filter { $0.station.prices[$0.fuel] != nil }
// Pinned stations for THIS fuel, cheapest-first. Not // Pinned stations for THIS fuel, in the USER'S manual order (the
// radius-bound a favourite in Edinburgh shows on a widget in // Favourites tab drag-to-reorder). Not radius-bound a favourite
// London. Prices come from the keychain snapshot (the app // in Edinburgh shows on a widget in London. Prices come from the
// refreshes favourite prices into keychain after every fetch), or // keychain snapshot (the app refreshes favourite prices into
// fresher from a focused relay fetch when a favourite happens to // keychain after every fetch), or fresher from a focused relay
// be in range. The small face renders the first of this list // fetch when a favourite happens to be in range. The small face
// the cheapest favourite of the chosen fuel. (The old per-widget // renders the FIRST of this list the user's top favourite for
// pinned-favourite picker was removed: its AppEntity params made // the chosen fuel. (The old per-widget pinned-favourite picker
// fresh-widget default-config resolution fail at the system level // was removed: its AppEntity params made fresh-widget
// the merged widget uses the minimal fuel/sort/distance intent.) // default-config resolution fail at the system level the merged
// widget uses the minimal fuel/sort/distance intent.)
let fuelFavourites = favourites let fuelFavourites = favourites
.filter { $0.fuel == fuel && $0.station.prices[fuel] != nil } .filter { $0.fuel == fuel && $0.station.prices[fuel] != nil }
.map(\.station) .map(\.station)
@@ -227,16 +228,10 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) }) let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
refreshed = fuelFavourites.map { freshByID[$0.id] ?? $0 } refreshed = fuelFavourites.map { freshByID[$0.id] ?? $0 }
} }
ordered = refreshed.sorted { lhs, rhs in // Stored order IS the widget order the user's manual ranking,
let lPrice = lhs.prices[fuel]! // NOT a price sort (cheapest-first would override the top
let rPrice = rhs.prices[fuel]! // favourite that the Favourites tab sets for single widgets).
if lPrice != rPrice { return lPrice < rPrice } ordered = refreshed
if let location {
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
rhs.distanceKM(to: location.lat, lng2: location.lng)
}
return false
}
} else { } else {
// 3a) Load stations. The widget prefers its OWN focused fetch from the // 3a) Load stations. The widget prefers its OWN focused fetch from the
// relay so it shows real prices even when the app-group cache isn't // relay so it shows real prices even when the app-group cache isn't
+31
View File
@@ -490,6 +490,37 @@ struct FuelStore {
} }
} }
/// Reorders ONE fuel's favourites within the global array (drag-and-drop in
/// the Favourites tab). The moved fuel's block stays at its original
/// position in the array; other fuels keep their relative order. The array
/// order IS the widget order the first favourite of a fuel is the
/// "single widget" favourite.
static func reorderedFavourites(_ favourites: [FavouriteEntry],
fuel: FuelType,
fromOffsets source: IndexSet,
toOffset destination: Int) -> [FavouriteEntry] {
var fuelEntries = favourites.filter { $0.fuel == fuel }
// Manual reorder (Foundation-only file Array.move(fromOffsets:) is
// a SwiftUI helper). Reproduces the standard drag semantics: remove
// the source items, then insert at the destination, shifted by the
// number of removed items that were before it.
let moving = source.sorted()
let removed = moving.map { fuelEntries[$0] }
for index in moving.reversed() {
fuelEntries.remove(at: index)
}
var insertion = destination
for index in moving where index < destination {
insertion -= 1
}
fuelEntries.insert(contentsOf: removed, at: min(max(insertion, 0), fuelEntries.count))
let movedIDs = Set(fuelEntries.map(\.id))
var others = favourites.filter { !movedIDs.contains($0.id) }
let blockIndex = favourites.firstIndex { $0.fuel == fuel } ?? others.count
others.insert(contentsOf: fuelEntries, at: min(blockIndex, others.count))
return others
}
// MARK: Alerts // MARK: Alerts
static func loadAlertsEnabled() -> Bool { static func loadAlertsEnabled() -> Bool {