diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 16b163d..13a6e4e 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -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 { diff --git a/FuelBoard/FavouritesView.swift b/FuelBoard/FavouritesView.swift index 1baf7fe..6921f04 100644 --- a/FuelBoard/FavouritesView.swift +++ b/FuelBoard/FavouritesView.swift @@ -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 { 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() + } + } } } } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 0db81fb..c62ae14 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -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) diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 6df13d7..673aa7b 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -208,16 +208,17 @@ struct FuelPriceTimelineProvider [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 {