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
+72 -34
View File
@@ -1,13 +1,19 @@
import WidgetKit
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
// 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=).
// 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,
@@ -18,53 +24,62 @@ struct FuelPriceWidget: Widget {
let kind = "FuelPriceWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: FuelPriceTimelineProvider()) { entry in
AppIntentConfiguration(
kind: kind,
intent: FuelBoardWidgetConfigurationIntent.self,
provider: FuelPriceTimelineProvider()
) { entry in
FuelPriceWidgetView(entry: entry)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
}
.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])
}
}
struct FuelPriceEntry: TimelineEntry {
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 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: TimelineProvider {
struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> FuelPriceEntry {
let fuel = FuelStore.loadSelectedFuel()
let unit = FuelStore.loadDistanceUnit()
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, locationSource: "none", unit: unit)
.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 getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) {
Task {
completion(await makeEntry())
}
func snapshot(for configuration: FuelBoardWidgetConfigurationIntent, in context: Context) async -> FuelPriceEntry {
await makeEntry(configuration: configuration)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<FuelPriceEntry>) -> Void) {
Task {
let entry = await makeEntry()
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
completion(Timeline(entries: [entry], policy: .after(nextRefresh)))
}
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() async -> FuelPriceEntry {
let fuel = FuelStore.loadSelectedFuel()
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 {
@@ -86,7 +101,7 @@ struct FuelPriceTimelineProvider: TimelineProvider {
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 app's list.
var stations = FuelStore.loadStations()
@@ -102,20 +117,37 @@ struct FuelPriceTimelineProvider: TimelineProvider {
}
let filtered = stations.filter { $0.prices[fuel] != nil }
let sorted: [FuelStation]
if let location {
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
// Cheapest first; distance only breaks ties.
let lPrice = lhs.prices[fuel]!
let rPrice = rhs.prices[fuel]!
if lPrice != rPrice { return lPrice < rPrice }
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
rhs.distanceKM(to: location.lat, lng2: location.lng)
if let location {
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
rhs.distanceKM(to: location.lat, lng2: location.lng)
}
return false
}
} else {
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)
}
} else if family == .systemSmall {
singleCheapest
singleStation
} else {
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]
return VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(.green)
Text("Cheapest \(entry.fuel.displayName)")
Text(heading)
.font(.caption2)
.foregroundStyle(.secondary)
}
@@ -180,7 +218,7 @@ struct FuelPriceWidgetView: View {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(.green)
Text("Cheapest \(entry.fuel.displayName)")
Text(heading)
.font(.caption2)
.foregroundStyle(.secondary)
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
}