Files
fuelboard/FuelBoard/SiriShortcuts.swift

612 lines
25 KiB
Swift

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: - 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<String> {
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 = FuelStore.priceText(price)
let priceSpoken = FuelStore.priceTextSpoken(price)
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 \(priceSpoken), \(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<String> {
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 = FuelStore.priceText(price)
let priceSpoken = FuelStore.priceTextSpoken(price)
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 \(priceSpoken)\(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 {
VStack(alignment: .leading, spacing: 10) {
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)
Text(price)
.font(.title2.bold())
.monospacedDigit()
}
if let directionsIntent {
Button(intent: directionsIntent) {
Label("Directions", systemImage: "map.fill")
.font(.footnote.weight(.medium))
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
.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 — per-fuel CHEAPEST directions carry the App Shortcut
// phrases (the user's "directions to the cheapest X" asks). Apple caps
// app shortcuts at 10 (the metadata processor hard-fails past that);
// the favourite-directions path no longer needs phrases because every
// favourite price card carries the interactive Directions button
// (OpenDirectionsIntent, zero slots) — so the favourite directions
// intents below remain in code, still runnable from the Shortcuts
// action list and via the card button, just without Siri phrases.
// Phrases use direction verbs ("directions to", "get directions") so
// they never collide with the price shortcut phrases.
AppShortcut(
intent: DirectionsToCheapestFuelIntent(fuel: .e10),
phrases: [
"Ask \(.applicationName) for directions to the cheapest unleaded near me",
"Get directions to the cheapest petrol near me \(.applicationName)",
],
shortTitle: "Directions to Cheapest Unleaded",
systemImageName: "map"
)
AppShortcut(
intent: DirectionsToCheapestFuelIntent(fuel: .e5),
phrases: [
"Ask \(.applicationName) for directions to the cheapest premium near me",
"Get directions to the cheapest super unleaded near me \(.applicationName)",
],
shortTitle: "Directions to Cheapest Premium",
systemImageName: "map"
)
AppShortcut(
intent: DirectionsToCheapestFuelIntent(fuel: .diesel),
phrases: [
"Ask \(.applicationName) for directions to the cheapest diesel near me",
"Get directions to the cheapest diesel near me \(.applicationName)",
],
shortTitle: "Directions to Cheapest Diesel",
systemImageName: "map"
)
}
}