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,
|
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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user