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() ) { 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: 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 { 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) } }