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 keychain diagnostic slots /// stay distinguishable. 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, originSource) = await makeEntryCore(configuration: configuration) writeDiagBeacon(entry: entry, originSource: originSource) return entry } /// Fire-and-forget diagnostics beacon: writes the entry state to keychain /// (app-readable, one slot per intent type) and records what a widget /// timeline produced for Settings → Widget Diagnostics. Deliberately /// outside the timeline result — can never affect rendering. No user /// coordinates are written, only which fix decided the distances. private func writeDiagBeacon(entry: FuelPriceEntry, originSource: String) { 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)",\ "origin":"\(originSource)",\ "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) // Local app-group diagnostics only. No network beacon is sent: the // App Store build must not contain a private LAN relay fallback. } private func makeEntryCore(configuration: Configuration) async -> (FuelPriceEntry, String) { // 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). The fix's own // accuracy is kept: the extension runs its own CLLocationManager and is // routinely handed a coarse Wi-Fi/cell fix (or a cached one from earlier // in the day), which must not be read as "the user moved away from the // route cache". let fix = await WidgetLocationFetcher.shared.currentLocation() var location = fix.map { Coordinate(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude) } let fixAccuracy = fix?.horizontalAccuracy var source = "live" // 2) Fall back to the app's cached fix, then to our own last fix. if location == nil, let cached = FuelStore.loadLocation() { location = cached source = "cached" } if location == nil, let own = FuelStore.loadWidgetLocation() { location = own.coordinate source = "widgetCached" } if location == nil { source = "none" } // Persist OUR fix under its own key. It must never be written over the // app's GPS fix: that stored coordinate is what the app seeds itself // with, and it is the fix the route cache is anchored to. if let location { FuelStore.saveWidgetLocation(lat: location.lat, lng: location.lng) } // The origin every distance on this face is measured from. Routed values // are anchored to the app's fix, so when our own fix is coarse and lands // away from that anchor this labels from the app's fix instead of // collapsing every row to straight-line + "Approx." — see // FuelStore.distanceDisplayOrigin. var origin: Coordinate? var originSource = "none" if let location { let resolved = FuelStore.distanceDisplayOrigin(candidateLat: location.lat, candidateLng: location.lng, candidateAccuracyMeters: fixAccuracy) origin = Coordinate(lat: resolved.lat, lng: resolved.lng) originSource = resolved.source } 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 mirror // 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 origin, let fetched = await Self.fetchFocused(near: origin, 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 // public mirror 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 origin { if let fetched = await Self.fetchFocused(near: origin, 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 origin { // STRICT: cached data fetched around another location must never // leak out-of-radius stations into the widget. Measured from the // SAME origin the rows are labelled with, so the radius and the // displayed distances can never disagree. stations = stations.filter { FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM, userLat: origin.lat, userLng: origin.lng) } } 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 origin { let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng) let rDist = FuelStore.displayDistanceKM(station: rhs, userLat: origin.lat, userLng: origin.lng) if lDist != rDist { return lDist < rDist } } return lhs.prices[fuel]! < rhs.prices[fuel]! } case .cheapest, .worthTheDrive: // Cheapest first; distance only breaks ties. Worth-the-Drive is app-only; // widgets fall back to cheapest ordering if a stored config ever carries it. ordered = filtered.sorted { lhs, rhs in let lPrice = lhs.prices[fuel]! let rPrice = rhs.prices[fuel]! if lPrice != rPrice { return lPrice < rPrice } if let origin { return FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng) < FuelStore.displayDistanceKM(station: rhs, userLat: origin.lat, userLng: origin.lng) } return false } } } return (FuelPriceEntry( data: FuelPriceWidgetEntryData( date: Date(), stations: Array(ordered.prefix(8)), fuel: fuel, sort: sort, isFavourites: isFavourites, location: origin, locationSource: source, unit: unit ) ), originSource) } /// One focused fetch around the widget's location. Public HTTPS mirror only; /// no private LAN fallback is attempted. 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]? { try? await MirrorFuelProvider().fetchStations( near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM) } /// 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. /// Public HTTPS mirror only; same nil-on-failure semantics as fetchFocused. private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [FuelStation]? { guard let stations = try? await MirrorFuelProvider().fetchStations( near: nil, lng: nil, fuel: fuel, radiusKM: nil) else { return nil } return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit)) } } struct FuelPriceWidgetView: View { @Environment(\.widgetFamily) private var family let entry: FuelPriceEntry var body: some View { FuelPriceWidgetContent(entry: entry.data, family: family) } }