Favourites: drag-and-drop reorder per fuel; stored order drives single-widget favourite
This commit is contained in:
@@ -159,7 +159,8 @@ struct ContentView: View {
|
||||
selectedFuel: selectedFuel,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit,
|
||||
onToggleFavourite: toggleFavourite
|
||||
onToggleFavourite: toggleFavourite,
|
||||
onReorder: reorderFavourites
|
||||
)
|
||||
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
||||
|
||||
@@ -395,6 +396,17 @@ struct ContentView: View {
|
||||
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
|
||||
/// `force` is true (pull-to-refresh is the manual override).
|
||||
private func refresh(force: Bool = false) async {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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.
|
||||
/// 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.
|
||||
@@ -12,6 +14,8 @@ struct FavouritesView: View {
|
||||
let location: Coordinate?
|
||||
let distanceUnit: DistanceUnit
|
||||
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
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Stations favourited for the active fuel, cheapest first (distance tiebreak).
|
||||
private var ranked: [FuelStation] {
|
||||
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]!
|
||||
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)
|
||||
/// 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))
|
||||
}
|
||||
|
||||
private var cheapest: FuelStation? { ranked.first }
|
||||
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
|
||||
|
||||
init(favourites: [FavouriteEntry],
|
||||
selectedFuel: FuelType,
|
||||
location: Coordinate?,
|
||||
distanceUnit: DistanceUnit,
|
||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }) {
|
||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }) {
|
||||
self.favourites = favourites
|
||||
self.selectedFuel = selectedFuel
|
||||
self.location = location
|
||||
self.distanceUnit = distanceUnit
|
||||
self.onToggleFavourite = onToggleFavourite
|
||||
self.onReorder = onReorder
|
||||
_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 {
|
||||
@@ -74,7 +88,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 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)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -90,8 +104,8 @@ struct FavouritesView: View {
|
||||
)
|
||||
}
|
||||
|
||||
Section("\(activeFuel.displayName) favourites — cheapest first") {
|
||||
ForEach(Array(ranked.enumerated()), id: \.element.id) { index, station in
|
||||
Section {
|
||||
ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
|
||||
StationRow(
|
||||
station: station,
|
||||
fuel: activeFuel,
|
||||
@@ -103,6 +117,11 @@ struct FavouritesView: View {
|
||||
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 {
|
||||
@@ -115,6 +134,11 @@ struct FavouritesView: View {
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
func testAlertsFuelRoundTrips() {
|
||||
FuelStore.saveAlertsFuel(.diesel)
|
||||
|
||||
@@ -208,16 +208,17 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
let favourites = FuelStore.loadFavourites()
|
||||
.filter { $0.station.prices[$0.fuel] != nil }
|
||||
|
||||
// Pinned stations for THIS fuel, cheapest-first. Not
|
||||
// radius-bound — a favourite in Edinburgh shows on a widget in
|
||||
// London. Prices come from the keychain snapshot (the app
|
||||
// refreshes favourite prices into keychain after every fetch), or
|
||||
// fresher from a focused relay fetch when a favourite happens to
|
||||
// be in range. The small face renders the first of this list —
|
||||
// the cheapest favourite of the chosen fuel. (The old per-widget
|
||||
// pinned-favourite picker was removed: its AppEntity params made
|
||||
// fresh-widget default-config resolution fail at the system level
|
||||
// — the merged widget uses the minimal fuel/sort/distance intent.)
|
||||
// Pinned stations for THIS fuel, in the USER'S manual order (the
|
||||
// Favourites tab drag-to-reorder). Not radius-bound — a favourite
|
||||
// in Edinburgh shows on a widget in London. Prices come from the
|
||||
// keychain snapshot (the app refreshes favourite prices into
|
||||
// keychain after every fetch), or fresher from a focused relay
|
||||
// fetch when a favourite happens to be in range. The small face
|
||||
// renders the FIRST of this list — the user's top favourite for
|
||||
// the chosen fuel. (The old per-widget pinned-favourite picker
|
||||
// was removed: its AppEntity params made fresh-widget
|
||||
// default-config resolution fail at the system level — the merged
|
||||
// widget uses the minimal fuel/sort/distance intent.)
|
||||
let fuelFavourites = favourites
|
||||
.filter { $0.fuel == fuel && $0.station.prices[fuel] != nil }
|
||||
.map(\.station)
|
||||
@@ -227,16 +228,10 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
||||
refreshed = fuelFavourites.map { freshByID[$0.id] ?? $0 }
|
||||
}
|
||||
ordered = refreshed.sorted { lhs, rhs in
|
||||
let lPrice = lhs.prices[fuel]!
|
||||
let rPrice = rhs.prices[fuel]!
|
||||
if lPrice != rPrice { return lPrice < rPrice }
|
||||
if let location {
|
||||
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
||||
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Stored order IS the widget order — the user's manual ranking,
|
||||
// NOT a price sort (cheapest-first would override the top
|
||||
// favourite that the Favourites tab sets for single widgets).
|
||||
ordered = refreshed
|
||||
} else {
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
|
||||
static func loadAlertsEnabled() -> Bool {
|
||||
|
||||
Reference in New Issue
Block a user