Location-driven results: widget fetches its own location (async, cache fallback), app auto-requests on first launch, Halifax sample stations
This commit is contained in:
@@ -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<FuelPriceEntry>) -> 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user