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
This commit is contained in:
@@ -47,6 +47,11 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
- [ ] **Watch app** — glanceable cheapest-price complication (WatchKit + shared
|
- [ ] **Watch app** — glanceable cheapest-price complication (WatchKit + shared
|
||||||
app-group store; watch target would need to be added to the project).
|
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.
|
- [ ] **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)
|
## Done (recent)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SwiftUI
|
|||||||
struct FuelBoardWidgetsBundle: WidgetBundle {
|
struct FuelBoardWidgetsBundle: WidgetBundle {
|
||||||
var body: some Widget {
|
var body: some Widget {
|
||||||
FuelPriceWidget()
|
FuelPriceWidget()
|
||||||
|
FuelPriceSmallWidget()
|
||||||
FuelBoardLiveActivity()
|
FuelBoardLiveActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import SwiftUI
|
|||||||
import AppIntents
|
import AppIntents
|
||||||
|
|
||||||
// FuelBoard widget — petrol stations near you.
|
// FuelBoard widget — petrol stations near you.
|
||||||
// systemMedium: top 3-4 stations with price + distance, each row opens Maps
|
// FuelPriceWidget (medium): top 3 stations with price + distance, each row
|
||||||
// systemSmall: single station, whole widget opens Maps
|
// 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
|
// Taps deep-link to Apple Maps directions (maps://?daddr=). On the Home
|
||||||
// Screen the system either opens Maps directly or delivers the URL to
|
// Screen the system either opens Maps directly or delivers the URL to
|
||||||
// FuelBoard, whose onOpenURL forwards it (and also still handles legacy
|
// 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
|
// widget taps there can never launch Maps (Apple only allows a widget
|
||||||
// to launch its own app in CarPlay, and only CarPlay-enabled apps).
|
// to launch its own app in CarPlay, and only CarPlay-enabled apps).
|
||||||
// Disfavored = read-only in the car, no dead interaction.
|
// Disfavored = read-only in the car, no dead interaction.
|
||||||
baseConfiguration()
|
|
||||||
.disfavoredLocations([.carPlay], for: [.systemSmall])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func baseConfiguration() -> some WidgetConfiguration {
|
|
||||||
AppIntentConfiguration(
|
AppIntentConfiguration(
|
||||||
kind: kind,
|
kind: kind,
|
||||||
intent: FuelBoardWidgetConfigurationIntent.self,
|
intent: FuelBoardWidgetConfigurationIntent.self,
|
||||||
provider: FuelPriceTimelineProvider()
|
provider: FuelPriceTimelineProvider<FuelBoardWidgetConfigurationIntent>()
|
||||||
) { entry in
|
) { entry in
|
||||||
FuelPriceWidgetView(entry: entry)
|
FuelPriceWidgetView(entry: entry)
|
||||||
.containerBackground(for: .widget) {
|
.containerBackground(for: .widget) {
|
||||||
@@ -56,7 +55,33 @@ struct FuelPriceWidget: Widget {
|
|||||||
}
|
}
|
||||||
.configurationDisplayName("FuelBoard Prices")
|
.configurationDisplayName("FuelBoard Prices")
|
||||||
.description("Fuel prices near you. Configure fuel + sort per widget.")
|
.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<FuelBoardSmallWidgetConfigurationIntent>()
|
||||||
|
) { 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
|
let unit: DistanceUnit // user's display unit for distances
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
|
struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & WidgetConfigValues>: AppIntentTimelineProvider {
|
||||||
|
typealias Intent = Configuration
|
||||||
|
|
||||||
func placeholder(in context: Context) -> FuelPriceEntry {
|
func placeholder(in context: Context) -> FuelPriceEntry {
|
||||||
let unit = FuelStore.loadDistanceUnit()
|
let unit = FuelStore.loadDistanceUnit()
|
||||||
let sample = SampleFuelProvider.sampleStations
|
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)
|
await makeEntry(configuration: configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
func timeline(
|
func timeline(
|
||||||
for configuration: FuelBoardWidgetConfigurationIntent,
|
for configuration: Configuration,
|
||||||
in context: Context
|
in context: Context
|
||||||
) async -> Timeline<FuelPriceEntry> {
|
) async -> Timeline<FuelPriceEntry> {
|
||||||
let entry = await makeEntry(configuration: configuration)
|
let entry = await makeEntry(configuration: configuration)
|
||||||
@@ -99,7 +126,7 @@ struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
|
|||||||
return Timeline(entries: [entry], policy: .after(nextRefresh))
|
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.
|
// Per-widget config: fuel + sort + distance come from THIS widget instance.
|
||||||
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
|
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
|
||||||
let isFavourites = configuration.sort == .favourites
|
let isFavourites = configuration.sort == .favourites
|
||||||
@@ -131,20 +158,40 @@ struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
|
|||||||
// 3) Load + order stations per widget mode.
|
// 3) Load + order stations per widget mode.
|
||||||
let ordered: [FuelStation]
|
let ordered: [FuelStation]
|
||||||
if isFavourites {
|
if isFavourites {
|
||||||
// Favourites: pinned stations for THIS fuel, cheapest-first. Not
|
let favourites = FuelStore.loadFavourites()
|
||||||
// radius-bound — a favourite in Edinburgh shows on a widget in
|
.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
|
// London. Prices come from the keychain snapshot (the app
|
||||||
// refreshes favourite prices into keychain after every fetch), or
|
// refreshes favourite prices into keychain after every fetch), or
|
||||||
// fresher from a focused relay fetch when a favourite happens to
|
// fresher from a focused relay fetch when a favourite happens to
|
||||||
// be in range.
|
// be in range.
|
||||||
let favourites = FuelStore.loadFavourites()
|
let fuelFavourites = favourites
|
||||||
.filter { $0.fuel == fuel && $0.station.prices[fuel] != nil }
|
.filter { $0.fuel == fuel && $0.station.prices[fuel] != nil }
|
||||||
.map(\.station)
|
.map(\.station)
|
||||||
var refreshed: [FuelStation] = favourites
|
var refreshed: [FuelStation] = fuelFavourites
|
||||||
if let location,
|
if let location,
|
||||||
let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
|
let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
|
||||||
let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
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
|
ordered = refreshed.sorted { lhs, rhs in
|
||||||
let lPrice = lhs.prices[fuel]!
|
let lPrice = lhs.prices[fuel]!
|
||||||
|
|||||||
@@ -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 title: LocalizedStringResource = "Fuel & Sort"
|
||||||
static var description = IntentDescription("Which fuel, ordering and search radius this widget shows.")
|
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))
|
@Parameter(title: "Distance", default: WidgetDistance(id: 5))
|
||||||
var distance: WidgetDistance
|
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
|
/// Edit-Widget UI: the Distance picker only makes sense for Cheapest
|
||||||
/// ordering — Closest is inherently "nearest within range" and Favourites
|
/// ordering — Closest is inherently "nearest within range" and Favourites
|
||||||
/// is not radius-bound. Show it for Cheapest only; hide for both others.
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user