Siri: interactive price-card snippet (iOS 26 SnippetIntent) with Directions button → Apple Maps; zero AppShortcut slots
This commit is contained in:
+122
-15
@@ -59,7 +59,7 @@ struct CheapestFuelIntent: AppIntent {
|
||||
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()
|
||||
|
||||
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<String> {
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetIntent & ReturnsValue<String> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user