Siri: cheapest [fuel] near me intent + App Shortcuts (cached-first, freshness label)
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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. 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 one phrase per the review guidance.
|
||||
|
||||
// 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
|
||||
|
||||
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.")
|
||||
)
|
||||
}
|
||||
|
||||
guard let station = SiriCheapestLookup.cheapest(
|
||||
in: stations,
|
||||
fuel: fuel,
|
||||
fromLat: coordinate.lat,
|
||||
lng: coordinate.lng
|
||||
), let price = station.prices[fuel] else {
|
||||
let fuelName = fuel.displayName.lowercased()
|
||||
return .result(
|
||||
value: "No \(fuelName) stations found",
|
||||
dialog: IntentDialog(stringLiteral: "I couldn't find any station selling \(fuelName) near you."),
|
||||
view: CheapestFuelMessage(text: "No \(fuelName) stations found.")
|
||||
)
|
||||
}
|
||||
|
||||
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] {
|
||||
AppShortcut(
|
||||
intent: CheapestFuelIntent(),
|
||||
phrases: [
|
||||
"Ask \(.applicationName) what's the cheapest \(\.$fuel) near me",
|
||||
"Find the cheapest \(\.$fuel) near me \(.applicationName)",
|
||||
"Cheapest \(\.$fuel) near me \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Cheapest Fuel",
|
||||
systemImageName: "fuelpump"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user