Files
fuelboard/FuelBoardWidgets/FuelPriceWidget.swift
T
FuelBoard Contributor 5246f41c48 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.
2026-08-12 13:45:42 +01:00

345 lines
15 KiB
Swift

import WidgetKit
import SwiftUI
import AppIntents
// FuelBoard widget — petrol stations near you.
// systemMedium: top 3-4 stations with price + distance, each row opens Maps
// systemSmall: single station, whole widget opens Maps
// Taps deep-link to Apple Maps directions (maps://?daddr=). The widget uses
// the native Maps scheme (not a custom app scheme) so that CarPlay can route
// the tap straight to Apple Maps — which IS a CarPlay app — without needing
// the containing app to launch. On the Home Screen the system either opens
// Maps directly or delivers the URL to FuelBoard, whose onOpenURL forwards it
// (and also still handles legacy fuelboard:// and http://maps.apple.com links
// from older cached timelines).
//
// Each widget instance is configured INDEPENDENTLY via its own App Intent
// (long-press → Edit Widget): fuel type (Unleaded/Premium/Diesel) and sort
// (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
// (no permission yet / timeout) it falls back to the app's cached location,
// then to price-only sorting. So the widget is location-driven the moment the
// app has been opened once and permission granted.
struct FuelPriceWidget: Widget {
let kind = "FuelPriceWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: FuelBoardWidgetConfigurationIntent.self,
provider: FuelPriceTimelineProvider()
) { entry in
FuelPriceWidgetView(entry: entry)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
}
.configurationDisplayName("FuelBoard Prices")
.description("Fuel prices near you. Configure fuel + sort per widget.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
struct FuelPriceEntry: TimelineEntry {
let date: Date
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
}
struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> FuelPriceEntry {
let unit = FuelStore.loadDistanceUnit()
let sample = SampleFuelProvider.sampleStations
.filter { $0.prices[.e10] != nil }
.sorted { $0.prices[.e10]! < $1.prices[.e10]! }
return FuelPriceEntry(
date: Date(), stations: Array(sample.prefix(4)),
fuel: .e10, sort: .cheapest, isFavourites: false,
location: nil, locationSource: "none", unit: unit
)
}
func snapshot(for configuration: FuelBoardWidgetConfigurationIntent, in context: Context) async -> FuelPriceEntry {
await makeEntry(configuration: configuration)
}
func timeline(
for configuration: FuelBoardWidgetConfigurationIntent,
in context: Context
) async -> Timeline<FuelPriceEntry> {
let entry = await makeEntry(configuration: configuration)
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
return Timeline(entries: [entry], policy: .after(nextRefresh))
}
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
// 1) Try a fresh location fix (bounded to a few seconds).
var location = await WidgetLocationFetcher.shared.currentLocation().map {
Coordinate(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude)
}
var source = "live"
// 2) Fall back to the app's cached fix.
if location == nil, let cached = FuelStore.loadLocation() {
location = cached
source = "cached"
}
if location == nil {
source = "none"
}
// Persist a live fix so the app shows fresh coords on next launch.
if let location {
FuelStore.saveLocation(lat: location.lat, lng: location.lng)
}
let unit = FuelStore.loadDistanceUnit()
// 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 }
}
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
}
} 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(ordered.prefix(4)),
fuel: fuel, sort: sort, isFavourites: isFavourites,
location: location, locationSource: source,
unit: unit
)
}
/// One focused fetch from the relay around the widget's location — small
/// response (radius + limit 500), bounded so a dead relay can't stall the
/// timeline. Returns nil on any failure so callers fall back to cache.
private static func fetchFocused(near location: Coordinate, fuel: FuelType, radiusKM: Double) async -> [FuelStation]? {
var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
resolvingAgainstBaseURL: false
)!
components.queryItems = [
URLQueryItem(name: "fuel", value: fuel.rawValue),
URLQueryItem(name: "lat", value: String(location.lat)),
URLQueryItem(name: "lng", value: String(location.lng)),
URLQueryItem(name: "radius", value: String(radiusKM)),
URLQueryItem(name: "limit", value: "500"),
]
var request = URLRequest(url: components.url!)
request.timeoutInterval = 5
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? FuelPriceProvider.decodeStations(from: data)
} catch {
return nil
}
}
}
struct FuelPriceWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: FuelPriceEntry
var body: some View {
Group {
if entry.stations.isEmpty {
VStack(spacing: 6) {
Image(systemName: entry.isFavourites ? "star" : "fuelpump")
.font(.title2)
.foregroundStyle(.secondary)
Text(entry.isFavourites
? "No favourite \(entry.fuel.displayName) stations"
: "No \(entry.fuel.displayName) stations")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else if family == .systemSmall {
singleStation
} else {
stationList
}
}
}
private var heading: String {
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
return entry.sort == .closest
? "Closest \(entry.fuel.displayName)"
: "Cheapest \(entry.fuel.displayName)"
}
private var singleStation: some View {
let station = entry.stations[0]
return VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(.green)
Text(heading)
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(station.name)
.font(.headline)
.lineLimit(1)
if let price = station.prices[entry.fuel] {
Text(String(format: "£%.3f", price / 100))
.font(.system(size: 26, weight: .bold).monospaced())
.foregroundStyle(.green)
}
if let location = entry.location {
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
Text("Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.widgetURL(station.widgetDirectionsURL)
}
private var stationList: some View {
let cheapest = entry.stations.compactMap { $0.prices[entry.fuel] }.min()
return VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(.green)
Text(heading)
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
Text(entry.isFavourites ? "cheapest first"
: (entry.locationSource == "none" ? "by price" : "near you"))
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(entry.stations.prefix(3)) { station in
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
HStack(spacing: 8) {
Text(station.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
if let location = entry.location {
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
if let price = station.prices[entry.fuel] {
HStack(spacing: 4) {
Circle()
.fill(ragColor(for: price, cheapest: cheapest))
.frame(width: 6, height: 6)
Text(String(format: "£%.3f", price / 100))
.font(.caption.weight(.bold).monospaced())
.foregroundStyle(.primary)
}
}
}
}
.buttonStyle(.plain)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func ragColor(for price: Double, cheapest: Double?) -> Color {
guard let cheapest else { return .gray }
switch RAGRating.rating(price: price, cheapest: cheapest) {
case .green: return .green
case .amber: return .orange
case .red: return .red
}
}
}