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:
@@ -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
|
||||||
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
|
) async -> Timeline<FuelPriceEntry> {
|
||||||
completion(Timeline(entries: [entry], policy: .after(nextRefresh)))
|
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 {
|
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
|
||||||
|
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
|
sorted = filtered.sorted { lhs, rhs in
|
||||||
// Cheapest first; distance only breaks ties.
|
|
||||||
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 }
|
||||||
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
if let location {
|
||||||
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
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)
|
.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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user