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
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user