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 (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). // // Each widget instance is configured INDEPENDENTLY via its own App Intent // (long-press → Edit Widget): fuel type (Unleaded/Premium/Diesel) and sort // (Cheapest/Closest). 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 { 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 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(4)), fuel: .e10, sort: .cheapest, 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) let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())! return Timeline(entries: [entry], policy: .after(nextRefresh)) } private func makeEntry(configuration: FuelBoardWidgetConfigurationIntent) async -> FuelPriceEntry { // Per-widget config: fuel + sort come from THIS widget instance. let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10 let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest // 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 per this widget's config, scoped to // the chosen search radius so the widget's "cheapest" matches // the app's list. var stations = FuelStore.loadStations() if stations.isEmpty { stations = SampleFuelProvider.sampleStations } let unit = FuelStore.loadDistanceUnit() let radiusKM = unit.toKM(Double(FuelStore.loadStationLimit())) // chosen units → 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] switch sort { case .closest: // Nearest first; price only breaks ties. sorted = 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. sorted = 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(sorted.prefix(4)), fuel: fuel, sort: sort, location: location, locationSource: source, unit: FuelStore.loadDistanceUnit() ) } } 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 { singleStation } else { stationList } } } private var heading: String { 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.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(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.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(heading) .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(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: "%.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 } } }