Files
fuelboard/FuelBoard/SiriShortcuts.swift
T
FuelBoard Contributor ed41df46de siri: drop generic shortcut and broaden fuel-word phrases
Siri was hit-and-miss on 'unleaded near me' (\u2018can't do that, searching
in app\u2019 fallback). The generic parameterized AppShortcut ('cheapest
${fuel}') competed with the fixed-fuel literal-word shortcuts for the same
intent, degrading NLU ranking; it was already documented as flaky. Remove it
(now 9 shortcuts, under the 10 cap) and add natural phrase variants for
unleaded/petrol ('What's the cheapest unleaded near me', 'Cheapest unleaded
near me', 'Where's the cheapest petrol'). All phrases keep .applicationName
per the iOS 26 metadata requirement. Build-verified in extract.actionsdata:
generic gone, new phrases registered.
2026-08-20 19:27:34 +01:00

611 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] {
// NOTE (2026-08-20): the generic parameterized shortcut ("cheapest
// ${fuel}") was REMOVED. It competed with the fixed-fuel literal-word
// shortcuts for the same intent (parameter resolution was already flaky
// on-device), which degraded Siri's NLU ranking and caused the
// hit-and-miss "can't do that, searching in app" fallback. Now Siri
// routes on literal fuel words only, and the freed slot keeps us under
// the 10-shortcut cap. Every phrase carries .applicationName (iOS 26
// metadata-processor requirement) and mirrors how people actually ask.
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)",
"What's the cheapest unleaded near me \(.applicationName)",
"What's the cheapest petrol near me \(.applicationName)",
"Cheapest unleaded near me \(.applicationName)",
"Where's the cheapest petrol \(.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"
)
}
}