Fix small widget skeleton: use the proven medium intent; delete failing small intent

ROOT CAUSE (proven by A/B test on-device): the small widget's custom
intent (FuelBoardSmallWidgetConfigurationIntent with favouriteFuel +
favourite AppEntity params) fails fresh-widget default-config resolution
at the system level — the whole default config fails, no timeline is
ever requested, makeEntry never runs, widget stays on the skeleton.
A minimal small widget sharing the MEDIUM intent (fuel/sort/distance
only) populated immediately in the same extension on the same device.
Neither removing defaultResult() (4d35b08) nor the
@IntentParameterDependency (8a9e775) helped — the extra entity params
themselves break resolution.

FIX: FuelPriceSmallWidget now uses FuelBoardWidgetConfigurationIntent
exactly like the working test widget. FuelBoardSmallWidgetConfiguration
Intent, FavouriteFuel(+Query), WidgetFavourite(+Query) deleted; the
pinned-favourite picker is gone (approved tradeoff). Favourites sort
still works on both faces via the list branch — the small face renders
the cheapest favourite of the chosen fuel. Beacon tag distinguishes the
small widget in diagnostics; test widget removed, gallery order
restored (small, medium, live activity).
This commit is contained in:
FuelBoard Contributor
2026-08-13 20:43:15 +01:00
parent 553730681f
commit dafc96f845
4 changed files with 22 additions and 289 deletions
-202
View File
@@ -74,151 +74,12 @@ struct WidgetDistanceQuery: EntityQuery {
}
}
// 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 {
@@ -234,11 +95,6 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConf
@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.
@@ -264,61 +120,3 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConf
}
}
}
// 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
}
}
}
}
}