Files
fuelboard/FuelBoard/SiriShortcuts.swift
T

230 lines
8.9 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).
/// - Fuel case labels use UK speech ("Petrol", "Super Unleaded") so Siri's
/// phrase matching maps how people actually ask; the app UI's own
/// FuelType.displayName ("Unleaded") is unaffected.
// MARK: - Fuel parameter
extension FuelType: AppEnum {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [
.e10: "Petrol",
.e5: "Super Unleaded",
.diesel: "Diesel",
]
}
// 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 & ShowsSnippetView & 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),
view: CheapestFuelMessage(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),
view: CheapestFuelMessage(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."),
view: CheapestFuelMessage(text: "No \(fuelName) stations within \(radiusText).")
)
}
let distanceKM = station.distanceKM(to: coordinate.lat, lng2: coordinate.lng)
let distanceText = FuelStore.loadDistanceUnit().format(distanceKM)
let priceText = String(format: "£%.3f", price / 100)
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 \(priceText), \(distanceText) away\(freshnessClause)."
return .result(
value: summary,
dialog: IntentDialog(stringLiteral: dialog),
view: CheapestFuelCard(
stationName: station.name,
fuelName: fuel.displayName,
price: priceText,
distance: distanceText,
freshness: freshness
)
)
}
}
// MARK: - Snippets
/// Price card shown in Shortcuts / Siri results.
struct CheapestFuelCard: View {
let stationName: String
let fuelName: String
let price: String
let distance: String
let freshness: String
var body: some View {
HStack(spacing: 14) {
Image(systemName: "fuelpump.fill")
.font(.title2)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
Text(stationName)
.font(.headline)
.lineLimit(1)
Text("\(fuelName) · \(distance)")
.font(.subheadline)
.foregroundStyle(.secondary)
if !freshness.isEmpty {
Text(freshness)
.font(.caption)
.foregroundStyle(.tertiary)
}
}
Spacer(minLength: 8)
Text(price)
.font(.title2.bold())
.monospacedDigit()
}
.padding(12)
}
}
/// Fallback message card for the no-data / no-location / no-station cases.
struct CheapestFuelMessage: View {
let text: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text(text)
.font(.subheadline)
}
.padding(12)
}
}
// 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)",
],
shortTitle: "Cheapest Petrol",
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 Super Unleaded",
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"
)
}
}