FuelPriceWidget now uses AppIntentConfiguration with a per-widget FuelBoardWidgetConfigurationIntent (fuel + sort). Each widget placed on the home screen carries its OWN fuel type (Unleaded/Premium/Diesel) and ordering (Cheapest/Closest), edited by long-press -> Edit Widget — independent of the app's selection, so multiple widgets can show different data side by side. Widget no longer reads the app's selected fuel. 35 tests pass.
269 lines
11 KiB
Swift
269 lines
11 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, 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
|
|
}
|
|
}
|
|
}
|