382 lines
19 KiB
Swift
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)
|
|
}
|
|
}
|