513 lines
21 KiB
Swift
513 lines
21 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: - Fuel parameter
|
|
|
|
extension FuelType: AppEnum {
|
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
|
|
|
|
static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [
|
|
.e10: "Unleaded",
|
|
.e5: "Premium",
|
|
.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: FuelMessage(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: FuelMessage(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: FuelMessage(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: FuelPriceCard(
|
|
stationName: station.name,
|
|
fuelName: fuel.displayName,
|
|
price: priceText,
|
|
distance: distanceText,
|
|
freshness: freshness
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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 & ShowsSnippetView & 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),
|
|
view: FuelMessage(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),
|
|
view: FuelMessage(text: "No current price for \(favourite.station.name).")
|
|
)
|
|
}
|
|
|
|
let priceText = String(format: "£%.3f", price / 100)
|
|
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 \(priceText)\(distanceClause)\(freshnessClause)."
|
|
|
|
return .result(
|
|
value: summary,
|
|
dialog: IntentDialog(stringLiteral: dialog),
|
|
view: FuelPriceCard(
|
|
stationName: favourite.station.name,
|
|
fuelName: fuel.displayName,
|
|
price: priceText,
|
|
distance: distanceText,
|
|
freshness: freshness
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
struct FuelPriceCard: 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(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()
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
|
|
// 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 — only the FAVOURITE directions carry App Shortcut
|
|
// phrases: Apple caps app shortcuts at 10 (the metadata processor
|
|
// hard-fails past that), and the per-fuel cheapest-price + favourite
|
|
// price/directions entries cover the user's actual asks. The cheapest-
|
|
// directions and generic favourite intents below remain in code —
|
|
// still runnable from the Shortcuts action list, just without Siri
|
|
// phrases. Phrases use direction verbs ("directions to", "get
|
|
// directions") so they never collide with the price shortcut phrases.
|
|
AppShortcut(
|
|
intent: DirectionsToFavouriteFuelIntent(fuel: .e10),
|
|
phrases: [
|
|
"Ask \(.applicationName) for directions to my favourite unleaded station",
|
|
"Get directions to my favourite petrol station \(.applicationName)",
|
|
],
|
|
shortTitle: "Directions to Favourite Unleaded",
|
|
systemImageName: "map"
|
|
)
|
|
|
|
AppShortcut(
|
|
intent: DirectionsToFavouriteFuelIntent(fuel: .e5),
|
|
phrases: [
|
|
"Ask \(.applicationName) for directions to my favourite premium station",
|
|
"Get directions to my favourite super unleaded station \(.applicationName)",
|
|
],
|
|
shortTitle: "Directions to Favourite Premium",
|
|
systemImageName: "map"
|
|
)
|
|
|
|
AppShortcut(
|
|
intent: DirectionsToFavouriteFuelIntent(fuel: .diesel),
|
|
phrases: [
|
|
"Ask \(.applicationName) for directions to my favourite diesel station",
|
|
"Get directions to my favourite diesel station \(.applicationName)",
|
|
],
|
|
shortTitle: "Directions to Favourite Diesel",
|
|
systemImageName: "map"
|
|
)
|
|
}
|
|
}
|