import WidgetKit import SwiftUI import AppIntents // FuelBoard widget — petrol stations near you. // FuelPriceWidget (medium): top 3 stations with price + distance, each row // opens Maps. Per-widget fuel + sort (+ distance for Cheapest). // FuelPriceSmallWidget (small): single station, whole widget opens Maps. // Same knobs, plus a Favourite picker when Sort = Favourites (a small face // shows ONE station, so you choose which favourite to pin; the medium face // lists all favourites and has no picker). // 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. 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([.systemMedium]) .disfavoredLocations([.carPlay], for: [.systemMedium]) } } // Small widget — exactly ONE station. Favourites sort gains a picker to choose // WHICH favourite to pin, because a small face can show only one (the medium // face lists them all, so the picker exists only here). Separate kind keeps // the extra parameter off the list widget's Edit-Widget sheet. struct FuelPriceSmallWidget: Widget { let kind = "FuelPriceWidgetSmall" var body: some WidgetConfiguration { AppIntentConfiguration( kind: kind, intent: FuelBoardSmallWidgetConfigurationIntent.self, provider: FuelPriceTimelineProvider() ) { entry in FuelPriceWidgetView(entry: entry) .containerBackground(for: .widget) { Color(.systemBackground) } } .configurationDisplayName("FuelBoard Favourite") .description("One pinned favourite station with its price.") .supportedFamilies([.systemSmall]) .disfavoredLocations([.carPlay], for: [.systemSmall]) } } 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 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) Self.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 static func writeDiagBeacon(entry: FuelPriceEntry) { let intentType = String(describing: Configuration.self) 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 } // Small widget, pinned favourite: exactly ONE station, its own // fuel, no radius. Freshen its price when a focused fetch covers // it. The chosen favourite wins; when it's missing or stale (fresh // widget, favourite removed, fuel row changed) fall back to the // favourites fuel's first favourite so the face is never blank. if let chosen = configuration.favouriteChoice { let favouriteFuelType = configuration.favouriteFuelChoice?.fuel ?? fuel // Fresh widget / static default (empty pinned favourite): // auto-populate to the FIRST favourite of the fuel. This is // the sheet's old defaultResult() auto-populate, moved to // entry time so default-config resolution stays keychain-free // (storage-backed defaultResult() stranding fresh small // widgets on the skeleton placeholder). let pinned: FavouriteEntry? if chosen.stationID.isEmpty { pinned = favourites .filter { $0.fuel == favouriteFuelType && $0.station.prices[favouriteFuelType] != nil } .first } else { pinned = favourites.first { $0.id == chosen.id } ?? favourites .filter { $0.fuel == favouriteFuelType && $0.station.prices[favouriteFuelType] != nil } .min { lhs, rhs in (lhs.station.prices[favouriteFuelType] ?? .infinity) < (rhs.station.prices[favouriteFuelType] ?? .infinity) } } if let pinned { var station = pinned.station if let location, let fetched = await Self.fetchFocused(near: location, fuel: pinned.fuel, radiusKM: radiusKM), let fresh = fetched.first(where: { $0.id == pinned.station.id }) { station = fresh } return FuelPriceEntry( date: Date(), stations: [station], fuel: pinned.fuel, sort: .cheapest, isFavourites: true, location: location, locationSource: source, unit: unit ) } } // List mode (medium): pinned stations for THIS fuel, cheapest-first. // 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. 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 } } ordered = refreshed.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 } } 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(.green) Text(heading) .font(.caption2) .foregroundStyle(.secondary) } Text(station.name) .font(.headline) .lineLimit(1) if let price = station.prices[entry.fuel] { Text(String(format: "£%.3f", price / 100)) .font(.system(size: 26, weight: .bold).monospaced()) .foregroundStyle(.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(.green) 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) Text(String(format: "£%.3f", price / 100)) .font(.caption.weight(.bold).monospaced()) .foregroundStyle(.primary) } } } } .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 } } }