From dafc96f845963667ea6afde6adddb31a7c48feef Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 13 Aug 2026 20:43:15 +0100 Subject: [PATCH] Fix small widget skeleton: use the proven medium intent; delete failing small intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- FuelBoard/SettingsView.swift | 2 +- FuelBoardWidgets/FuelBoardWidgetsBundle.swift | 5 +- FuelBoardWidgets/FuelPriceWidget.swift | 102 ++------- FuelBoardWidgets/WidgetConfigIntent.swift | 202 ------------------ 4 files changed, 22 insertions(+), 289 deletions(-) diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index aac1800..49f7a90 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -52,7 +52,7 @@ struct SettingsView: View { @State private var installedWidgets: String = "" private func refreshWidgetDiag() { - smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidgetConfigurationIntent") + smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidget") mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent") WidgetCenter.shared.getCurrentConfigurations { result in let text: String diff --git a/FuelBoardWidgets/FuelBoardWidgetsBundle.swift b/FuelBoardWidgets/FuelBoardWidgetsBundle.swift index de82e43..e38f95e 100644 --- a/FuelBoardWidgets/FuelBoardWidgetsBundle.swift +++ b/FuelBoardWidgets/FuelBoardWidgetsBundle.swift @@ -4,10 +4,7 @@ import SwiftUI @main struct FuelBoardWidgetsBundle: WidgetBundle { var body: some Widget { - // Gallery order follows registration order. The A/B test widget is - // first so it's easy to find; remove it once the small-widget root - // cause is confirmed. - FuelBoardTestSmallWidget() + // Gallery order follows registration order: small first, then medium. FuelPriceSmallWidget() FuelPriceWidget() FuelBoardLiveActivity() diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 6bfb4df..6e67dbb 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -6,9 +6,9 @@ import AppIntents // 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). +// Same knobs as the medium — the small face shows the first of the +// configured result (cheapest/closest station, or the cheapest favourite +// of the chosen fuel in Favourites mode). // 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 @@ -60,46 +60,22 @@ struct FuelPriceWidget: Widget { } } -// 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. +// Small widget — exactly ONE station (the first of the configured result). +// Shares the medium widget's intent: the small intent with extra favourite +// AppEntity params failed fresh-widget default-config resolution at the +// system level (whole default config failed → no timeline → skeleton; proven +// by an A/B test where a small widget using this intent populated while the +// custom-intent small widget never even reached makeEntry). Favourites sort +// on the small face shows the cheapest favourite of the chosen fuel. 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]) - } -} - -// A/B TEST widget (temporary): a from-scratch SMALL widget driven by the -// PROVEN medium intent — no favourite params, no dependency, no custom -// query. If this populates while FuelPriceSmallWidget skeletons, the small -// SIZE is fine and the small intent's extra machinery is the culprit. If it -// ALSO skeletons, the failure is at the small-family/system level. Removed -// once the root cause is confirmed. -struct FuelBoardTestSmallWidget: Widget { - let kind = "FuelBoardTestSmallWidget" - var body: some WidgetConfiguration { AppIntentConfiguration( kind: kind, intent: FuelBoardWidgetConfigurationIntent.self, provider: FuelPriceTimelineProvider( - beaconTag: "FuelBoardTestSmallWidget" + beaconTag: "FuelBoardSmallWidget" ) ) { entry in FuelPriceWidgetView(entry: entry) @@ -107,8 +83,8 @@ struct FuelBoardTestSmallWidget: Widget { Color(.systemBackground) } } - .configurationDisplayName("FuelBoard Small Test") - .description("A/B test: minimal small widget using the proven medium intent.") + .configurationDisplayName("FuelBoard Small") + .description("Fuel prices near you. Configure fuel + sort per widget.") .supportedFamilies([.systemSmall]) .disfavoredLocations([.carPlay], for: [.systemSmall]) } @@ -239,54 +215,16 @@ struct FuelPriceTimelineProvider [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 - } - } - } - } -}