From 5246f41c484df9b0f0f25a6dfbe529c5f67d1066 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 12 Aug 2026 13:45:42 +0100 Subject: [PATCH] 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. --- FuelBoard/ContentView.swift | 5 + FuelBoardWidgets/FuelPriceWidget.swift | 128 ++++++++++++++-------- FuelBoardWidgets/WidgetConfigIntent.swift | 19 ++++ 3 files changed, 107 insertions(+), 45 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index e8c9b6f..0a95cf0 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -285,6 +285,11 @@ struct ContentView: View { stations = fetched FuelStore.saveStations(fetched) FuelStore.saveLastRefresh() + // Keep the keychain favourites fresh with the new prices — the + // widget's Favourites mode reads them from keychain (the only + // channel shared on SideStore free), so stale star-time snapshots + // would otherwise show old prices. + FuelStore.saveFavourites(refreshedFavourites) WidgetCenter.shared.reloadAllTimelines() statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))" } catch { diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index c8b3bce..c28205c 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -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) } diff --git a/FuelBoardWidgets/WidgetConfigIntent.swift b/FuelBoardWidgets/WidgetConfigIntent.swift index 53e87f2..d02ebf0 100644 --- a/FuelBoardWidgets/WidgetConfigIntent.swift +++ b/FuelBoardWidgets/WidgetConfigIntent.swift @@ -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 + } + } + } }