Files
fuelboard/FuelBoardWidgets/FuelPriceWidget.swift
T
FuelBoard Contributor 535a504160 Consumer chain: GitHub-only — relay removed from app+widget, Local Network permission gone
The LAN relay is unreachable from any phone outside the developer's LAN, so
for App Store users a relay fallback was dead weight AND harmful: attempting
the private IP fires the iOS Local Network prompt on a stranger's phone and
adds a timeout before the bundled dump. GitHub raw is reachable anywhere the
relay would be, and more reliably.

- LiveChainProvider: GitHub mirror first EVERYWHERE (focused alert path no
  longer relay-first); LAN relay only joins behind the hidden dev flag
  fuelboard.relayFallback (Settings → Debug, keychain-first, off by default)
- MirrorFuelProvider: focused() projection replaces the relay's server-side
  radius endpoint (fuel filter + within-radius + nearest-first + limit 500)
  — the alert path re-checks radius/fuel itself, so behaviour is identical
- Widget: fetchFocused/fetchFuelOnly now GitHub-first via the chain (day-cache
  shared through the app group); relay + widget-diag GET gated on the same
  dev flag — off-LAN widgets no longer attempt local network at all
- FuelBeacon: fires only when the dev flag is on — consumers make zero
  local-network attempts; /stats app-hit spike = GitHub down + dev flag on
- Onboarding: Local Network machinery removed entirely (no probe, no prompt,
  no 12 s stall) — data page is now informational: prices download from the
  internet, no permissions needed. Replay on cellular can no longer hang.
- Info.plist: NSLocalNetworkUsageDescription copy → optional dev-only wording
- 95 tests (6 new: relay-skip-when-disabled x2, focused prefer-mirror, mirror
  focused projection x3, beacon dev-flag gate)
2026-08-15 13:33:02 +01:00

491 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
// 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: 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 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 {
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
}
}
}