From 002d7f63e237ad183e752476c80f011123d7e8a1 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 12 Aug 2026 11:45:37 +0100 Subject: [PATCH] Widgets configured per-instance via App Intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- FuelBoardWidgets/FuelPriceWidget.swift | 106 +++++++++++++++------- FuelBoardWidgets/WidgetConfigIntent.swift | 45 +++++++++ 2 files changed, 117 insertions(+), 34 deletions(-) create mode 100644 FuelBoardWidgets/WidgetConfigIntent.swift diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index c7d56a0..f89ad57 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -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) -> 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 { + 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() diff --git a/FuelBoardWidgets/WidgetConfigIntent.swift b/FuelBoardWidgets/WidgetConfigIntent.swift new file mode 100644 index 0000000..68caf67 --- /dev/null +++ b/FuelBoardWidgets/WidgetConfigIntent.swift @@ -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 +}