Files

362 lines
18 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 beacons stay
/// distinguishable in the relay log and keychain slots.
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 GETs the relay so its
/// access log records that a widget timeline actually ran in the
/// extension and what it produced. 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)
// The relay GET is dev-only (same flag as the app beacon): consumers
// must never make a local-network attempt. The local app-group diag
// write above stays — Settings → Widget Diagnostics reads that.
guard FuelStore.loadRelayFallbackEnabled() else { return }
guard var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"),
resolvingAgainstBaseURL: false
) else { return }
components.queryItems = [
URLQueryItem(name: "intent", value: intentType),
URLQueryItem(name: "source", value: d.locationSource),
URLQueryItem(name: "n", value: String(d.stations.count)),
URLQueryItem(name: "fuel", value: d.fuel.rawValue),
URLQueryItem(name: "sort", value: d.sort.rawValue),
URLQueryItem(name: "fav", value: d.isFavourites ? "1" : "0"),
URLQueryItem(name: "station", value: first?.name ?? ""),
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
]
guard let url = components.url else { return }
let request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
Task {
_ = try? await URLSession.shared.data(for: request)
}
}
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 relay
// 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
// 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(
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. GitHub mirror FIRST —
/// HTTPS, works anywhere, and the app-group day-cache usually makes it a
/// local decode rather than a 2.8 MB download. The LAN relay only joins
/// behind the dev flag (fuelboard.relayFallback); consumers must never
/// attempt local-network access. 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]? {
if let stations = try? await MirrorFuelProvider().fetchStations(
near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM) {
return stations
}
guard FuelStore.loadRelayFallbackEnabled() else { return nil }
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"),
]
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 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) — used when the widget has
/// no location fix so the face shows real stations instead of sample
/// data. GitHub mirror first (full dump + local fuel filter), relay only
/// behind the dev flag. Same nil-on-failure semantics as fetchFocused.
private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [FuelStation]? {
if let stations = try? await MirrorFuelProvider().fetchStations(
near: nil, lng: nil, fuel: fuel, radiusKM: nil) {
return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit))
}
guard FuelStore.loadRelayFallbackEnabled() else { return nil }
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)),
]
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 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 {
FuelPriceWidgetContent(entry: entry.data, family: family)
}
}