Final structural difference from the working medium widget. The dependent-picker machinery resolves the favourite parameter through the hidden favouriteFuel parameter during fresh-widget default-config resolution (default Sort = Cheapest hides favouriteFuel), and that resolution fails on-device — the whole default config fails and the small widget strands on the skeleton. Favourite picker now lists ALL favourites, each row labelled with its fuel (the fuel-scoped filter was the dependency's only purpose). Configured Favourites-mode widgets are unaffected. Per-intent beacons remain in place to verify the outcome.
325 lines
14 KiB
Swift
325 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 Favourite picker itself is
|
|
// fuel-independent now — 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 {
|
|
// LENIENT resolution: every valid fuel resolves, even one without
|
|
// favourites. The timeline request resolves the FULL configuration
|
|
// through these queries, and a nil result fails the whole timeline — a
|
|
// default-config widget would hang on the skeleton placeholder.
|
|
//
|
|
// NO defaultResult(): fresh/default configs resolve from the static
|
|
// @Parameter(default:) value through entities(for:) with ZERO storage
|
|
// reads. The old keychain-backed defaultResult() put storage access in
|
|
// the gallery-preview/fresh-add resolution path, which failed on-device
|
|
// (small widget stranded on the skeleton; configured widgets were fine).
|
|
// Auto-populate for the SHEET moved to entry time (makeEntry picks the
|
|
// first favourite of the fuel); picker options stay in suggestedEntities().
|
|
func entities(for identifiers: [String]) async throws -> [FavouriteFuel] {
|
|
identifiers.compactMap { FuelType(rawValue: $0) }.map { FavouriteFuel(fuel: $0) }
|
|
}
|
|
|
|
func suggestedEntities() async throws -> [FavouriteFuel] {
|
|
let fuels = Set(FuelStore.loadFavourites().map(\.fuel))
|
|
return FuelType.allCases.filter { fuels.contains($0) }.map { FavouriteFuel(fuel: $0) }
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// NO @IntentParameterDependency: the dependent-picker machinery resolves
|
|
// the favourite parameter through the (hidden, in Cheapest mode)
|
|
// favouriteFuel parameter during fresh-widget default-config resolution,
|
|
// and that resolution fails on-device — the whole default config fails
|
|
// and the small widget strands on the skeleton placeholder. The picker
|
|
// now lists ALL favourites, each row labelled with its fuel; the
|
|
// fuel-scoped filter was the dependency's only purpose. Configured
|
|
// Favourites-mode widgets are unaffected (stored configs skip the
|
|
// default resolution path entirely).
|
|
|
|
// LENIENT resolution: every stored identifier resolves to a
|
|
// WidgetFavourite (best-effort name lookup). NEVER return nil here — the
|
|
// timeline request resolves the whole configuration through this query
|
|
// and a failed resolution strands a default-config widget on the skeleton
|
|
// placeholder.
|
|
//
|
|
// NO defaultResult(): fresh/default configs resolve from the static
|
|
// @Parameter(default:) value (empty id "e10|") with NO storage reads —
|
|
// the old keychain-backed defaultResult() ran in the gallery-preview /
|
|
// fresh-add resolution path and failed on-device. The sheet's
|
|
// auto-populate moved to entry time (makeEntry pins the first favourite
|
|
// of the fuel); the dependent picker keeps working via suggestedEntities().
|
|
func entities(for identifiers: [String]) async throws -> [WidgetFavourite] {
|
|
return identifiers.map { id in
|
|
let parts = id.split(separator: "|", maxSplits: 1)
|
|
let fuel = FuelType(rawValue: String(parts.first ?? "")) ?? .e10
|
|
let stationID = String(parts.last ?? "")
|
|
// Id-only value (fresh widget's static default): resolve with
|
|
// zero keychain access. displayRepresentation falls back to a
|
|
// storage lookup if the name is empty, so stored picks still
|
|
// render their name.
|
|
guard !stationID.isEmpty else {
|
|
return WidgetFavourite(fuel: fuel, stationID: stationID, stationName: "")
|
|
}
|
|
let favourites = FuelStore.loadFavourites()
|
|
let entry = favourites.first { $0.id == id }
|
|
return WidgetFavourite(
|
|
fuel: fuel,
|
|
stationID: stationID,
|
|
stationName: entry?.station.name ?? ""
|
|
)
|
|
}
|
|
}
|
|
|
|
func suggestedEntities() async throws -> [WidgetFavourite] {
|
|
// All favourites, each labelled with its fuel (no dependency available
|
|
// to filter by the chosen fuel — see the note above).
|
|
FuelStore.loadFavourites().map { entry in
|
|
WidgetFavourite(
|
|
fuel: entry.fuel,
|
|
stationID: entry.station.id,
|
|
stationName: "\(entry.fuel.displayName) · \(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. 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|