Widget Favourites filter: pinned stations cheapest-first, no radius
- New Sort by value 'Favourites' in Edit Widget (per-widget config). - ParameterSummary hides the Distance picker when Favourites is selected (favourites are not radius-bound). - Favourites mode reads keychain favourites (shared access group works on SideStore free even without App Groups), filters to the widget's fuel, refreshes prices from a focused relay fetch when in range, and ranks cheapest-first with distance tiebreak. Not radius-filtered. - App now re-saves refreshed favourites to keychain after every fetch so the widget shows current prices instead of star-time snapshots. - Empty state + headings adapt to favourites mode. 35 tests pass.
This commit is contained in:
@@ -15,8 +15,10 @@ import AppIntents
|
||||
//
|
||||
// Each widget instance is configured INDEPENDENTLY via its own App Intent
|
||||
// (long-press → Edit Widget): fuel type (Unleaded/Premium/Diesel) and sort
|
||||
// (Cheapest/Closest). Widgets no longer depend on the app's selected fuel —
|
||||
// you can place several widgets showing different fuels/orders side by side.
|
||||
// (Cheapest/Closest/Favourites). Favourites shows pinned stations for the
|
||||
// chosen fuel, cheapest-first, with no radius — the Distance picker is hidden
|
||||
// in that mode. Widgets no longer depend on the app's selected fuel — you can
|
||||
// place several widgets showing different fuels/orders side by side.
|
||||
//
|
||||
// Location flow: the provider requests location itself (async, timeout-bounded).
|
||||
// On success it caches the fix in the shared store for the app; on failure
|
||||
@@ -49,6 +51,7 @@ struct FuelPriceEntry: TimelineEntry {
|
||||
let stations: [FuelStation] // already filtered + sorted per config
|
||||
let fuel: FuelType
|
||||
let sort: SortMode // per-widget: cheapest | closest
|
||||
let isFavourites: Bool // favourites mode: pinned stations, cheapest-first
|
||||
let location: Coordinate?
|
||||
let locationSource: String // "live" | "cached" | "none"
|
||||
let unit: DistanceUnit // user's display unit for distances
|
||||
@@ -62,7 +65,7 @@ struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
|
||||
.sorted { $0.prices[.e10]! < $1.prices[.e10]! }
|
||||
return FuelPriceEntry(
|
||||
date: Date(), stations: Array(sample.prefix(4)),
|
||||
fuel: .e10, sort: .cheapest,
|
||||
fuel: .e10, sort: .cheapest, isFavourites: false,
|
||||
location: nil, locationSource: "none", unit: unit
|
||||
)
|
||||
}
|
||||
@@ -83,6 +86,7 @@ struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
|
||||
private func makeEntry(configuration: FuelBoardWidgetConfigurationIntent) async -> FuelPriceEntry {
|
||||
// Per-widget config: fuel + sort + distance come from THIS widget instance.
|
||||
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
|
||||
let isFavourites = configuration.sort == .favourites
|
||||
let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest
|
||||
let radiusKM = Double(configuration.distance.miles) * 1.60934
|
||||
|
||||
@@ -106,43 +110,27 @@ struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
|
||||
FuelStore.saveLocation(lat: location.lat, lng: location.lng)
|
||||
}
|
||||
|
||||
// 3) 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
|
||||
// shared (SideStore free accounts don't provision the group, which
|
||||
// previously left the widget stuck on sample data). Falls back to the
|
||||
// shared cache, then to samples.
|
||||
var stations: [FuelStation] = []
|
||||
if let location {
|
||||
if let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
|
||||
stations = fetched
|
||||
}
|
||||
}
|
||||
if stations.isEmpty { stations = FuelStore.loadStations() }
|
||||
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
|
||||
let unit = FuelStore.loadDistanceUnit()
|
||||
if let location {
|
||||
// STRICT: cached data fetched around another location must never
|
||||
// leak out-of-radius stations into the widget.
|
||||
stations = stations.filter {
|
||||
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
||||
|
||||
// 3) Load + order stations per widget mode.
|
||||
let ordered: [FuelStation]
|
||||
if isFavourites {
|
||||
// Favourites: 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.
|
||||
let favourites = FuelStore.loadFavourites()
|
||||
.filter { $0.fuel == fuel && $0.station.prices[fuel] != nil }
|
||||
.map(\.station)
|
||||
var refreshed: [FuelStation] = favourites
|
||||
if let location,
|
||||
let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
|
||||
let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
||||
refreshed = favourites.map { freshByID[$0.id] ?? $0 }
|
||||
}
|
||||
}
|
||||
let filtered = stations.filter { $0.prices[fuel] != nil }
|
||||
let sorted: [FuelStation]
|
||||
switch sort {
|
||||
case .closest:
|
||||
// Nearest first; price only breaks ties.
|
||||
sorted = filtered.sorted { lhs, rhs in
|
||||
if let location {
|
||||
let lDist = lhs.distanceKM(to: location.lat, lng2: location.lng)
|
||||
let rDist = rhs.distanceKM(to: location.lat, lng2: location.lng)
|
||||
if lDist != rDist { return lDist < rDist }
|
||||
}
|
||||
return lhs.prices[fuel]! < rhs.prices[fuel]!
|
||||
}
|
||||
case .cheapest:
|
||||
// Cheapest first; distance only breaks ties.
|
||||
sorted = filtered.sorted { lhs, rhs in
|
||||
ordered = refreshed.sorted { lhs, rhs in
|
||||
let lPrice = lhs.prices[fuel]!
|
||||
let rPrice = rhs.prices[fuel]!
|
||||
if lPrice != rPrice { return lPrice < rPrice }
|
||||
@@ -152,13 +140,59 @@ struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
|
||||
}
|
||||
return false
|
||||
}
|
||||
} 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
|
||||
// shared (SideStore free accounts don't provision the group, which
|
||||
// previously left the widget stuck on sample data). Falls back to the
|
||||
// shared cache, then to samples.
|
||||
var stations: [FuelStation] = []
|
||||
if let location {
|
||||
if let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
|
||||
stations = fetched
|
||||
}
|
||||
}
|
||||
if stations.isEmpty { stations = FuelStore.loadStations() }
|
||||
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
|
||||
if let location {
|
||||
// STRICT: cached data fetched around another location must never
|
||||
// leak out-of-radius stations into the widget.
|
||||
stations = stations.filter {
|
||||
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
||||
}
|
||||
}
|
||||
let filtered = stations.filter { $0.prices[fuel] != nil }
|
||||
switch sort {
|
||||
case .closest:
|
||||
// Nearest first; price only breaks ties.
|
||||
ordered = filtered.sorted { lhs, rhs in
|
||||
if let location {
|
||||
let lDist = lhs.distanceKM(to: location.lat, lng2: location.lng)
|
||||
let rDist = rhs.distanceKM(to: location.lat, lng2: location.lng)
|
||||
if lDist != rDist { return lDist < rDist }
|
||||
}
|
||||
return lhs.prices[fuel]! < rhs.prices[fuel]!
|
||||
}
|
||||
case .cheapest:
|
||||
// Cheapest first; distance only breaks ties.
|
||||
ordered = filtered.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return FuelPriceEntry(
|
||||
date: Date(), stations: Array(sorted.prefix(4)),
|
||||
fuel: fuel, sort: sort,
|
||||
date: Date(), stations: Array(ordered.prefix(4)),
|
||||
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
||||
location: location, locationSource: source,
|
||||
unit: FuelStore.loadDistanceUnit()
|
||||
unit: unit
|
||||
)
|
||||
}
|
||||
|
||||
@@ -197,10 +231,12 @@ struct FuelPriceWidgetView: View {
|
||||
Group {
|
||||
if entry.stations.isEmpty {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "fuelpump")
|
||||
Image(systemName: entry.isFavourites ? "star" : "fuelpump")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No \(entry.fuel.displayName) stations")
|
||||
Text(entry.isFavourites
|
||||
? "No favourite \(entry.fuel.displayName) stations"
|
||||
: "No \(entry.fuel.displayName) stations")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -213,7 +249,8 @@ struct FuelPriceWidgetView: View {
|
||||
}
|
||||
|
||||
private var heading: String {
|
||||
entry.sort == .closest
|
||||
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
|
||||
return entry.sort == .closest
|
||||
? "Closest \(entry.fuel.displayName)"
|
||||
: "Cheapest \(entry.fuel.displayName)"
|
||||
}
|
||||
@@ -260,7 +297,8 @@ struct FuelPriceWidgetView: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(entry.locationSource == "none" ? "by price" : "near you")
|
||||
Text(entry.isFavourites ? "cheapest first"
|
||||
: (entry.locationSource == "none" ? "by price" : "near you"))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -24,12 +24,14 @@ enum WidgetFuel: String, AppEnum, CaseIterable, Codable {
|
||||
enum WidgetSort: String, AppEnum, CaseIterable, Codable {
|
||||
case cheapest
|
||||
case closest
|
||||
case favourites
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Sort by"
|
||||
|
||||
static var caseDisplayRepresentations: [WidgetSort: DisplayRepresentation] = [
|
||||
.cheapest: "Cheapest",
|
||||
.closest: "Closest",
|
||||
.favourites: "Favourites",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -67,4 +69,21 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent {
|
||||
|
||||
@Parameter(title: "Distance", default: .five)
|
||||
var distance: WidgetDistance
|
||||
|
||||
/// Edit-Widget UI: Favourites ranks pinned stations cheapest-first and is
|
||||
/// not radius-bound, so the Distance picker is hidden in that mode.
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
When(\.$sort, .equalTo, WidgetSort.favourites) {
|
||||
Summary("Show \(\.$fuel) favourites") {
|
||||
\.$fuel
|
||||
\.$sort
|
||||
}
|
||||
} otherwise: {
|
||||
Summary("Show \(\.$fuel) by \(\.$sort) within \(\.$distance)") {
|
||||
\.$fuel
|
||||
\.$sort
|
||||
\.$distance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user