Files
fuelboard/FuelBoardWidgets/FuelPriceWidget.swift
T
FuelBoard Contributor 2a2823c180 Accurate distances toggle, Approx. distance markers, and Closest-mode speedup
Distance confidence
- Every distance surface takes the figure AND its "Approx." marker from one
  FuelStore.stationDistanceDisplay call (station rows, detail sheet, widget,
  Live Activity, watch, Siri), so a surface can never mark one figure approximate
  while showing a routed one. Radius filters and Closest ordering test that same
  displayed distance, so no row can read a distance outside the radius that
  filtered it (the reported "14.6 mi inside a 10-mile list").

Accurate distances (Settings -> Distances, default off)
- Off: the browse layer serves the straight line, marked Approx., for every row,
  so two installs agree to the metre and the app makes no route requests at all.
- On: routed distances for the whole search area, unmarked, served only from a
  COMPLETE set anchored within 600 m — a partial fill reverts every row at once
  rather than mixing road and straight-line bases.
- Best Value maths sits deliberately outside the toggle: it keeps using measured
  road routes and its own confidence marking, so money figures never move with
  the switch.
- Fills run only while the toggle is on and the app is frontmost; capacity raised
  to 600 stations per pass; completeness tolerates pins Apple Maps has no route
  for, which otherwise left an area permanently incomplete and the whole list
  stuck on estimates.

Performance (the reported slowdown switching to Closest / launching with it)
- FuelStore.accurateDistancesEnabled was a UserDefaults(suiteName:) read per
  call: a synchronous cfprefsd round-trip measured at ~1.9 us, ~200k calls per
  Closest sort. Memoised, with the setter keeping it in step.
- Both keyed sorts decorate their keys — one distance per station, carried as an
  index instead of copying whole stations into the decorated array — and the
  comparison baseline plus per-station journeys now come from ONE pass instead of
  two full scans per tab. The Stations tab also reuses its single sorted list for
  the count and the TOP badge instead of sorting the country three times.
- Measured on iPhone SE (7,914-station Closest pool): sort 36.6 ms -> 17.1 ms per
  list, Cheapest 6 ms, Best Value 11 ms.
- DEBUG probes: FB_PERF list timing, FB_PERF_BENCH toggle-read benchmark, and
  FB_DIST_FOCUS now reports the served value, marker and mode (and re-prints when
  a fill lands). FB_ACCURATE_DISTANCES / FB_SORT_MODE env forms for devices, where
  devicectl eats a leading-dash argument.

Tests: 198, 0 failures. Device and simulator Debug builds green.
2026-10-13 18:01:45 +01:00

298 lines
15 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
)
)
}
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 = await makeEntryCore(configuration: configuration)
writeDiagBeacon(entry: entry)
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.
private func writeDiagBeacon(entry: FuelPriceEntry) {
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)",\
"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 {
// 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 }
// 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 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 }
}
// 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 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 {
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
userLat: location.lat, userLng: location.lng)
}
}
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 = FuelStore.displayDistanceKM(station: lhs, userLat: location.lat, userLng: location.lng)
let rDist = FuelStore.displayDistanceKM(station: rhs, userLat: location.lat, userLng: location.lng)
if lDist != rDist { return lDist < rDist }
}
return lhs.prices[fuel]! < rhs.prices[fuel]!
}
case .cheapest, .worthTheDrive:
// Cheapest first; distance only breaks ties. Worth-the-Drive is app-only;
// widgets fall back to cheapest ordering if a stored config ever carries it.
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 FuelStore.displayDistanceKM(station: lhs, userLat: location.lat, userLng: location.lng) <
FuelStore.displayDistanceKM(station: rhs, userLat: location.lat, userLng: location.lng)
}
return false
}
}
}
return FuelPriceEntry(
data: FuelPriceWidgetEntryData(
date: Date(), stations: Array(ordered.prefix(8)),
fuel: fuel, sort: sort, isFavourites: isFavourites,
location: location, locationSource: source,
unit: unit
)
)
}
/// 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)
}
}