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 { 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" ) } }