From 1bdef59d789fbe0f551283126fd11f5e9a28d8a5 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 13 Aug 2026 15:49:18 +0100 Subject: [PATCH] Small widget: Favourites gains a picker to pin which favourite to show (separate FuelPriceWidgetSmall kind + AppEntity picker; medium keeps the list, no picker). Backlog: dual-unit widget picker labels --- BACKLOG.md | 5 + FuelBoardWidgets/FuelBoardWidgetsBundle.swift | 1 + FuelBoardWidgets/FuelPriceWidget.swift | 83 +++++++++++--- FuelBoardWidgets/WidgetConfigIntent.swift | 106 +++++++++++++++++- 4 files changed, 176 insertions(+), 19 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index b36f327..7c58f55 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -47,6 +47,11 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED. - [ ] **Watch app** — glanceable cheapest-price complication (WatchKit + shared app-group store; watch target would need to be added to the project). - [ ] **App Clip** — 10 MB budget for "find cheapest fuel nearby" without install. +- [ ] **Widget picker: dual-unit distance labels** — the Edit-Widget Distance row + label is cached by the system at sheet-render time and only re-resolves on + picker interaction, so "mirror the app unit" goes stale. Fix: label options + with both units ("5 miles (8 km)") so nothing can go stale; widget rows keep + mirroring the live unit. ## Done (recent) diff --git a/FuelBoardWidgets/FuelBoardWidgetsBundle.swift b/FuelBoardWidgets/FuelBoardWidgetsBundle.swift index aa64666..78a2a38 100644 --- a/FuelBoardWidgets/FuelBoardWidgetsBundle.swift +++ b/FuelBoardWidgets/FuelBoardWidgetsBundle.swift @@ -5,6 +5,7 @@ import SwiftUI struct FuelBoardWidgetsBundle: WidgetBundle { var body: some Widget { FuelPriceWidget() + FuelPriceSmallWidget() FuelBoardLiveActivity() } } diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index f4d6fc3..7501eb7 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -3,8 +3,12 @@ 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 +// FuelPriceWidget (medium): top 3 stations with price + distance, each row +// opens Maps. Per-widget fuel + sort (+ distance for Cheapest). +// FuelPriceSmallWidget (small): single station, whole widget opens Maps. +// Same knobs, plus a Favourite picker when Sort = Favourites (a small face +// shows ONE station, so you choose which favourite to pin; the medium face +// lists all favourites and has no picker). // Taps deep-link to Apple Maps directions (maps://?daddr=). On the Home // Screen the system either opens Maps directly or delivers the URL to // FuelBoard, whose onOpenURL forwards it (and also still handles legacy @@ -39,15 +43,10 @@ struct FuelPriceWidget: Widget { // widget taps there can never launch Maps (Apple only allows a widget // to launch its own app in CarPlay, and only CarPlay-enabled apps). // Disfavored = read-only in the car, no dead interaction. - baseConfiguration() - .disfavoredLocations([.carPlay], for: [.systemSmall]) - } - - private func baseConfiguration() -> some WidgetConfiguration { AppIntentConfiguration( kind: kind, intent: FuelBoardWidgetConfigurationIntent.self, - provider: FuelPriceTimelineProvider() + provider: FuelPriceTimelineProvider() ) { entry in FuelPriceWidgetView(entry: entry) .containerBackground(for: .widget) { @@ -56,7 +55,33 @@ struct FuelPriceWidget: Widget { } .configurationDisplayName("FuelBoard Prices") .description("Fuel prices near you. Configure fuel + sort per widget.") - .supportedFamilies([.systemSmall, .systemMedium]) + .supportedFamilies([.systemMedium]) + .disfavoredLocations([.carPlay], for: [.systemMedium]) + } +} + +// Small widget — exactly ONE station. Favourites sort gains a picker to choose +// WHICH favourite to pin, because a small face can show only one (the medium +// face lists them all, so the picker exists only here). Separate kind keeps +// the extra parameter off the list widget's Edit-Widget sheet. +struct FuelPriceSmallWidget: Widget { + let kind = "FuelPriceWidgetSmall" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + intent: FuelBoardSmallWidgetConfigurationIntent.self, + provider: FuelPriceTimelineProvider() + ) { entry in + FuelPriceWidgetView(entry: entry) + .containerBackground(for: .widget) { + Color(.systemBackground) + } + } + .configurationDisplayName("FuelBoard Favourite") + .description("One pinned favourite station with its price.") + .supportedFamilies([.systemSmall]) + .disfavoredLocations([.carPlay], for: [.systemSmall]) } } @@ -71,7 +96,9 @@ struct FuelPriceEntry: TimelineEntry { let unit: DistanceUnit // user's display unit for distances } -struct FuelPriceTimelineProvider: AppIntentTimelineProvider { +struct FuelPriceTimelineProvider: AppIntentTimelineProvider { + typealias Intent = Configuration + func placeholder(in context: Context) -> FuelPriceEntry { let unit = FuelStore.loadDistanceUnit() let sample = SampleFuelProvider.sampleStations @@ -84,12 +111,12 @@ struct FuelPriceTimelineProvider: AppIntentTimelineProvider { ) } - func snapshot(for configuration: FuelBoardWidgetConfigurationIntent, in context: Context) async -> FuelPriceEntry { + func snapshot(for configuration: Configuration, in context: Context) async -> FuelPriceEntry { await makeEntry(configuration: configuration) } func timeline( - for configuration: FuelBoardWidgetConfigurationIntent, + for configuration: Configuration, in context: Context ) async -> Timeline { let entry = await makeEntry(configuration: configuration) @@ -99,7 +126,7 @@ struct FuelPriceTimelineProvider: AppIntentTimelineProvider { return Timeline(entries: [entry], policy: .after(nextRefresh)) } - private func makeEntry(configuration: FuelBoardWidgetConfigurationIntent) async -> FuelPriceEntry { + private func makeEntry(configuration: Configuration) async -> FuelPriceEntry { // Per-widget config: fuel + sort + distance come from THIS widget instance. let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10 let isFavourites = configuration.sort == .favourites @@ -131,20 +158,40 @@ struct FuelPriceTimelineProvider: AppIntentTimelineProvider { // 3) Load + order stations per widget mode. let ordered: [FuelStation] if isFavourites { - // Favourites: pinned stations for THIS fuel, cheapest-first. Not - // radius-bound — a favourite in Edinburgh shows on a widget in + let favourites = FuelStore.loadFavourites() + .filter { $0.station.prices[$0.fuel] != nil } + + // Small widget, pinned favourite: exactly ONE station, its own + // fuel, no radius. Freshen its price when a focused fetch covers it. + if let chosen = configuration.favouriteChoice, + let entry = favourites.first(where: { $0.id == chosen.id }) { + var station = entry.station + if let location, + let fetched = await Self.fetchFocused(near: location, fuel: entry.fuel, radiusKM: radiusKM), + let fresh = fetched.first(where: { $0.id == entry.station.id }) { + station = fresh + } + return FuelPriceEntry( + date: Date(), stations: [station], + fuel: entry.fuel, sort: .cheapest, isFavourites: true, + location: location, locationSource: source, unit: unit + ) + } + + // List mode (medium): pinned stations for THIS fuel, cheapest-first. + // Not radius-bound — a favourite in Edinburgh shows on a widget in // London. Prices come from the keychain snapshot (the app // refreshes favourite prices into keychain after every fetch), or // fresher from a focused relay fetch when a favourite happens to // be in range. - let favourites = FuelStore.loadFavourites() + let fuelFavourites = favourites .filter { $0.fuel == fuel && $0.station.prices[fuel] != nil } .map(\.station) - var refreshed: [FuelStation] = favourites + var refreshed: [FuelStation] = fuelFavourites if let location, let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) { let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) }) - refreshed = favourites.map { freshByID[$0.id] ?? $0 } + refreshed = fuelFavourites.map { freshByID[$0.id] ?? $0 } } ordered = refreshed.sorted { lhs, rhs in let lPrice = lhs.prices[fuel]! diff --git a/FuelBoardWidgets/WidgetConfigIntent.swift b/FuelBoardWidgets/WidgetConfigIntent.swift index 0a7e03c..73b21e6 100644 --- a/FuelBoardWidgets/WidgetConfigIntent.swift +++ b/FuelBoardWidgets/WidgetConfigIntent.swift @@ -68,7 +68,61 @@ struct WidgetDistanceQuery: EntityQuery { } } -struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent { +// A pinned favourite station, selectable on SMALL widgets (which show a single +// station). Reuses FavouriteEntry's id scheme ("fuel|stationID") so the +// provider can resolve the choice straight back to a stored favourite. Only +// surfaced when Sort = Favourites. +struct WidgetFavourite: AppEntity, Identifiable, Hashable, Codable { + let fuel: FuelType + let stationID: String + + var id: String { "\(fuel.rawValue)|\(stationID)" } + + var displayRepresentation: DisplayRepresentation { + let favourites = FuelStore.loadFavourites() + guard let entry = favourites.first(where: { $0.id == id }) else { + return DisplayRepresentation(stringLiteral: "Favourite") + } + let name = entry.station.name + // Same station pinned for another fuel? Disambiguate with the fuel tag. + let hasOtherFuel = favourites.contains { + $0.station.id == entry.station.id && $0.fuel != entry.fuel + } + return DisplayRepresentation( + stringLiteral: hasOtherFuel ? "\(name) · \(entry.fuel.displayName)" : name + ) + } + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Favourite" + static var defaultQuery = WidgetFavouriteQuery() +} + +struct WidgetFavouriteQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [WidgetFavourite] { + identifiers.map { id in + let parts = id.split(separator: "|", maxSplits: 1) + let fuel = FuelType(rawValue: String(parts.first ?? "")) ?? .e10 + return WidgetFavourite(fuel: fuel, stationID: String(parts.last ?? "")) + } + } + + func suggestedEntities() async throws -> [WidgetFavourite] { + FuelStore.loadFavourites().map { WidgetFavourite(fuel: $0.fuel, stationID: $0.station.id) } + } +} + +// The knobs both the medium-list and small-single widget intents expose, so +// one timeline provider can drive either. +protocol WidgetConfigValues { + var fuel: WidgetFuel { get } + var sort: WidgetSort { get } + var distance: WidgetDistance { get } + /// The pinned favourite (small widget, Favourites sort) — nil when the + /// widget lists favourites instead (medium). + var favouriteChoice: WidgetFavourite? { get } +} + +struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConfigValues { static var title: LocalizedStringResource = "Fuel & Sort" static var description = IntentDescription("Which fuel, ordering and search radius this widget shows.") @@ -81,6 +135,10 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent { @Parameter(title: "Distance", default: WidgetDistance(id: 5)) var distance: WidgetDistance + // Medium/list widgets show ALL favourites for the chosen fuel — no + // pinned-favourite knob. + var favouriteChoice: WidgetFavourite? { nil } + /// Edit-Widget UI: the Distance picker only makes sense for Cheapest /// ordering — Closest is inherently "nearest within range" and Favourites /// is not radius-bound. Show it for Cheapest only; hide for both others. @@ -106,3 +164,49 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent { } } } + +// SMALL widget intent: same knobs as the list widget, PLUS a pinned-favourite +// picker for Favourites sort (a small widget shows exactly ONE station, so the +// user chooses which favourite). In Favourites mode the Fuel + Distance rows +// are hidden — the chosen favourite carries its own fuel, and there is no +// radius when a station is pinned. +struct FuelBoardSmallWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConfigValues { + static var title: LocalizedStringResource = "Fuel & Sort" + static var description = IntentDescription("Which fuel, ordering, radius and pinned favourite this small widget shows.") + + @Parameter(title: "Fuel", default: .e10) + var fuel: WidgetFuel + + @Parameter(title: "Sort by", default: .cheapest) + var sort: WidgetSort + + @Parameter(title: "Distance", default: WidgetDistance(id: 5)) + var distance: WidgetDistance + + @Parameter(title: "Favourite", default: WidgetFavourite(fuel: .e10, stationID: "")) + var favourite: WidgetFavourite + + var favouriteChoice: WidgetFavourite? { favourite } + + static var parameterSummary: some ParameterSummary { + When(\.$sort, .equalTo, WidgetSort.favourites) { + Summary("Show \(\.$favourite)") { + \.$sort + \.$favourite + } + } otherwise: { + When(\.$sort, .equalTo, WidgetSort.cheapest) { + Summary("Show \(\.$fuel) by \(\.$sort) within \(\.$distance)") { + \.$fuel + \.$sort + \.$distance + } + } otherwise: { + Summary("Show \(\.$fuel) by \(\.$sort)") { + \.$fuel + \.$sort + } + } + } + } +}