Siri: interactive price-card snippet (iOS 26 SnippetIntent) with Directions button → Apple Maps; zero AppShortcut slots

This commit is contained in:
FuelBoard Contributor
2026-08-14 14:38:14 +01:00
parent b458d92972
commit 6d7254ebba
+122 -15
View File
@@ -59,7 +59,7 @@ struct CheapestFuelIntent: AppIntent {
self.fuel = fuel self.fuel = fuel
} }
func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView & ReturnsValue<String> { func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetIntent & ReturnsValue<String> {
let stations = FuelStore.loadStations() let stations = FuelStore.loadStations()
guard !stations.isEmpty else { guard !stations.isEmpty else {
@@ -67,7 +67,7 @@ struct CheapestFuelIntent: AppIntent {
return .result( return .result(
value: "No price data yet", value: "No price data yet",
dialog: IntentDialog(stringLiteral: dialog), 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( return .result(
value: "Location unavailable", value: "Location unavailable",
dialog: IntentDialog(stringLiteral: dialog), 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( return .result(
value: "No \(fuelName) stations within \(radiusText)", value: "No \(fuelName) stations within \(radiusText)",
dialog: IntentDialog(stringLiteral: "I couldn't find any station selling \(fuelName) within \(radiusText) of you."), 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( return .result(
value: summary, value: summary,
dialog: IntentDialog(stringLiteral: dialog), dialog: IntentDialog(stringLiteral: dialog),
view: FuelPriceCard( snippetIntent: FuelPriceSnippetIntent(
stationName: station.name, stationName: station.name,
fuelName: fuel.displayName, fuelName: fuel.displayName,
price: priceText, price: priceText,
distance: distanceText, distance: distanceText,
freshness: freshness freshness: freshness,
latitude: station.lat,
longitude: station.lng
) )
) )
} }
@@ -221,7 +223,7 @@ struct FavouriteFuelPriceIntent: AppIntent {
self.fuel = fuel self.fuel = fuel
} }
func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView & ReturnsValue<String> { func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetIntent & ReturnsValue<String> {
let stations = FuelStore.loadStations() let stations = FuelStore.loadStations()
let favourites = FuelStore.loadFavourites() let favourites = FuelStore.loadFavourites()
let fuelName = fuel.displayName.lowercased() let fuelName = fuel.displayName.lowercased()
@@ -231,7 +233,7 @@ struct FavouriteFuelPriceIntent: AppIntent {
return .result( return .result(
value: "No favourite \(fuelName) station", value: "No favourite \(fuelName) station",
dialog: IntentDialog(stringLiteral: dialog), 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( return .result(
value: "No current price for \(favourite.station.name)", value: "No current price for \(favourite.station.name)",
dialog: IntentDialog(stringLiteral: dialog), 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( return .result(
value: summary, value: summary,
dialog: IntentDialog(stringLiteral: dialog), dialog: IntentDialog(stringLiteral: dialog),
view: FuelPriceCard( snippetIntent: FuelPriceSnippetIntent(
stationName: favourite.station.name, stationName: favourite.station.name,
fuelName: fuel.displayName, fuelName: fuel.displayName,
price: priceText, price: priceText,
distance: distanceText, distance: distanceText,
freshness: freshness freshness: freshness,
latitude: favourite.station.lat,
longitude: favourite.station.lng
) )
) )
} }
@@ -337,13 +341,19 @@ struct DirectionsToFavouriteFuelIntent: AppIntent {
// MARK: - Snippets // 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 { struct FuelPriceCard: View {
let stationName: String let stationName: String
let fuelName: String let fuelName: String
let price: String let price: String
let distance: String let distance: String
let freshness: String let freshness: String
var directionsIntent: OpenDirectionsIntent? = nil
var body: some View { var body: some View {
HStack(spacing: 14) { HStack(spacing: 14) {
@@ -364,9 +374,19 @@ struct FuelPriceCard: View {
} }
} }
Spacer(minLength: 8) Spacer(minLength: 8)
Text(price) VStack(alignment: .trailing, spacing: 6) {
.font(.title2.bold()) Text(price)
.monospacedDigit() .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) .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 // MARK: - App Shortcuts
struct FuelBoardShortcuts: AppShortcutsProvider { struct FuelBoardShortcuts: AppShortcutsProvider {