The widget previously read stations only from the app-group cache, which SideStore free accounts don't provision — the widget process saw empty data and silently fell back to sample stations (stuck on Morrisons). Now the widget makes its own focused relay fetch around its location (radius + limit 500, 5s timeout) whenever it has a fix, falling back to the shared cache then samples. Widget data now reflects the relay independently of the app. 35 tests pass.
309 lines
13 KiB
Swift
309 lines
13 KiB
Swift
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<FuelPriceEntry> {
|
|
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. 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 {
|
|
let radiusKM = unitToKM()
|
|
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 }
|
|
let unit = FuelStore.loadDistanceUnit()
|
|
let radiusKM = unitToKM()
|
|
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()
|
|
)
|
|
}
|
|
|
|
/// The search radius in km for the widget's own focused relay fetch.
|
|
private func unitToKM() -> Double {
|
|
FuelStore.loadDistanceUnit().toKM(Double(FuelStore.loadStationLimit()))
|
|
}
|
|
|
|
/// 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: "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
|
|
}
|
|
}
|
|
}
|