Files
fuelboard/FuelBoardWidgets/WidgetConfigIntent.swift
T

336 lines
14 KiB
Swift

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",
]
}
// Per-widget sort order. A String-backed AppEnum (NOT an AppEntity): the
// parameterSummary conditionals (When(\.$sort, .equalTo, …)) fall back to the
// otherwise branch for AppEntity parameters (iOS 17+ bug FB13263902), which
// hid the Favourite picker. String AppEnums match reliably. Tradeoff: the
// option set is static — "Favourites" always appears, and picking it with no
// favourites shows the empty state.
enum WidgetSort: String, AppEnum, CaseIterable, Codable {
case cheapest
case closest
case favourites
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Sort by"
static var caseDisplayRepresentations: [WidgetSort: DisplayRepresentation] = [
.cheapest: "Cheapest",
.closest: "Closest",
.favourites: "Favourites",
]
}
// Per-widget distance filter. Modelled as an AppEntity (not an AppEnum) so
// the option labels can mirror the app's units setting at render time: an
// AppEnum's caseDisplayRepresentations are STATIC, so the Edit-Widget picker
// would always show "5 miles / 10 miles / 15 miles" regardless of the app's
// km mode. An entity query resolves labels per value, so they can read the
// shared unit preference ("8 km" in km mode).
struct WidgetDistance: AppEntity, Identifiable, Hashable, Codable {
/// The distance in miles (5/10/15) — the persisted value. Labels are
/// unit-aware, the stored value stays in miles.
let id: Int
var miles: Int { id }
var displayRepresentation: DisplayRepresentation {
let unit = FuelStore.loadDistanceUnit()
let shown = unit.displayMiles(id)
return DisplayRepresentation(stringLiteral: "\(shown) \(unit.label(for: Double(shown)))")
}
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Distance"
static var defaultQuery = WidgetDistanceQuery()
}
struct WidgetDistanceQuery: EntityQuery {
func entities(for identifiers: [Int]) async throws -> [WidgetDistance] {
identifiers.map { WidgetDistance(id: $0) }
}
func suggestedEntities() async throws -> [WidgetDistance] {
FuelStore.stationRadiusOptions.map { WidgetDistance(id: $0) }
}
}
// Fuel for the favourites face (small widget, Favourites sort). Modelled as an
// AppEntity so only fuels that ACTUALLY have favourites appear in the picker —
// mirrors the app's Favourites tab capsules. The chosen fuel dictates which
// favourites the Favourite picker offers (see WidgetFavouriteQuery).
struct FavouriteFuel: AppEntity, Identifiable, Hashable, Codable {
let fuel: FuelType
var id: String { fuel.rawValue }
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(stringLiteral: fuel.displayName)
}
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
static var defaultQuery = FavouriteFuelQuery()
}
struct FavouriteFuelQuery: EntityQuery {
/// Strict: a fuel only resolves if it ACTUALLY has favourites. A stored
/// value for a fuel that lost all its favourites (or the static .e10
/// default on a fresh widget) fails resolution and falls back to
/// defaultResult() — the first fuel that has a favourite.
func entities(for identifiers: [String]) async throws -> [FavouriteFuel] {
let fuels = Set(FuelStore.loadFavourites().map(\.fuel))
return identifiers.compactMap { id in
guard let fuel = FuelType(rawValue: id), fuels.contains(fuel) else { return nil }
return FavouriteFuel(fuel: fuel)
}
}
func suggestedEntities() async throws -> [FavouriteFuel] {
let fuels = Set(FuelStore.loadFavourites().map(\.fuel))
return FuelType.allCases.filter { fuels.contains($0) }.map { FavouriteFuel(fuel: $0) }
}
/// Fresh widget / unresolved value → auto-populate the Fuel row with the
/// FIRST fuel that has a favourite (Unleaded, Premium, Diesel order).
func defaultResult() async -> FavouriteFuel? {
let fuels = Set(FuelStore.loadFavourites().map(\.fuel))
return FuelType.allCases.first { fuels.contains($0) }
.map { FavouriteFuel(fuel: $0) }
?? FavouriteFuel(fuel: .e10)
}
}
// 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.
//
// The display name is EMBEDDED in the entity (stationName): the config sheet
// can resolve entities in a process where keychain/app-group storage is
// unavailable, and a storage-backed lookup there made every picker row fall
// back to the "Favourite" placeholder. With the name carried on the value,
// rows render with no storage read at all.
struct WidgetFavourite: AppEntity, Identifiable, Hashable, Codable {
let fuel: FuelType
let stationID: String
/// Station display name, embedded so picker rows render storage-free.
let stationName: String
var id: String { "\(fuel.rawValue)|\(stationID)" }
var displayRepresentation: DisplayRepresentation {
if !stationName.isEmpty {
return DisplayRepresentation(stringLiteral: stationName)
}
// Id-only entity (e.g. a stored default): best-effort storage lookup.
let favourites = FuelStore.loadFavourites()
guard let entry = favourites.first(where: { $0.id == id }) else {
return DisplayRepresentation(stringLiteral: "Favourite")
}
return DisplayRepresentation(stringLiteral: entry.station.name)
}
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Favourite"
static var defaultQuery = WidgetFavouriteQuery()
}
struct WidgetFavouriteQuery: EntityQuery {
/// Reads the small intent's Favourite Fuel parameter: the Favourite picker
/// only offers THAT fuel's favourites (requirement: the chosen fuel
/// dictates the list). Nil (medium widget / fresh sheet) → all favourites.
@IntentParameterDependency<FuelBoardSmallWidgetConfigurationIntent>(
\.$favouriteFuel
)
var smallIntent
func entities(for identifiers: [String]) async throws -> [WidgetFavourite] {
let favourites = FuelStore.loadFavourites()
return identifiers.compactMap { id in
// Strict: only real, existing favourites resolve. The empty
// static default (id "fuel|") and removed favourites fail
// resolution so the sheet falls back to defaultResult().
guard !id.hasSuffix("|"),
let entry = favourites.first(where: { $0.id == id }) else { return nil }
return WidgetFavourite(
fuel: entry.fuel,
stationID: entry.station.id,
stationName: entry.station.name
)
}
}
func suggestedEntities() async throws -> [WidgetFavourite] {
let favourites = FuelStore.loadFavourites()
let filtered: [FavouriteEntry]
if let fuel = smallIntent?.favouriteFuel.fuel {
filtered = favourites.filter { $0.fuel == fuel }
} else {
filtered = favourites
}
return filtered.map { entry in
WidgetFavourite(
fuel: entry.fuel,
stationID: entry.station.id,
stationName: entry.station.name
)
}
}
/// Fresh widget / unresolved value → auto-populate the Favourite row with
/// the FIRST favourite of the Fuel row's fuel (falling back to the first
/// favourite of any fuel if the fuel has none, e.g. mid-change).
func defaultResult() async -> WidgetFavourite? {
let favourites = FuelStore.loadFavourites()
guard !favourites.isEmpty else { return nil }
let entry: FavouriteEntry
if let fuel = smallIntent?.favouriteFuel.fuel,
let scoped = favourites.first(where: { $0.fuel == fuel }) {
entry = scoped
} else {
entry = favourites[0]
}
return WidgetFavourite(
fuel: entry.fuel,
stationID: entry.station.id,
stationName: entry.station.name
)
}
}
// 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 }
/// The favourites-face fuel (small widget, Favourites sort) — its options
/// are the fuels that have favourites, and it dictates which favourites
/// the Favourite picker offers. Nil on the medium widget.
var favouriteFuelChoice: FavouriteFuel? { get }
}
struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConfigValues {
static var title: LocalizedStringResource = "Fuel & Sort"
static var description = IntentDescription("Which fuel, ordering and search radius this 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
// Medium/list widgets show ALL favourites for the chosen fuel — no
// pinned-favourite knob, no favourites-scoped fuel.
var favouriteChoice: WidgetFavourite? { nil }
var favouriteFuelChoice: FavouriteFuel? { 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.
static var parameterSummary: some ParameterSummary {
When(\.$sort, .equalTo, WidgetSort.cheapest) {
Summary("Show \(\.$fuel) by \(\.$sort) within \(\.$distance)") {
\.$fuel
\.$sort
\.$distance
}
} otherwise: {
When(\.$sort, .equalTo, WidgetSort.favourites) {
Summary("Show \(\.$fuel) favourites") {
\.$fuel
\.$sort
}
} otherwise: {
Summary("Show \(\.$fuel) by \(\.$sort)") {
\.$fuel
\.$sort
}
}
}
}
}
// 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). Favourites mode swaps the Fuel row for a
// favourites-scoped one — only fuels with favourites are offered, and the
// chosen fuel dictates which favourites the Favourite picker lists. The
// pinned favourite wins when set; the fuel row picks the default favourite on
// a fresh widget. Distance stays hidden (a pinned station has no radius).
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.")
// Declared in display order for EVERY summary branch: iOS may fall back to
// declaration order when re-rendering the sheet after a parameter change,
// so the relative order here must match the wanted layout in all modes:
// Favourites → Fuel(favouriteFuel) · Sort by · Favourite
// Cheapest → Fuel · Sort by · Distance
// Closest → Fuel · Sort by
@Parameter(title: "Fuel", default: FavouriteFuel(fuel: .e10))
var favouriteFuel: FavouriteFuel
@Parameter(title: "Fuel", default: .e10)
var fuel: WidgetFuel
@Parameter(title: "Sort by", default: .cheapest)
var sort: WidgetSort
@Parameter(title: "Favourite", default: WidgetFavourite(fuel: .e10, stationID: "", stationName: ""))
var favourite: WidgetFavourite
@Parameter(title: "Distance", default: WidgetDistance(id: 5))
var distance: WidgetDistance
var favouriteChoice: WidgetFavourite? { favourite }
var favouriteFuelChoice: FavouriteFuel? { favouriteFuel }
static var parameterSummary: some ParameterSummary {
When(\.$sort, .equalTo, WidgetSort.favourites) {
Summary("Show \(\.$favouriteFuel) favourite \(\.$favourite)") {
\.$favouriteFuel
\.$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
}
}
}
}
}