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 { 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 { /// 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( \.$favouriteFuel ) var smallIntent func entities(for identifiers: [String]) async throws -> [WidgetFavourite] { let favourites = FuelStore.loadFavourites() 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 ?? "") let entry = favourites.first { $0.id == id } return WidgetFavourite( fuel: fuel, stationID: stationID, 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 ) } } } // 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.") @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 @Parameter(title: "Fuel", default: FavouriteFuel(fuel: .e10)) var favouriteFuel: FavouriteFuel @Parameter(title: "Favourite", default: WidgetFavourite(fuel: .e10, stationID: "", stationName: "")) var favourite: WidgetFavourite 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 } } } } }