diff --git a/FuelBoard/SiriShortcuts.swift b/FuelBoard/SiriShortcuts.swift index 691ce84..4cbad2c 100644 --- a/FuelBoard/SiriShortcuts.swift +++ b/FuelBoard/SiriShortcuts.swift @@ -59,7 +59,7 @@ struct CheapestFuelIntent: AppIntent { self.fuel = fuel } - func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView & ReturnsValue { + func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetIntent & ReturnsValue { let stations = FuelStore.loadStations() guard !stations.isEmpty else { @@ -67,7 +67,7 @@ struct CheapestFuelIntent: AppIntent { return .result( value: "No price data yet", dialog: IntentDialog(stringLiteral: dialog), - view: FuelMessage(text: "No price data yet — open FuelBoard to download prices.") + snippetIntent: FuelMessageSnippetIntent(text: "No price data yet — open FuelBoard to download prices.") ) } @@ -76,7 +76,7 @@ struct CheapestFuelIntent: AppIntent { return .result( value: "Location unavailable", dialog: IntentDialog(stringLiteral: dialog), - view: FuelMessage(text: "Location unavailable — open FuelBoard to share your location.") + snippetIntent: FuelMessageSnippetIntent(text: "Location unavailable — open FuelBoard to share your location.") ) } @@ -96,7 +96,7 @@ struct CheapestFuelIntent: AppIntent { return .result( value: "No \(fuelName) stations within \(radiusText)", dialog: IntentDialog(stringLiteral: "I couldn't find any station selling \(fuelName) within \(radiusText) of you."), - view: FuelMessage(text: "No \(fuelName) stations within \(radiusText).") + snippetIntent: FuelMessageSnippetIntent(text: "No \(fuelName) stations within \(radiusText).") ) } @@ -114,12 +114,14 @@ struct CheapestFuelIntent: AppIntent { return .result( value: summary, dialog: IntentDialog(stringLiteral: dialog), - view: FuelPriceCard( + snippetIntent: FuelPriceSnippetIntent( stationName: station.name, fuelName: fuel.displayName, price: priceText, distance: distanceText, - freshness: freshness + freshness: freshness, + latitude: station.lat, + longitude: station.lng ) ) } @@ -221,7 +223,7 @@ struct FavouriteFuelPriceIntent: AppIntent { self.fuel = fuel } - func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView & ReturnsValue { + func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetIntent & ReturnsValue { let stations = FuelStore.loadStations() let favourites = FuelStore.loadFavourites() let fuelName = fuel.displayName.lowercased() @@ -231,7 +233,7 @@ struct FavouriteFuelPriceIntent: AppIntent { return .result( value: "No favourite \(fuelName) station", dialog: IntentDialog(stringLiteral: dialog), - view: FuelMessage(text: "No favourite \(fuelName) station — star one in FuelBoard first.") + snippetIntent: FuelMessageSnippetIntent(text: "No favourite \(fuelName) station — star one in FuelBoard first.") ) } @@ -240,7 +242,7 @@ struct FavouriteFuelPriceIntent: AppIntent { return .result( value: "No current price for \(favourite.station.name)", dialog: IntentDialog(stringLiteral: dialog), - view: FuelMessage(text: "No current price for \(favourite.station.name).") + snippetIntent: FuelMessageSnippetIntent(text: "No current price for \(favourite.station.name).") ) } @@ -273,12 +275,14 @@ struct FavouriteFuelPriceIntent: AppIntent { return .result( value: summary, dialog: IntentDialog(stringLiteral: dialog), - view: FuelPriceCard( + snippetIntent: FuelPriceSnippetIntent( stationName: favourite.station.name, fuelName: fuel.displayName, price: priceText, distance: distanceText, - freshness: freshness + freshness: freshness, + latitude: favourite.station.lat, + longitude: favourite.station.lng ) ) } @@ -337,13 +341,19 @@ struct DirectionsToFavouriteFuelIntent: AppIntent { // MARK: - Snippets -/// Price card shown in Shortcuts / Siri results. +/// 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) { @@ -364,9 +374,19 @@ struct FuelPriceCard: View { } } Spacer(minLength: 8) - Text(price) - .font(.title2.bold()) - .monospacedDigit() + 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) } @@ -388,6 +408,93 @@ struct FuelMessage: View { } } +/// 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 {