Widgets configured per-instance via App Intent

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.
This commit is contained in:
FuelBoard Contributor
2026-08-12 11:45:37 +01:00
parent 7d0a3645ee
commit 002d7f63e2
2 changed files with 117 additions and 34 deletions
+68 -30
View File
@@ -1,13 +1,19 @@
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
import AppIntents
// FuelBoard widget cheapest petrol stations near you. // FuelBoard widget petrol stations near you.
// systemMedium: top 3-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 // systemSmall: single station, whole widget opens Maps
// Taps deep-link to Apple Maps directions (http://maps.apple.com/?daddr=). // 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 // 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). // 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). // 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 // 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, // (no permission yet / timeout) it falls back to the app's cached location,
@@ -18,53 +24,62 @@ struct FuelPriceWidget: Widget {
let kind = "FuelPriceWidget" let kind = "FuelPriceWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: FuelPriceTimelineProvider()) { entry in AppIntentConfiguration(
kind: kind,
intent: FuelBoardWidgetConfigurationIntent.self,
provider: FuelPriceTimelineProvider()
) { entry in
FuelPriceWidgetView(entry: entry) FuelPriceWidgetView(entry: entry)
.containerBackground(for: .widget) { .containerBackground(for: .widget) {
Color(.systemBackground) Color(.systemBackground)
} }
} }
.configurationDisplayName("FuelBoard Prices") .configurationDisplayName("FuelBoard Prices")
.description("The cheapest fuel near you. Tap a station for directions.") .description("Fuel prices near you. Configure fuel + sort per widget.")
.supportedFamilies([.systemSmall, .systemMedium]) .supportedFamilies([.systemSmall, .systemMedium])
} }
} }
struct FuelPriceEntry: TimelineEntry { struct FuelPriceEntry: TimelineEntry {
let date: Date let date: Date
let stations: [FuelStation] // already filtered + sorted for the selected fuel let stations: [FuelStation] // already filtered + sorted per config
let fuel: FuelType let fuel: FuelType
let sort: SortMode // per-widget: cheapest | closest
let location: Coordinate? let location: Coordinate?
let locationSource: String // "live" | "cached" | "none" let locationSource: String // "live" | "cached" | "none"
let unit: DistanceUnit // user's display unit for distances let unit: DistanceUnit // user's display unit for distances
} }
struct FuelPriceTimelineProvider: TimelineProvider { struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> FuelPriceEntry { func placeholder(in context: Context) -> FuelPriceEntry {
let fuel = FuelStore.loadSelectedFuel()
let unit = FuelStore.loadDistanceUnit() let unit = FuelStore.loadDistanceUnit()
let sample = SampleFuelProvider.sampleStations let sample = SampleFuelProvider.sampleStations
.filter { $0.prices[fuel] != nil } .filter { $0.prices[.e10] != nil }
.sorted { $0.prices[fuel]! < $1.prices[fuel]! } .sorted { $0.prices[.e10]! < $1.prices[.e10]! }
return FuelPriceEntry(date: Date(), stations: Array(sample.prefix(4)), fuel: fuel, location: nil, locationSource: "none", unit: unit) return FuelPriceEntry(
date: Date(), stations: Array(sample.prefix(4)),
fuel: .e10, sort: .cheapest,
location: nil, locationSource: "none", unit: unit
)
} }
func getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) { func snapshot(for configuration: FuelBoardWidgetConfigurationIntent, in context: Context) async -> FuelPriceEntry {
Task { await makeEntry(configuration: configuration)
completion(await makeEntry())
}
} }
func getTimeline(in context: Context, completion: @escaping (Timeline<FuelPriceEntry>) -> Void) { func timeline(
Task { for configuration: FuelBoardWidgetConfigurationIntent,
let entry = await makeEntry() in context: Context
) async -> Timeline<FuelPriceEntry> {
let entry = await makeEntry(configuration: configuration)
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())! let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
completion(Timeline(entries: [entry], policy: .after(nextRefresh))) return Timeline(entries: [entry], policy: .after(nextRefresh))
}
} }
private func makeEntry() async -> FuelPriceEntry { private func makeEntry(configuration: FuelBoardWidgetConfigurationIntent) async -> FuelPriceEntry {
let fuel = FuelStore.loadSelectedFuel() // 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). // 1) Try a fresh location fix (bounded to a few seconds).
var location = await WidgetLocationFetcher.shared.currentLocation().map { var location = await WidgetLocationFetcher.shared.currentLocation().map {
@@ -86,7 +101,7 @@ struct FuelPriceTimelineProvider: TimelineProvider {
FuelStore.saveLocation(lat: location.lat, lng: location.lng) FuelStore.saveLocation(lat: location.lat, lng: location.lng)
} }
// 3) Load stations, then sort by price (distance tiebreak), scoped to // 3) Load stations, then sort per this widget's config, scoped to
// the chosen search radius so the widget's "cheapest" matches // the chosen search radius so the widget's "cheapest" matches
// the app's list. // the app's list.
var stations = FuelStore.loadStations() var stations = FuelStore.loadStations()
@@ -102,20 +117,37 @@ struct FuelPriceTimelineProvider: TimelineProvider {
} }
let filtered = stations.filter { $0.prices[fuel] != nil } let filtered = stations.filter { $0.prices[fuel] != nil }
let sorted: [FuelStation] let sorted: [FuelStation]
if let location { switch sort {
case .closest:
// Nearest first; price only breaks ties.
sorted = filtered.sorted { lhs, rhs in 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. // Cheapest first; distance only breaks ties.
sorted = filtered.sorted { lhs, rhs in
let lPrice = lhs.prices[fuel]! let lPrice = lhs.prices[fuel]!
let rPrice = rhs.prices[fuel]! let rPrice = rhs.prices[fuel]!
if lPrice != rPrice { return lPrice < rPrice } if lPrice != rPrice { return lPrice < rPrice }
if let location {
return lhs.distanceKM(to: location.lat, lng2: location.lng) < return lhs.distanceKM(to: location.lat, lng2: location.lng) <
rhs.distanceKM(to: location.lat, lng2: location.lng) rhs.distanceKM(to: location.lat, lng2: location.lng)
} }
} else { return false
sorted = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! } }
} }
return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location, locationSource: source, unit: FuelStore.loadDistanceUnit()) return FuelPriceEntry(
date: Date(), stations: Array(sorted.prefix(4)),
fuel: fuel, sort: sort,
location: location, locationSource: source,
unit: FuelStore.loadDistanceUnit()
)
} }
} }
@@ -135,20 +167,26 @@ struct FuelPriceWidgetView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} else if family == .systemSmall { } else if family == .systemSmall {
singleCheapest singleStation
} else { } else {
stationList stationList
} }
} }
} }
private var singleCheapest: some View { 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] let station = entry.stations[0]
return VStack(alignment: .leading, spacing: 6) { return VStack(alignment: .leading, spacing: 6) {
HStack { HStack {
Image(systemName: "fuelpump.fill") Image(systemName: "fuelpump.fill")
.foregroundStyle(.green) .foregroundStyle(.green)
Text("Cheapest \(entry.fuel.displayName)") Text(heading)
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -180,7 +218,7 @@ struct FuelPriceWidgetView: View {
HStack { HStack {
Image(systemName: "fuelpump.fill") Image(systemName: "fuelpump.fill")
.foregroundStyle(.green) .foregroundStyle(.green)
Text("Cheapest \(entry.fuel.displayName)") Text(heading)
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer()
+45
View File
@@ -0,0 +1,45 @@
import AppIntents
import Foundation
// Per-widget configuration intent (iOS 17+). Every widget instance carries
// its OWN fuel + sort choices, edited directly on the home screen (long-press
// Edit Widget), independent of what the app has selected. This is what
// allows several FuelBoard widgets to show different fuels/orderings side by
// side.
enum WidgetFuel: String, AppEnum, CaseIterable, Codable {
case e10
case e5
case diesel
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
static var caseDisplayRepresentations: [WidgetFuel: DisplayRepresentation] = [
.e10: "Unleaded",
.e5: "Premium",
.diesel: "Diesel",
]
}
enum WidgetSort: String, AppEnum, CaseIterable, Codable {
case cheapest
case closest
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Sort by"
static var caseDisplayRepresentations: [WidgetSort: DisplayRepresentation] = [
.cheapest: "Cheapest",
.closest: "Closest",
]
}
struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Fuel & Sort"
static var description = IntentDescription("Which fuel and ordering this widget shows.")
@Parameter(title: "Fuel", default: .e10)
var fuel: WidgetFuel
@Parameter(title: "Sort by", default: .cheapest)
var sort: WidgetSort
}