Files
fuelboard/FuelBoardWidgets/WidgetConfigIntent.swift
T
FuelBoard Contributor 6375af4c05 Small widget: restore its own intent TYPE (minimal params) for the icon menu
iOS keys the long-press app-icon contextual menu by configuration-intent
TYPE, so sharing one intent across kinds collapsed the menu to the
first-registered kind (small) — the medium 'FuelBoard Prices' vanished
from the icon menu while the + gallery (kind-keyed) still listed both.
dafc96f's skeleton fix was never the problem; the extra favourite
AppEntity params were.

FIX: FuelBoardSmallWidgetConfigurationIntent — a distinct intent TYPE
with the IDENTICAL minimal parameter set (fuel/sort/distance only, no
AppEntity params). Fresh-widget default-config resolution stays intact;
the appex metadata now registers two WidgetConfiguration actions.

Verified: build green, 45/45 tests, extract.actionsdata lists both
intents, fresh IPA packaged + signed per component.
2026-08-14 02:28:43 +01:00

171 lines
6.5 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) }
}
}
// 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 }
}
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
/// 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
}
}
}
}
}
// The SMALL widget's OWN intent TYPE. iOS keys the long-press app-icon
// contextual menu (iOS 18+ "turn icon into a widget") by configuration-intent
// TYPE, so two widget kinds sharing one intent collapse there to a single
// entry — the first-registered kind. (The + gallery is kind-keyed, which is
// why it still lists both.) This intent carries the IDENTICAL minimal
// parameter set as the medium intent — fuel/sort/distance only, NO AppEntity
// params (the favouriteFuel/favourite AppEntity params were what broke
// fresh-widget default-config resolution, not intent separation; see
// Fresh-widget defaults). Sharing the generic provider keeps the timeline
// logic in one place.
struct FuelBoardSmallWidgetConfigurationIntent: 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
/// Edit-Widget UI: same layout as the medium intent — Distance only for
/// Cheapest ordering; hidden for Closest and Favourites.
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
}
}
}
}
}