Files
fuelboard/FuelBoardWidgets/FuelPriceWidget.swift
T

382 lines
19 KiB
Swift

import WidgetKit
import SwiftUI
import AppIntents
// Fuel colour wheel lives in Shared/FuelPriceWidgetViews.swift (moved from
// here 2026-08-16 so the app's widget preview screen uses the same colours).
// Shared/FuelStore.swift stays Foundation-only by design.
// FuelBoard widget — petrol stations near you. ONE widget kind, TWO sizes:
// .systemSmall: single station (the first of the configured result — the
// cheapest/closest station, or the cheapest favourite of the chosen fuel
// in Favourites mode); the whole face opens Maps.
// .systemMedium: top 3 stations with price + distance, each row opens Maps.
// Per-widget fuel + sort (+ distance for Cheapest) via one config intent.
// The view branches on widgetFamily, not kind — the small face renders the
// first station, the medium face the list.
// 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.
//
// History: a separate "small" kind existed (FuelPriceWidgetSmall) and was
// merged here on 2026-08-14. The small kind had gained .systemMedium so the
// iOS long-press app-icon menu (which shows only the FIRST-registered kind's
// sizes) would offer both tiles — but that duplicated the medium gallery
// entry. Merging keeps both size tiles in the icon menu AND one gallery entry
// with both sizes.
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([.systemSmall, .systemMedium])
.disfavoredLocations([.carPlay], for: [.systemSmall, .systemMedium])
}
}
struct FuelPriceEntry: TimelineEntry {
let data: FuelPriceWidgetEntryData
var date: Date { data.date }
}
struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & WidgetConfigValues>: AppIntentTimelineProvider {
typealias Intent = Configuration
/// Diagnostics beacon tag: empty → derive from the intent type name. A
/// widget kind that shares an intent (the A/B test small widget reuses the
/// medium intent) passes an explicit tag so its keychain diagnostic slots
/// stay distinguishable.
var beaconTag: String = ""
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(
data: FuelPriceWidgetEntryData(
date: Date(), stations: Array(sample.prefix(8)),
fuel: .e10, sort: .cheapest, isFavourites: false,
location: nil, locationSource: "none", unit: unit,
bestValueResult: nil
)
)
}
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 {
let (entry, originSource) = await makeEntryCore(configuration: configuration)
writeDiagBeacon(entry: entry, originSource: originSource)
return entry
}
/// Fire-and-forget diagnostics beacon: writes the entry state to keychain
/// (app-readable, one slot per intent type) and records what a widget
/// timeline produced for Settings → Widget Diagnostics. Deliberately
/// outside the timeline result — can never affect rendering. No user
/// coordinates are written, only which fix decided the distances.
private func writeDiagBeacon(entry: FuelPriceEntry, originSource: String) {
let d = entry.data
let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
let first = d.stations.first
let json = """
{"intent":"\(intentType)","source":"\(d.locationSource)",\
"origin":"\(originSource)",\
"n":\(d.stations.count),"fuel":"\(d.fuel.rawValue)",\
"sort":"\(d.sort.rawValue)","fav":\(d.isFavourites),\
"station":"\(first?.name ?? "")","price":\(first?.prices[d.fuel] ?? -1)}
"""
FuelStore.saveWidgetDiag(json, intentType: intentType)
// Local app-group diagnostics only. No network beacon is sent: the
// App Store build must not contain a private LAN relay fallback.
}
private func makeEntryCore(configuration: Configuration) async -> (FuelPriceEntry, String) {
// 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 :
(configuration.sort == .worthTheDrive ? .worthTheDrive : .cheapest)
let radiusKM = Double(configuration.distance.miles) * FuelSavingsCalculator.kilometresPerMile
// 1) Try a fresh location fix (bounded to a few seconds). The fix's own
// accuracy is kept: the extension runs its own CLLocationManager and is
// routinely handed a coarse Wi-Fi/cell fix (or a cached one from earlier
// in the day), which must not be read as "the user moved away from the
// route cache".
let fix = await WidgetLocationFetcher.shared.currentLocation()
var location = fix.map {
Coordinate(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude)
}
let fixAccuracy = fix?.horizontalAccuracy
var source = "live"
// 2) Fall back to the app's cached fix, then to our own last fix.
if location == nil, let cached = FuelStore.loadLocation() {
location = cached
source = "cached"
}
if location == nil, let own = FuelStore.loadWidgetLocation() {
location = own.coordinate
source = "widgetCached"
}
if location == nil {
source = "none"
}
// Persist OUR fix under its own key. It must never be written over the
// app's GPS fix: that stored coordinate is what the app seeds itself
// with, and it is the fix the route cache is anchored to.
if let location {
FuelStore.saveWidgetLocation(lat: location.lat, lng: location.lng)
}
// The origin every distance on this face is measured from. Routed values
// are anchored to the app's fix, so when our own fix is coarse and lands
// away from that anchor this labels from the app's fix instead of
// collapsing every row to straight-line + "Approx." — see
// FuelStore.distanceDisplayOrigin.
var origin: Coordinate?
var originSource = "none"
if let location {
let resolved = FuelStore.distanceDisplayOrigin(candidateLat: location.lat,
candidateLng: location.lng,
candidateAccuracyMeters: fixAccuracy)
origin = Coordinate(lat: resolved.lat, lng: resolved.lng)
originSource = resolved.source
}
let unit = FuelStore.loadDistanceUnit()
// 3) Load + order stations per widget mode.
let ordered: [FuelStation]
var bestValueResult: FuelSavingsResult?
if isFavourites {
let favourites = FuelStore.loadFavourites()
.filter { $0.station.prices[$0.fuel] != nil }
// Pinned stations for THIS fuel, in the USER'S manual order (the
// Favourites tab drag-to-reorder). 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 mirror
// fetch when a favourite happens to be in range. The small face
// renders the FIRST of this list — the user's top favourite for
// the chosen fuel. (The old per-widget pinned-favourite picker
// was removed: its AppEntity params made fresh-widget
// default-config resolution fail at the system level — the merged
// widget uses the minimal fuel/sort/distance intent.)
let fuelFavourites = favourites
.filter { $0.fuel == fuel && $0.station.prices[fuel] != nil }
.map(\.station)
var refreshed: [FuelStation] = fuelFavourites
if let origin,
let fetched = await Self.fetchFocused(near: origin, fuel: fuel, radiusKM: radiusKM) {
let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
refreshed = fuelFavourites.map { freshByID[$0.id] ?? $0 }
}
// Stored order IS the widget order — the user's manual ranking,
// NOT a price sort (cheapest-first would override the top
// favourite that the Favourites tab sets for single widgets).
ordered = refreshed
} else {
// 3a) Load stations. The widget prefers its OWN focused fetch from the
// public mirror 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 origin {
if let fetched = await Self.fetchFocused(near: origin, 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 origin {
// STRICT: cached data fetched around another location must never
// leak out-of-radius stations into the widget. Measured from the
// SAME origin the rows are labelled with, so the radius and the
// displayed distances can never disagree.
stations = stations.filter {
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
userLat: origin.lat, userLng: origin.lng)
}
}
let filtered = stations.filter { $0.prices[fuel] != nil }
switch sort {
case .closest:
// Closest first; price only breaks ties.
ordered = filtered.sorted { lhs, rhs in
if let origin {
let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng)
let rDist = FuelStore.displayDistanceKM(station: rhs, userLat: origin.lat, userLng: origin.lng)
if lDist != rDist { return lDist < rDist }
}
return lhs.prices[fuel]! < rhs.prices[fuel]!
}
case .worthTheDrive:
guard let origin else {
ordered = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
break
}
let journey: (FuelStation) -> FuelSavingsJourney? = { station in
if let meters = FuelStore.roadJourneyMeters(for: station, userLat: origin.lat, userLng: origin.lng) {
let confidence: JourneyConfidence = FuelStore.hasMeasuredReturnRoadDistance(
for: station, userLat: origin.lat, userLng: origin.lng
) ? .confirmed : .estimated
return FuelSavingsJourney(
miles: meters / FuelSavingsCalculator.metresPerMile,
basis: .roadRoute,
confidence: confidence
)
}
return FuelSavingsJourney(
miles: FuelStore.displayDistanceKM(station: station, userLat: origin.lat, userLng: origin.lng) /
FuelSavingsCalculator.kilometresPerMile * 2,
basis: .straightLine,
confidence: .estimated
)
}
let settings = FuelStore.loadFuelSavingsSettings()
guard settings.isConfigured else {
ordered = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
break
}
let amount = settings.preferredAmount
let baseline = FuelSavingsEstimator.baselineStation(filtered, fuel: fuel, journeyMiles: journey)
let ranked = FuelSavingsEstimator.ranked(
filtered,
fuel: fuel,
baseline: baseline,
settings: settings,
amount: amount,
journeyMiles: journey
)
ordered = ranked
if let first = ranked.first, let baseline {
let baselineJourney = journey(baseline)
let result = FuelSavingsEstimator.availability(
station: first,
fuel: fuel,
baseline: baseline,
settings: settings,
amount: amount,
journey: journey(first),
baselineJourney: baselineJourney
)
bestValueResult = result?.result
}
case .cheapest:
ordered = filtered.sorted { lhs, rhs in
let lPrice = lhs.prices[fuel]!
let rPrice = rhs.prices[fuel]!
if lPrice != rPrice { return lPrice < rPrice }
if let origin {
return FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng) <
FuelStore.displayDistanceKM(station: rhs, userLat: origin.lat, userLng: origin.lng)
}
return false
}
}
}
return (FuelPriceEntry(
data: FuelPriceWidgetEntryData(
date: Date(), stations: Array(ordered.prefix(8)),
fuel: fuel, sort: sort, isFavourites: isFavourites,
location: origin, locationSource: source,
unit: unit, bestValueResult: bestValueResult
)
), originSource)
}
/// One focused fetch around the widget's location. Public HTTPS mirror only;
/// no private LAN fallback is attempted. Returns nil on any failure so
/// callers fall back to cache/placeholder.
private static func fetchFocused(near location: Coordinate, fuel: FuelType, radiusKM: Double) async -> [FuelStation]? {
try? await MirrorFuelProvider().fetchStations(
near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM)
}
/// Fuel-only UK-wide fetch (no lat/lng/radius) — used when the widget has
/// no location fix so the face shows real stations instead of sample data.
/// Public HTTPS mirror only; same nil-on-failure semantics as fetchFocused.
private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [FuelStation]? {
guard let stations = try? await MirrorFuelProvider().fetchStations(
near: nil, lng: nil, fuel: fuel, radiusKM: nil) else { return nil }
return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit))
}
}
struct FuelPriceWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: FuelPriceEntry
var body: some View {
FuelPriceWidgetContent(entry: entry.data, family: family)
}
}