Files
fuelboard/FuelBoardWidgets/FuelPriceWidget.swift
T
FuelBoard Contributor 4d35b08533 Widget: keychain-free fresh defaults — drop defaultResult(), auto-populate at entry time
The small-widget skeleton (fresh widget stranded on the placeholder while
the medium populated in the gallery) traced to the small intent's default
configuration resolution: FavouriteFuelQuery/WidgetFavouriteQuery
defaultResult() read keychain in the gallery-preview/fresh-add path,
failing the whole default config and leaving no timeline. Configured
widgets bypassed defaultResult (stored values → lenient entities) — the
exact favourites-works/cheapest-closest-doesn't split.

- Remove defaultResult() from both queries: fresh configs now resolve
  from the static @Parameter(default:) values through lenient
  entities(for:) with ZERO storage reads.
- WidgetFavouriteQuery.entities(for:) skips the keychain name lookup for
  id-only (empty station) values.
- Auto-populate moves to makeEntry: empty pinned favourite → first
  favourite of the fuel (same rendered result as the old sheet default).
- Sheet keeps the dependent picker via suggestedEntities() + dependency;
  fresh widgets show static defaults until picked (approved tradeoff).
2026-08-13 19:35:46 +01:00

477 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import WidgetKit
import SwiftUI
import AppIntents
// FuelBoard widget — petrol stations near you.
// FuelPriceWidget (medium): top 3 stations with price + distance, each row
// opens Maps. Per-widget fuel + sort (+ distance for Cheapest).
// FuelPriceSmallWidget (small): single station, whole widget opens Maps.
// Same knobs, plus a Favourite picker when Sort = Favourites (a small face
// shows ONE station, so you choose which favourite to pin; the medium face
// lists all favourites and has no picker).
// Taps deep-link to Apple Maps directions (maps://?daddr=). 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).
//
// CarPlay: this widget is marked as a DISFAVORED location there. Widgets in
// CarPlay can only launch their OWN app, and only when that app is itself a
// CarPlay app (fueling entitlement). FuelBoard is not a CarPlay app, so a
// tap in the car would be a dead interaction — Apple's guidance for widgets
// whose purpose is launching a non-CarPlay app is to disfavor CarPlay: the
// widget stays visible (read-only prices) but interaction is disabled, so
// there is no tap that silently does nothing.
//
// 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 {
// CarPlay: mark as disfavored — FuelBoard is not a CarPlay app, so
// widget taps there can never launch Maps (Apple only allows a widget
// to launch its own app in CarPlay, and only CarPlay-enabled apps).
// Disfavored = read-only in the car, no dead interaction.
AppIntentConfiguration(
kind: kind,
intent: FuelBoardWidgetConfigurationIntent.self,
provider: FuelPriceTimelineProvider<FuelBoardWidgetConfigurationIntent>()
) { entry in
FuelPriceWidgetView(entry: entry)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
}
.configurationDisplayName("FuelBoard Prices")
.description("Fuel prices near you. Configure fuel + sort per widget.")
.supportedFamilies([.systemMedium])
.disfavoredLocations([.carPlay], for: [.systemMedium])
}
}
// Small widget — exactly ONE station. Favourites sort gains a picker to choose
// WHICH favourite to pin, because a small face can show only one (the medium
// face lists them all, so the picker exists only here). Separate kind keeps
// the extra parameter off the list widget's Edit-Widget sheet.
struct FuelPriceSmallWidget: Widget {
let kind = "FuelPriceWidgetSmall"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: FuelBoardSmallWidgetConfigurationIntent.self,
provider: FuelPriceTimelineProvider<FuelBoardSmallWidgetConfigurationIntent>()
) { entry in
FuelPriceWidgetView(entry: entry)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
}
.configurationDisplayName("FuelBoard Favourite")
.description("One pinned favourite station with its price.")
.supportedFamilies([.systemSmall])
.disfavoredLocations([.carPlay], for: [.systemSmall])
}
}
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<Configuration: WidgetConfigurationIntent & WidgetConfigValues>: AppIntentTimelineProvider {
typealias Intent = Configuration
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(8)),
fuel: .e10, sort: .cheapest, isFavourites: false,
location: nil, locationSource: "none", unit: unit
)
}
func snapshot(for configuration: Configuration, in context: Context) async -> FuelPriceEntry {
await makeEntry(configuration: configuration)
}
func timeline(
for configuration: Configuration,
in context: Context
) async -> Timeline<FuelPriceEntry> {
let entry = await makeEntry(configuration: configuration)
// Short cadence so the widget re-orders around a new location while
// driving — the provider re-fetches a fresh fix + prices each tick.
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 5, to: Date())!
return Timeline(entries: [entry], policy: .after(nextRefresh))
}
private func makeEntry(configuration: Configuration) 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 {
let favourites = FuelStore.loadFavourites()
.filter { $0.station.prices[$0.fuel] != nil }
// Small widget, pinned favourite: exactly ONE station, its own
// fuel, no radius. Freshen its price when a focused fetch covers
// it. The chosen favourite wins; when it's missing or stale (fresh
// widget, favourite removed, fuel row changed) fall back to the
// favourites fuel's first favourite so the face is never blank.
if let chosen = configuration.favouriteChoice {
let favouriteFuelType = configuration.favouriteFuelChoice?.fuel ?? fuel
// Fresh widget / static default (empty pinned favourite):
// auto-populate to the FIRST favourite of the fuel. This is
// the sheet's old defaultResult() auto-populate, moved to
// entry time so default-config resolution stays keychain-free
// (storage-backed defaultResult() stranding fresh small
// widgets on the skeleton placeholder).
let pinned: FavouriteEntry?
if chosen.stationID.isEmpty {
pinned = favourites
.filter { $0.fuel == favouriteFuelType && $0.station.prices[favouriteFuelType] != nil }
.first
} else {
pinned = favourites.first { $0.id == chosen.id }
?? favourites
.filter { $0.fuel == favouriteFuelType && $0.station.prices[favouriteFuelType] != nil }
.min { lhs, rhs in
(lhs.station.prices[favouriteFuelType] ?? .infinity) <
(rhs.station.prices[favouriteFuelType] ?? .infinity)
}
}
if let pinned {
var station = pinned.station
if let location,
let fetched = await Self.fetchFocused(near: location, fuel: pinned.fuel, radiusKM: radiusKM),
let fresh = fetched.first(where: { $0.id == pinned.station.id }) {
station = fresh
}
return FuelPriceEntry(
date: Date(), stations: [station],
fuel: pinned.fuel, sort: .cheapest, isFavourites: true,
location: location, locationSource: source, unit: unit
)
}
}
// List mode (medium): 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 fuelFavourites = favourites
.filter { $0.fuel == fuel && $0.station.prices[fuel] != nil }
.map(\.station)
var refreshed: [FuelStation] = fuelFavourites
if let location,
let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
refreshed = fuelFavourites.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
}
} else {
// No location (permission not granted / fix timed out): fetch
// fuel-only UK-wide so a fresh/default widget STILL populates
// with real stations. The app-group cache can be unavailable
// on free accounts and keychain can't hold the station dump,
// so the old fallback ended on sample data — the "skeleton"
// face that never populated.
if let fetched = await Self.fetchFuelOnly(fuel: fuel, limit: 500) {
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(8)),
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
}
}
/// Fuel-only UK-wide fetch (no lat/lng/radius) — the relay returns the
/// cheapest-first dataset for the fuel, bounded by limit. Used when the
/// widget has no location fix so the face shows real stations instead of
/// sample data. Same timeout/bounded semantics as fetchFocused.
private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [FuelStation]? {
var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
resolvingAgainstBaseURL: false
)!
components.queryItems = [
URLQueryItem(name: "fuel", value: fuel.rawValue),
URLQueryItem(name: "limit", value: String(limit)),
]
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)
}
/// How many station rows each family can fit: medium widgets are short
/// (158pt) so 3 rows is the safe cap; large/extraLarge have ~2× the height
/// and fit 5 compact rows. Small widgets use singleStation instead.
private var maxRows: Int {
switch family {
case .systemMedium: return 3
default: return 5
}
}
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(maxRows)) { 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
}
}
}