474 lines
22 KiB
Swift
474 lines
22 KiB
Swift
import WidgetKit
|
||
import SwiftUI
|
||
import AppIntents
|
||
|
||
// Fuel colour wheel — mirrors the app's palette in StationsView.swift
|
||
// (FuelType.tintColor): green = unleaded (#30D158), yellow = premium
|
||
// (#FFD60A), cyan = diesel (#64D2FF). Kept here (widget target) because
|
||
// Shared/FuelStore.swift is Foundation-only by design; if the app's copy
|
||
// ever moves to Shared, DELETE this duplicate to avoid redeclaration.
|
||
private extension FuelType {
|
||
var fuelColor: Color {
|
||
switch self {
|
||
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
||
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
||
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 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
|
||
|
||
/// 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(
|
||
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 intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
|
||
let first = entry.stations.first
|
||
let json = """
|
||
{"intent":"\(intentType)","source":"\(entry.locationSource)",\
|
||
"n":\(entry.stations.count),"fuel":"\(entry.fuel.rawValue)",\
|
||
"sort":"\(entry.sort.rawValue)","fav":\(entry.isFavourites),\
|
||
"station":"\(first?.name ?? "")","price":\(first?.prices[entry.fuel] ?? -1)}
|
||
"""
|
||
FuelStore.saveWidgetDiag(json, intentType: intentType)
|
||
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: entry.locationSource),
|
||
URLQueryItem(name: "n", value: String(entry.stations.count)),
|
||
URLQueryItem(name: "fuel", value: entry.fuel.rawValue),
|
||
URLQueryItem(name: "sort", value: entry.sort.rawValue),
|
||
URLQueryItem(name: "fav", value: entry.isFavourites ? "1" : "0"),
|
||
URLQueryItem(name: "station", value: first?.name ?? ""),
|
||
URLQueryItem(name: "price", value: String(first?.prices[entry.fuel] ?? -1)),
|
||
]
|
||
guard let url = components.url else { return }
|
||
var 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(
|
||
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 = 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) — 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 = 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 {
|
||
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(entry.fuel.fuelColor)
|
||
Text(heading)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Text(station.name)
|
||
.font(.headline)
|
||
.lineLimit(1)
|
||
if let price = station.prices[entry.fuel] {
|
||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .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(entry.fuel.fuelColor)
|
||
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)
|
||
FuelStore.priceTextAttributed(price, size: 12, weight: .bold)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.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
|
||
}
|
||
}
|
||
}
|