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() ) { 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: 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 { 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 = URLRequest(url: url) request.timeoutInterval = 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 = URLRequest(url: components.url!) request.timeoutInterval = 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 = URLRequest(url: components.url!) request.timeoutInterval = 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 } } }