import WidgetKit import SwiftUI // FuelBoard widget — cheapest petrol stations near you. // systemMedium: top 3-4 stations with price + distance, each row opens Maps // systemSmall: single cheapest station, whole widget opens Maps // Taps deep-link to Apple Maps directions (http://maps.apple.com/?daddr=). // Note: on the home screen Link opens Maps directly. Inside CarPlay the widget // renders but can't launch Maps (widgets can only launch their own CarPlay app). // // 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 { StaticConfiguration(kind: kind, provider: FuelPriceTimelineProvider()) { entry in FuelPriceWidgetView(entry: entry) .containerBackground(for: .widget) { Color(.systemBackground) } } .configurationDisplayName("FuelBoard Prices") .description("The cheapest fuel near you. Tap a station for directions.") .supportedFamilies([.systemSmall, .systemMedium]) } } struct FuelPriceEntry: TimelineEntry { let date: Date let stations: [FuelStation] // already filtered + sorted for the selected fuel let fuel: FuelType let location: Coordinate? let locationSource: String // "live" | "cached" | "none" } struct FuelPriceTimelineProvider: TimelineProvider { func placeholder(in context: Context) -> FuelPriceEntry { let fuel = FuelStore.loadSelectedFuel() let sample = SampleFuelProvider.sampleStations .filter { $0.prices[fuel] != nil } .sorted { $0.prices[fuel]! < $1.prices[fuel]! } return FuelPriceEntry(date: Date(), stations: Array(sample.prefix(4)), fuel: fuel, location: nil, locationSource: "none") } func getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) { Task { completion(await makeEntry()) } } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { Task { let entry = await makeEntry() let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())! completion(Timeline(entries: [entry], policy: .after(nextRefresh))) } } private func makeEntry() async -> FuelPriceEntry { let fuel = FuelStore.loadSelectedFuel() // 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) } // 3) Load stations, then sort by price (distance tiebreak), scoped to // the chosen search radius (miles) so the widget's "cheapest" matches // the app's list. var stations = FuelStore.loadStations() if stations.isEmpty { stations = SampleFuelProvider.sampleStations } let radiusKM = Double(FuelStore.loadStationLimit()) * 1.60934 // miles → km 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 } let sorted: [FuelStation] if let location { sorted = filtered.sorted { lhs, rhs in // Cheapest first; distance only breaks ties. let lPrice = lhs.prices[fuel]! let rPrice = rhs.prices[fuel]! if lPrice != rPrice { return lPrice < rPrice } return lhs.distanceKM(to: location.lat, lng2: location.lng) < rhs.distanceKM(to: location.lat, lng2: location.lng) } } else { sorted = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! } } return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location, locationSource: source) } } 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: "fuelpump") .font(.title2) .foregroundStyle(.secondary) Text("No \(entry.fuel.displayName) stations") .font(.caption2) .foregroundStyle(.secondary) } } else if family == .systemSmall { singleCheapest } else { stationList } } } private var singleCheapest: some View { let station = entry.stations[0] return VStack(alignment: .leading, spacing: 6) { HStack { Image(systemName: "fuelpump.fill") .foregroundStyle(.green) Text("Cheapest \(entry.fuel.displayName)") .font(.caption2) .foregroundStyle(.secondary) } Text(station.brand) .font(.headline) .lineLimit(1) if let price = station.prices[entry.fuel] { Text(String(format: "%.1fp", price)) .font(.system(size: 28, weight: .bold)) .foregroundStyle(.green) } if let location = entry.location { Text(String(format: "%.1f mi away", station.distanceKM(to: location.lat, lng2: location.lng) * 0.621371)) .font(.caption2) .foregroundStyle(.secondary) } else { Text("Tap for directions") .font(.caption2) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .widgetURL(station.mapsDirectionsURL) } 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("Cheapest \(entry.fuel.displayName)") .font(.caption2) .foregroundStyle(.secondary) Spacer() Text(entry.locationSource == "none" ? "by price" : "near you") .font(.caption2) .foregroundStyle(.secondary) } ForEach(entry.stations.prefix(3)) { station in Link(destination: station.mapsDirectionsURL ?? URL(string: "http://maps.apple.com")!) { HStack(spacing: 8) { Text(station.brand) .font(.caption.weight(.semibold)) .lineLimit(1) if let location = entry.location { Text(String(format: "%.1f km", 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: "%.1fp", price)) .font(.caption.weight(.bold)) .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 } } }