import WidgetKit import SwiftUI import AppIntents // FuelBoard widget — petrol stations near you. // systemMedium: top 3-4 stations with price + distance, each row opens Maps // systemSmall: single station, whole widget opens Maps // 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. baseConfiguration() .disfavoredLocations([.carPlay], for: [.systemSmall]) } private func baseConfiguration() -> some WidgetConfiguration { 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]) } } 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 { 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: FuelBoardWidgetConfigurationIntent, in context: Context) async -> FuelPriceEntry { await makeEntry(configuration: configuration) } func timeline( for configuration: FuelBoardWidgetConfigurationIntent, 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: FuelBoardWidgetConfigurationIntent) 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 { // Favourites: 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 favourites = FuelStore.loadFavourites() .filter { $0.fuel == fuel && $0.station.prices[fuel] != nil } .map(\.station) var refreshed: [FuelStation] = favourites if let location, let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) { let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) }) refreshed = favourites.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 } } 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 } } } 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 } } }