diff --git a/Config/Widget-Info.plist b/Config/Widget-Info.plist index 106f2cd..56b6bbb 100644 --- a/Config/Widget-Info.plist +++ b/Config/Widget-Info.plist @@ -20,6 +20,8 @@ 1.0 CFBundleVersion 1 + NSLocationWhenInUseUsageDescription + FuelBoard uses your location to show the cheapest nearby petrol stations. NSExtension NSExtensionPointIdentifier diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 26eedcc..178f01b 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -2,11 +2,6 @@ import SwiftUI import CoreLocation import WidgetKit -struct Coordinate: Equatable { - let lat: Double - let lng: Double -} - struct ContentView: View { @Environment(\.scenePhase) private var scenePhase @@ -111,6 +106,11 @@ struct ContentView: View { } } .onAppear { + // Auto-request location on first launch so the widget gets a + // fresh fix without the user hunting for the button. + if FuelStore.loadLocation() == nil { + locationManager.requestUpdate() + } Task { await refresh() } } .onChange(of: scenePhase) { _, newPhase in diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 9fc72d8..67fd324 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -2,17 +2,23 @@ import WidgetKit import SwiftUI // FuelBoard widget — cheapest petrol stations near you. -// systemMedium: top 4 stations with price + distance, each row opens Maps +// 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: FuelPriceProvider2()) { entry in + StaticConfiguration(kind: kind, provider: FuelPriceTimelineProvider()) { entry in FuelPriceWidgetView(entry: entry) .containerBackground(for: .widget) { Color(.systemBackground) @@ -28,31 +34,57 @@ struct FuelPriceEntry: TimelineEntry { let date: Date let stations: [FuelStation] // already filtered + sorted for the selected fuel let fuel: FuelType - let location: (lat: Double, lng: Double)? + let location: Coordinate? + let locationSource: String // "live" | "cached" | "none" } -struct FuelPriceProvider2: TimelineProvider { +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) + return FuelPriceEntry(date: Date(), stations: Array(sample.prefix(4)), fuel: fuel, location: nil, locationSource: "none") } func getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) { - completion(makeEntry()) + Task { + completion(await makeEntry()) + } } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let entry = makeEntry() - let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())! - completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + 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() -> FuelPriceEntry { + private func makeEntry() async -> FuelPriceEntry { let fuel = FuelStore.loadSelectedFuel() - let location = FuelStore.loadLocation().map { (lat: $0.lat, lng: $0.lng) } + + // 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 distance-weighted score. var stations = FuelStore.loadStations() if stations.isEmpty { stations = SampleFuelProvider.sampleStations } let filtered = stations.filter { $0.prices[fuel] != nil } @@ -66,11 +98,13 @@ struct FuelPriceProvider2: TimelineProvider { } else { sorted = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! } } - return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location) + + 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 { @@ -84,7 +118,7 @@ struct FuelPriceWidgetView: View { .font(.caption2) .foregroundStyle(.secondary) } - } else if entry.stations.count == 1 || UIDevice.current.userInterfaceIdiom == .pad { + } else if family == .systemSmall { singleCheapest } else { stationList @@ -114,10 +148,11 @@ struct FuelPriceWidgetView: View { Text(String(format: "%.1f km away", station.distanceKM(to: location.lat, lng2: location.lng))) .font(.caption2) .foregroundStyle(.secondary) + } else { + Text("Tap for directions") + .font(.caption2) + .foregroundStyle(.secondary) } - Text("Tap for directions") - .font(.caption2) - .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .widgetURL(station.mapsDirectionsURL) @@ -132,7 +167,7 @@ struct FuelPriceWidgetView: View { .font(.caption2) .foregroundStyle(.secondary) Spacer() - Text("Tap → Maps") + Text(entry.locationSource == "none" ? "by price" : "near you") .font(.caption2) .foregroundStyle(.secondary) } diff --git a/Shared/FuelPriceProvider.swift b/Shared/FuelPriceProvider.swift index fe4bdd8..60c31c2 100644 --- a/Shared/FuelPriceProvider.swift +++ b/Shared/FuelPriceProvider.swift @@ -23,8 +23,9 @@ enum FuelPriceProvider { // MARK: - Sample provider (default for the scaffold) -/// Ships realistic stations around Cambridge (52.2053, 0.1218) so the app and -/// widget render meaningful data with zero setup. Prices in pence/litre. +/// Ships realistic stations around Halifax, West Yorkshire (53.7270, -1.8575) +/// so the app and widget render meaningful distances for the user with zero +/// setup. Prices in pence/litre. struct SampleFuelProvider: FuelPriceProviding { func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType) async throws -> [FuelStation] { try await Task.sleep(nanoseconds: 300_000_000) // simulate fetch @@ -32,45 +33,45 @@ struct SampleFuelProvider: FuelPriceProviding { } static let sampleStations: [FuelStation] = [ - FuelStation(id: "s1", name: "Shell Cambridge Retail Park", brand: "Shell", - address: "12 Retail Park Way", postcode: "CB1 3EW", - lat: 52.1955, lng: 0.1380, - prices: [.e10: 142.9, .e5: 149.9, .diesel: 148.9], - priceUpdated: nil), - FuelStation(id: "s2", name: "Tesco Express Hills Road", brand: "Tesco", - address: "245 Hills Road", postcode: "CB2 8RP", - lat: 52.1809, lng: 0.1398, + FuelStation(id: "h1", name: "Tesco Express Halifax", brand: "Tesco", + address: "12 Clare Road", postcode: "HX1 2HX", + lat: 53.7200, lng: -1.8630, prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9], priceUpdated: nil), - FuelStation(id: "s3", name: "BP Milton Road", brand: "BP", - address: "161 Milton Road", postcode: "CB4 1XE", - lat: 52.2160, lng: 0.1410, - prices: [.e10: 140.9, .diesel: 147.9], + FuelStation(id: "h2", name: "Shell Skircoat Road", brand: "Shell", + address: "245 Skircoat Road", postcode: "HX3 0HP", + lat: 53.7120, lng: -1.8710, + prices: [.e10: 142.9, .e5: 149.9, .diesel: 148.9], priceUpdated: nil), - FuelStation(id: "s4", name: "Morrisons Newmarket Road", brand: "Morrisons", - address: "Newmarket Road", postcode: "CB5 8AA", - lat: 52.2164, lng: 0.1599, + FuelStation(id: "h3", name: "Morrisons Halifax", brand: "Morrisons", + address: "Haugh Shaw Road", postcode: "HX1 3TU", + lat: 53.7265, lng: -1.8580, prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil), - FuelStation(id: "s5", name: "Sainsbury's Coldhams Lane", brand: "Sainsbury's", - address: "Coldhams Lane", postcode: "CB1 3HY", - lat: 52.2025, lng: 0.1589, - prices: [.e10: 139.9, .e5: 147.9, .diesel: 146.9], + FuelStation(id: "h4", name: "BP Queensbury", brand: "BP", + address: "316 High Street, Queensbury", postcode: "BD13 2NB", + lat: 53.7550, lng: -1.8450, + prices: [.e10: 140.9, .diesel: 147.9], priceUpdated: nil), - FuelStation(id: "s6", name: "Esso Cherry Hinton", brand: "Esso", - address: "Cherry Hinton Road", postcode: "CB1 9AP", - lat: 52.1854, lng: 0.1657, + FuelStation(id: "h5", name: "Asda Halifax", brand: "Asda", + address: "Ovenden Way", postcode: "HX2 8DD", + lat: 53.7340, lng: -1.8890, + prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9], + priceUpdated: nil), + FuelStation(id: "h6", name: "Esso Sowerby Bridge", brand: "Esso", + address: "Wharf Street", postcode: "HX6 2AH", + lat: 53.7080, lng: -1.9080, prices: [.e10: 141.9, .e5: 148.9, .diesel: 147.9], priceUpdated: nil), - FuelStation(id: "s7", name: "Gulf Fen Road", brand: "Gulf", - address: "Fen Road", postcode: "CB4 1UN", - lat: 52.2201, lng: 0.1470, - prices: [.e10: 143.9, .e5: 151.9, .diesel: 149.9], + FuelStation(id: "h7", name: "Sainsbury's Elland", brand: "Sainsbury's", + address: "Southgate", postcode: "HX5 0PA", + lat: 53.6860, lng: -1.8380, + prices: [.e10: 139.9, .e5: 147.9, .diesel: 146.9], priceUpdated: nil), - FuelStation(id: "s8", name: "Asda Beehive Centre", brand: "Asda", - address: "Coldhams Lane", postcode: "CB1 3ER", - lat: 52.1995, lng: 0.1641, - prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9], + FuelStation(id: "h8", name: "Gulf Brighouse", brand: "Gulf", + address: "Bradford Road", postcode: "HD6 1RW", + lat: 53.7000, lng: -1.7850, + prices: [.e10: 143.9, .e5: 151.9, .diesel: 149.9], priceUpdated: nil), ] } diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 26dc89a..156e6f4 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -12,6 +12,11 @@ import Security // MARK: - Fuel types +struct Coordinate: Equatable, Codable { + let lat: Double + let lng: Double +} + enum FuelType: String, Codable, CaseIterable, Identifiable { case e10 // Unleaded 95 (E10) case e5 // Premium 97/98 (E5) @@ -94,11 +99,11 @@ struct FuelStore { // MARK: Last known location ("lat,lng,unixTime") - static func loadLocation() -> (lat: Double, lng: Double, date: Date)? { + static func loadLocation() -> Coordinate? { let raw = loadString(service: locationKey) let parts = raw?.split(separator: ",").compactMap { Double($0) } guard let parts, parts.count == 3 else { return nil } - return (parts[0], parts[1], Date(timeIntervalSince1970: parts[2])) + return Coordinate(lat: parts[0], lng: parts[1]) } static func saveLocation(lat: Double, lng: Double, date: Date = Date()) { diff --git a/Shared/WidgetLocationFetcher.swift b/Shared/WidgetLocationFetcher.swift new file mode 100644 index 0000000..dec8cd1 --- /dev/null +++ b/Shared/WidgetLocationFetcher.swift @@ -0,0 +1,72 @@ +// WidgetLocationFetcher.swift — one-shot location for the WIDGET EXTENSION. +// +// WidgetKit providers run in a short budget, so this fetcher is deliberately +// timeout-bounded: it requests a single location and gives up after a few +// seconds, falling back to whatever the caller already has cached. +// +// Location permission is shared with the containing app (same TCC identity), +// so if the user granted "While Using" in FuelBoard, the widget can use +// location directly — no extra prompt in the car. + +import Foundation +import CoreLocation + +@MainActor +final class WidgetLocationFetcher: NSObject, CLLocationManagerDelegate { + static let shared = WidgetLocationFetcher() + + private let manager = CLLocationManager() + private var continuation: CheckedContinuation? + private var timeoutTask: Task? + + private override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + + /// Requests the current location. Returns nil on denial, timeout, or + /// no-permission — callers should fall back to cached data. + func currentLocation(timeout: TimeInterval = 4) async -> CLLocation? { + guard CLLocationManager.locationServicesEnabled() else { return nil } + + switch manager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + break + case .notDetermined: + // Widget extensions share the app's TCC identity; if the app was + // never opened, the system can't prompt from here — fall back. + return nil + case .denied, .restricted: + return nil + @unknown default: + return nil + } + + return await withCheckedContinuation { cont in + continuation = cont + manager.requestLocation() + timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + self?.continuation?.resume(returning: nil) + self?.continuation = nil + } + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + MainActor.assumeIsolated { + timeoutTask?.cancel() + continuation?.resume(returning: locations.last) + continuation = nil + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + MainActor.assumeIsolated { + timeoutTask?.cancel() + continuation?.resume(returning: nil) + continuation = nil + } + } +}