- Move widget entry view into Shared/FuelPriceWidgetViews.swift (FuelPriceWidgetContent, FuelPriceWidgetEntryData) so the app can render the REAL widget faces; widget extension now wraps it (FuelPriceEntry.data) — one source of truth for the face - WidgetMockScreen (-widgets launch arg): app icon + small + medium widget previews for App Store captures; AppIconPreview image set (1024 drip master) - AppStoreScreenshots: 5-8 dark-mode tab captures with real Halifax data, 9 widgets - 95/95 tests green; Release IPA rebuilt + re-served
362 lines
18 KiB
Swift
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 }
|
|
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(
|
|
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"),
|
|
]
|
|
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) — 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)),
|
|
]
|
|
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 {
|
|
FuelPriceWidgetContent(entry: entry.data, family: family)
|
|
}
|
|
}
|