import AppIntents import SwiftUI /// Siri / Shortcuts surface for "cheapest [fuel] near me". /// /// Design (see skill reference `siri-app-intents-scope.md`): /// - App Intents (iOS 16+), no entitlements/capabilities/Info.plist keys. /// - Cached-data-first: answers from the full-UK dump saved in the app-group /// defaults by the normal app fetch, SCOPED to the app's saved search radius /// (5/10/15 mi) so "near me" really means near me. The dialog ALWAYS labels /// data age ("as of 6:30 AM") — silent stale answers are a trust killer for /// fuel prices. The fresh-data path (relay baseURL is LAN-only) is a P0 /// dependency; until it lands, off-LAN answers use whatever the phone /// last fetched at home. /// - Location: last-known location fallback (background CoreLocation from a /// Siri-launched process is slow/unreliable). /// - Phrases stay literal/boring for App Store review; `\.applicationName` /// appears in EVERY phrase (iOS 26 metadata-processor requirement — /// without it the metadata exports ZERO utterances). /// - Shortcut names match the app (Unleaded / Premium / Diesel); the /// registered phrases ALSO carry the UK speech variants ("petrol", /// "super unleaded") so Siri matches how people actually ask. The app UI's /// FuelType.displayName is untouched. // MARK: - Fuel parameter extension FuelType: AppEnum { static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel" static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [ .e10: "Unleaded", .e5: "Premium", .diesel: "Diesel", ] } // MARK: - Intent struct CheapestFuelIntent: AppIntent { static var title: LocalizedStringResource = "Cheapest Fuel Near Me" static var description = IntentDescription( "Finds the cheapest station selling a fuel near you, using the latest cached prices." ) @Parameter(title: "Fuel") var fuel: FuelType /// Plain construction — Siri resolves the fuel parameter from the /// utterance. Defaults to petrol if the parameter is unresolved. init() { self.fuel = .e10 } /// Fixed-fuel construction for the per-fuel App Shortcuts: the fuel word /// becomes literal phrase text so Siri matches "unleaded"/"petrol"/ /// "diesel" directly instead of resolving the parameter (which was flaky /// on-device — "cheapest diesel" matched, "cheapest unleaded" didn't). init(fuel: FuelType) { self.fuel = fuel } func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetIntent & ReturnsValue { let stations = FuelStore.loadStations() guard !stations.isEmpty else { let dialog = "I don't have any price data yet. Open FuelBoard once to download the latest prices, then ask me again." return .result( value: "No price data yet", dialog: IntentDialog(stringLiteral: dialog), snippetIntent: FuelMessageSnippetIntent(text: "No price data yet — open FuelBoard to download prices.") ) } guard let coordinate = FuelStore.loadLocationWithDate()?.coordinate else { let dialog = "I don't know your location yet. Open FuelBoard and allow location access, then ask me again." return .result( value: "Location unavailable", dialog: IntentDialog(stringLiteral: dialog), snippetIntent: FuelMessageSnippetIntent(text: "Location unavailable — open FuelBoard to share your location.") ) } // Scope to the app's saved search radius (5/10/15 mi, default 5) so // "near me" really means near me — the UK-wide minimum can be hundreds // of miles away. let radiusMiles = Double(FuelStore.loadStationLimit()) guard let station = SiriCheapestLookup.cheapest( in: stations, fuel: fuel, fromLat: coordinate.lat, lng: coordinate.lng, withinMiles: radiusMiles ), let price = station.prices[fuel] else { let fuelName = fuel.displayName.lowercased() let radiusText = radiusMiles == 1 ? "1 mile" : "\(Int(radiusMiles)) miles" return .result( value: "No \(fuelName) stations within \(radiusText)", dialog: IntentDialog(stringLiteral: "I couldn't find any station selling \(fuelName) within \(radiusText) of you."), snippetIntent: FuelMessageSnippetIntent(text: "No \(fuelName) stations within \(radiusText).") ) } let distanceKM = station.distanceKM(to: coordinate.lat, lng2: coordinate.lng) let distanceText = FuelStore.loadDistanceUnit().format(distanceKM) let priceText = String(format: "£%.3f", price / 100) let freshness = SiriCheapestLookup.freshnessLabel( updated: FuelStore.loadDataUpdated(), lastRefresh: FuelStore.loadLastRefresh() ) let summary = "\(station.name): \(priceText), \(distanceText)" let freshnessClause = freshness.isEmpty ? "" : " — prices \(freshness)" let dialog = "The cheapest \(fuel.displayName.lowercased()) near you is \(station.name) at \(priceText), \(distanceText) away\(freshnessClause)." return .result( value: summary, dialog: IntentDialog(stringLiteral: dialog), snippetIntent: FuelPriceSnippetIntent( stationName: station.name, fuelName: fuel.displayName, price: priceText, distance: distanceText, freshness: freshness, latitude: station.lat, longitude: station.lng ) ) } } // MARK: - Directions intent /// Opens Apple Maps with turn-by-turn directions to the cheapest station /// selling `fuel` within the saved search radius. Shares the exact same /// radius-scoped lookup as `CheapestFuelIntent`; the only difference is the /// result — it hands off to Maps instead of speaking the price. struct DirectionsToCheapestFuelIntent: AppIntent { static var title: LocalizedStringResource = "Directions to Cheapest Fuel Near Me" static var description = IntentDescription( "Opens Apple Maps directions to the cheapest station selling a fuel near you, using the latest cached prices." ) @Parameter(title: "Fuel") var fuel: FuelType init() { self.fuel = .e10 } init(fuel: FuelType) { self.fuel = fuel } func perform() async throws -> some IntentResult & ProvidesDialog & OpensIntent { let stations = FuelStore.loadStations() guard !stations.isEmpty else { throw DirectionsError("I don't have any price data yet. Open FuelBoard once to download the latest prices, then ask me again.") } guard let coordinate = FuelStore.loadLocationWithDate()?.coordinate else { throw DirectionsError("I don't know your location yet. Open FuelBoard and allow location access, then ask me again.") } let radiusMiles = Double(FuelStore.loadStationLimit()) guard let station = SiriCheapestLookup.cheapest( in: stations, fuel: fuel, fromLat: coordinate.lat, lng: coordinate.lng, withinMiles: radiusMiles ) else { let fuelName = fuel.displayName.lowercased() let radiusText = radiusMiles == 1 ? "1 mile" : "\(Int(radiusMiles)) miles" throw DirectionsError("I couldn't find any station selling \(fuelName) within \(radiusText) of you.") } // Speak where we're taking the user BEFORE the Maps hand-off — a // silent jump straight into Maps is jarring. The `.result(opensIntent: // dialog:)` overload (confirmed in the iOS 26 SDK interface) lets the // intent do both: announce the station, then open it. let distanceKM = station.distanceKM(to: coordinate.lat, lng2: coordinate.lng) let distanceText = FuelStore.loadDistanceUnit().format(distanceKM) let url = SiriCheapestLookup.mapsURL(latitude: station.lat, longitude: station.lng) return .result( opensIntent: OpenURLIntent(url), dialog: IntentDialog(stringLiteral: "Opening Maps to \(station.name), \(distanceText) away.") ) } } /// Localized error thrown when directions can't be produced (no data, no /// location, or no station within the radius). Siri presents the message /// directly. private struct DirectionsError: LocalizedError { let message: String init(_ message: String) { self.message = message } var errorDescription: String? { message } } // MARK: - Favourite intents /// Price at the user's TOP favourite station for `fuel` — the first entry in /// the manual favourites order for that fuel (the Favourites tab's /// drag-and-drop order, the same order the single widget uses). Unlike the /// cheapest intents this deliberately does NOT require a location: the /// favourite is chosen by the user, not by distance, so it answers even when /// a Siri-launched location fix is unavailable (distance is included only /// when the saved last-known fix exists). struct FavouriteFuelPriceIntent: AppIntent { static var title: LocalizedStringResource = "Favourite Fuel Price" static var description = IntentDescription( "Tells you the price at your top favourite station for a fuel, using the latest cached prices." ) @Parameter(title: "Fuel") var fuel: FuelType init() { self.fuel = .e10 } init(fuel: FuelType) { self.fuel = fuel } func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetIntent & ReturnsValue { let stations = FuelStore.loadStations() let favourites = FuelStore.loadFavourites() let fuelName = fuel.displayName.lowercased() guard let favourite = SiriCheapestLookup.topFavourite(in: favourites, from: stations, fuel: fuel) else { let dialog = "You don't have a favourite \(fuelName) station yet. Open FuelBoard and star one, then ask me again." return .result( value: "No favourite \(fuelName) station", dialog: IntentDialog(stringLiteral: dialog), snippetIntent: FuelMessageSnippetIntent(text: "No favourite \(fuelName) station — star one in FuelBoard first.") ) } guard let price = favourite.station.prices[fuel] else { let dialog = "I don't have a current price for \(favourite.station.name), your favourite \(fuelName) station." return .result( value: "No current price for \(favourite.station.name)", dialog: IntentDialog(stringLiteral: dialog), snippetIntent: FuelMessageSnippetIntent(text: "No current price for \(favourite.station.name).") ) } let priceText = String(format: "£%.3f", price / 100) let freshness = SiriCheapestLookup.freshnessLabel( updated: FuelStore.loadDataUpdated(), lastRefresh: FuelStore.loadLastRefresh() ) let freshnessClause = freshness.isEmpty ? "" : " — prices \(freshness)" // Distance is a nicety, not a requirement: the favourite is chosen by // the user, so a missing location fix still gets a full answer. let distanceText: String let distanceClause: String let summary: String if let coordinate = FuelStore.loadLocationWithDate()?.coordinate { distanceText = FuelStore.loadDistanceUnit().format( favourite.station.distanceKM(to: coordinate.lat, lng2: coordinate.lng) ) distanceClause = ", \(distanceText) away" summary = "\(favourite.station.name): \(priceText), \(distanceText)" } else { distanceText = "" distanceClause = "" summary = "\(favourite.station.name): \(priceText)" } let dialog = "Your favourite \(fuelName) station, \(favourite.station.name), is at \(priceText)\(distanceClause)\(freshnessClause)." return .result( value: summary, dialog: IntentDialog(stringLiteral: dialog), snippetIntent: FuelPriceSnippetIntent( stationName: favourite.station.name, fuelName: fuel.displayName, price: priceText, distance: distanceText, freshness: freshness, latitude: favourite.station.lat, longitude: favourite.station.lng ) ) } } /// Opens Apple Maps with turn-by-turn directions to the user's TOP favourite /// station for `fuel`. Same selection as `FavouriteFuelPriceIntent`; same /// Maps hand-off pattern as `DirectionsToCheapestFuelIntent` — the station is /// announced before Maps opens. struct DirectionsToFavouriteFuelIntent: AppIntent { static var title: LocalizedStringResource = "Directions to Favourite Fuel Station" static var description = IntentDescription( "Opens Apple Maps directions to your top favourite station for a fuel." ) @Parameter(title: "Fuel") var fuel: FuelType init() { self.fuel = .e10 } init(fuel: FuelType) { self.fuel = fuel } func perform() async throws -> some IntentResult & ProvidesDialog & OpensIntent { let stations = FuelStore.loadStations() let favourites = FuelStore.loadFavourites() let fuelName = fuel.displayName.lowercased() guard let favourite = SiriCheapestLookup.topFavourite(in: favourites, from: stations, fuel: fuel) else { throw DirectionsError("You don't have a favourite \(fuelName) station yet. Open FuelBoard and star one, then ask me again.") } let url = SiriCheapestLookup.mapsURL(latitude: favourite.station.lat, longitude: favourite.station.lng) // Distance text when the saved last-known fix exists; otherwise just // announce the station before the hand-off. let dialog: String if let coordinate = FuelStore.loadLocationWithDate()?.coordinate { let distanceText = FuelStore.loadDistanceUnit().format( favourite.station.distanceKM(to: coordinate.lat, lng2: coordinate.lng) ) dialog = "Opening Maps to \(favourite.station.name), \(distanceText) away." } else { dialog = "Opening Maps to \(favourite.station.name)." } return .result( opensIntent: OpenURLIntent(url), dialog: IntentDialog(stringLiteral: dialog) ) } } // MARK: - Snippets /// Price card shown in Shortcuts / Siri results. Rendered via /// `FuelPriceSnippetIntent` so it is an INTERACTIVE snippet (iOS 26): the /// optional `directionsIntent` wires a button that opens Apple Maps at the /// station — an AppIntent run by the button needs NO AppShortcut slot, so /// directions are available on every price card without touching the 10- /// shortcut cap. `directionsIntent == nil` renders the plain static card. struct FuelPriceCard: View { let stationName: String let fuelName: String let price: String let distance: String let freshness: String var directionsIntent: OpenDirectionsIntent? = nil var body: some View { HStack(spacing: 14) { Image(systemName: "fuelpump.fill") .font(.title2) .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 2) { Text(stationName) .font(.headline) .lineLimit(1) Text(distance.isEmpty ? fuelName : "\(fuelName) · \(distance)") .font(.subheadline) .foregroundStyle(.secondary) if !freshness.isEmpty { Text(freshness) .font(.caption) .foregroundStyle(.tertiary) } } Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 6) { Text(price) .font(.title2.bold()) .monospacedDigit() if let directionsIntent { Button(intent: directionsIntent) { Label("Directions", systemImage: "map.fill") .font(.footnote.weight(.medium)) } .buttonStyle(.bordered) .controlSize(.small) } } } .padding(12) } } /// Fallback message card for the no-data / no-location / no-station / no- /// favourite cases. struct FuelMessage: View { let text: String var body: some View { HStack(spacing: 10) { Image(systemName: "info.circle") .foregroundStyle(.secondary) Text(text) .font(.subheadline) } .padding(12) } } /// The button action on the price card: opens Apple Maps at the exact /// station shown. Hidden from Shortcuts discovery (`isDiscoverable = false`) /// — it is only ever run by the snippet button, so it consumes no App /// Shortcut slot and adds no phrase. struct OpenDirectionsIntent: AppIntent { static var title: LocalizedStringResource = "Directions" static var description = IntentDescription("Opens Apple Maps directions to the station.") static var isDiscoverable: Bool = false @Parameter var stationName: String @Parameter var latitude: Double @Parameter var longitude: Double init() {} init(stationName: String, latitude: Double, longitude: Double) { self.stationName = stationName self.latitude = latitude self.longitude = longitude } func perform() async throws -> some IntentResult & ProvidesDialog & OpensIntent { let url = SiriCheapestLookup.mapsURL(latitude: latitude, longitude: longitude) return .result( opensIntent: OpenURLIntent(url), dialog: IntentDialog(stringLiteral: "Opening Maps to \(stationName).") ) } } /// Interactive snippet backing the price card. Parameter-carried (side-effect- /// free `perform`); the view's Directions button runs `OpenDirectionsIntent`. struct FuelPriceSnippetIntent: SnippetIntent { static var title: LocalizedStringResource = "Fuel Price Card" static var isDiscoverable: Bool = false @Parameter var stationName: String @Parameter var fuelName: String @Parameter var price: String @Parameter var distance: String @Parameter var freshness: String @Parameter var latitude: Double @Parameter var longitude: Double init() {} init(stationName: String, fuelName: String, price: String, distance: String, freshness: String, latitude: Double, longitude: Double) { self.stationName = stationName self.fuelName = fuelName self.price = price self.distance = distance self.freshness = freshness self.latitude = latitude self.longitude = longitude } func perform() async throws -> some IntentResult & ShowsSnippetView { .result(view: FuelPriceCard( stationName: stationName, fuelName: fuelName, price: price, distance: distance, freshness: freshness, directionsIntent: OpenDirectionsIntent(stationName: stationName, latitude: latitude, longitude: longitude) )) } } /// Interactive snippet backing the fallback message card (no data / no /// location / no station / no favourite). Static — no buttons. struct FuelMessageSnippetIntent: SnippetIntent { static var title: LocalizedStringResource = "Fuel Message" static var isDiscoverable: Bool = false @Parameter var text: String init() {} init(text: String) { self.text = text } func perform() async throws -> some IntentResult & ShowsSnippetView { .result(view: FuelMessage(text: text)) } } // MARK: - App Shortcuts struct FuelBoardShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { // Generic parameterized shortcut — matches whatever fuel word Siri // resolves. Parameter resolution is flaky on-device ("cheapest diesel" // matched, "cheapest unleaded" didn't), which is why the fixed-fuel // entries below carry the fuel word as LITERAL phrase text. AppShortcut( intent: CheapestFuelIntent(), phrases: [ "Ask \(.applicationName) what's the cheapest \(\.$fuel) near me", ], shortTitle: "Cheapest Fuel", systemImageName: "fuelpump" ) AppShortcut( intent: CheapestFuelIntent(fuel: .e10), phrases: [ "Ask \(.applicationName) for the cheapest unleaded near me", "Ask \(.applicationName) for the cheapest petrol near me", "Find the cheapest petrol near me \(.applicationName)", "Find the cheapest unleaded near me \(.applicationName)", ], shortTitle: "Cheapest Unleaded", systemImageName: "fuelpump" ) AppShortcut( intent: CheapestFuelIntent(fuel: .e5), phrases: [ "Ask \(.applicationName) for the cheapest premium near me", "Ask \(.applicationName) for the cheapest super unleaded near me", "Find the cheapest super unleaded near me \(.applicationName)", ], shortTitle: "Cheapest Premium", systemImageName: "fuelpump" ) AppShortcut( intent: CheapestFuelIntent(fuel: .diesel), phrases: [ "Ask \(.applicationName) for the cheapest diesel near me", "Find the cheapest diesel near me \(.applicationName)", "Cheapest diesel near me \(.applicationName)", ], shortTitle: "Cheapest Diesel", systemImageName: "fuelpump" ) // Favourite price — "How much is my favourite [fuel]?" The answer is // the TOP favourite for that fuel (first in the manual Favourites // order — the same order the single widget uses). AppShortcut( intent: FavouriteFuelPriceIntent(fuel: .e10), phrases: [ "Ask \(.applicationName) how much my favourite unleaded costs", "Ask \(.applicationName) the price of my favourite petrol", ], shortTitle: "Favourite Unleaded Price", systemImageName: "star.fill" ) AppShortcut( intent: FavouriteFuelPriceIntent(fuel: .e5), phrases: [ "Ask \(.applicationName) how much my favourite premium costs", "Ask \(.applicationName) the price of my favourite super unleaded", ], shortTitle: "Favourite Premium Price", systemImageName: "star.fill" ) AppShortcut( intent: FavouriteFuelPriceIntent(fuel: .diesel), phrases: [ "Ask \(.applicationName) how much my favourite diesel costs", ], shortTitle: "Favourite Diesel Price", systemImageName: "star.fill" ) // Directions — only the FAVOURITE directions carry App Shortcut // phrases: Apple caps app shortcuts at 10 (the metadata processor // hard-fails past that), and the per-fuel cheapest-price + favourite // price/directions entries cover the user's actual asks. The cheapest- // directions and generic favourite intents below remain in code — // still runnable from the Shortcuts action list, just without Siri // phrases. Phrases use direction verbs ("directions to", "get // directions") so they never collide with the price shortcut phrases. AppShortcut( intent: DirectionsToFavouriteFuelIntent(fuel: .e10), phrases: [ "Ask \(.applicationName) for directions to my favourite unleaded station", "Get directions to my favourite petrol station \(.applicationName)", ], shortTitle: "Directions to Favourite Unleaded", systemImageName: "map" ) AppShortcut( intent: DirectionsToFavouriteFuelIntent(fuel: .e5), phrases: [ "Ask \(.applicationName) for directions to my favourite premium station", "Get directions to my favourite super unleaded station \(.applicationName)", ], shortTitle: "Directions to Favourite Premium", systemImageName: "map" ) AppShortcut( intent: DirectionsToFavouriteFuelIntent(fuel: .diesel), phrases: [ "Ask \(.applicationName) for directions to my favourite diesel station", "Get directions to my favourite diesel station \(.applicationName)", ], shortTitle: "Directions to Favourite Diesel", systemImageName: "map" ) } }