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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../Shared/SiriCheapestLookup.swift
|
||||||
@@ -406,3 +406,73 @@ final class DistanceLabelTests: XCTestCase {
|
|||||||
XCTAssertEqual(DistanceUnit.kilometers.displayMiles(15), 24) // 15 mi = 24.1 km
|
XCTAssertEqual(DistanceUnit.kilometers.displayMiles(15), 24) // 15 mi = 24.1 km
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class SiriCheapestLookupTests: XCTestCase {
|
||||||
|
private func station(_ id: String, lat: Double, lng: Double, _ prices: [FuelType: Double]) -> FuelStation {
|
||||||
|
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng, prices: prices, priceUpdated: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheapestPicksLowestPriceNotNearest() {
|
||||||
|
// Near station is pricier; far station is cheaper — price must win.
|
||||||
|
let stations = [
|
||||||
|
station("near", lat: 53.0, lng: -1.0, [.e10: 145.9]),
|
||||||
|
station("far", lat: 53.5, lng: -1.5, [.e10: 139.9]),
|
||||||
|
]
|
||||||
|
let result = SiriCheapestLookup.cheapest(in: stations, fuel: .e10, fromLat: 53.01, lng: -1.01)
|
||||||
|
XCTAssertEqual(result?.id, "far", "cheapest by price, not by distance")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheapestTieBreaksByDistance() {
|
||||||
|
let stations = [
|
||||||
|
station("near", lat: 53.0, lng: -1.0, [.e10: 140.0]),
|
||||||
|
station("far", lat: 53.9, lng: -1.9, [.e10: 140.0]),
|
||||||
|
]
|
||||||
|
let result = SiriCheapestLookup.cheapest(in: stations, fuel: .e10, fromLat: 53.01, lng: -1.01)
|
||||||
|
XCTAssertEqual(result?.id, "near", "equal prices resolve to the nearest station")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheapestSkipsStationsWithoutThatFuel() {
|
||||||
|
let stations = [
|
||||||
|
station("noDiesel", lat: 53.0, lng: -1.0, [.e10: 139.9]),
|
||||||
|
station("sellsDiesel", lat: 53.5, lng: -1.5, [.diesel: 149.9]),
|
||||||
|
]
|
||||||
|
let result = SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01)
|
||||||
|
XCTAssertEqual(result?.id, "sellsDiesel", "stations without the fuel are skipped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheapestReturnsNilWhenNoStationSellsFuel() {
|
||||||
|
let stations = [station("a", lat: 53.0, lng: -1.0, [.e10: 139.9])]
|
||||||
|
XCTAssertNil(SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.0, lng: -1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheapestReturnsNilForEmptyInput() {
|
||||||
|
XCTAssertNil(SiriCheapestLookup.cheapest(in: [], fuel: .e10, fromLat: 53.0, lng: -1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFreshnessLabelUsesDataUpdatedStamp() {
|
||||||
|
let label = SiriCheapestLookup.freshnessLabel(
|
||||||
|
updated: "2026-08-14T10:31:36.000Z",
|
||||||
|
lastRefresh: nil
|
||||||
|
)
|
||||||
|
XCTAssertTrue(label.hasPrefix("as of "), "stamp must be prefixed for the dialog")
|
||||||
|
XCTAssertFalse(label.contains("10:31"), "stamp is converted to the user's local time, not raw UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFreshnessLabelFallsBackToLastRefresh() {
|
||||||
|
let label = SiriCheapestLookup.freshnessLabel(updated: nil, lastRefresh: Date(timeIntervalSince1970: 0))
|
||||||
|
XCTAssertTrue(label.hasPrefix("as of "), "local refresh date is used when no GOV.UK stamp exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFreshnessLabelEmptyWithoutAnyDate() {
|
||||||
|
XCTAssertEqual(SiriCheapestLookup.freshnessLabel(updated: nil, lastRefresh: nil), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFreshnessLabelToleratesPlainISODate() {
|
||||||
|
// Some relays emit no fractional seconds.
|
||||||
|
let label = SiriCheapestLookup.freshnessLabel(
|
||||||
|
updated: "2026-08-14T10:31:36Z",
|
||||||
|
lastRefresh: nil
|
||||||
|
)
|
||||||
|
XCTAssertTrue(label.hasPrefix("as of "), "plain ISO 8601 still parses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure, testable logic behind the Siri "cheapest fuel near me" intent.
|
||||||
|
/// Foundation-only on purpose: `Shared/` joins both the app and widget
|
||||||
|
/// targets, and FuelBoardTests exercises this directly.
|
||||||
|
enum SiriCheapestLookup {
|
||||||
|
|
||||||
|
/// The cheapest station selling `fuel`, tie-broken by distance from the
|
||||||
|
/// given point. Stations without a price for `fuel` are skipped entirely.
|
||||||
|
/// Returns nil when no station sells the fuel.
|
||||||
|
static func cheapest(
|
||||||
|
in stations: [FuelStation],
|
||||||
|
fuel: FuelType,
|
||||||
|
fromLat lat: Double,
|
||||||
|
lng: Double
|
||||||
|
) -> FuelStation? {
|
||||||
|
stations
|
||||||
|
.filter { $0.prices[fuel] != nil }
|
||||||
|
.min { lhs, rhs in
|
||||||
|
guard let lp = lhs.prices[fuel], let rp = rhs.prices[fuel] else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lp != rp { return lp < rp }
|
||||||
|
return lhs.distanceKM(to: lat, lng2: lng) < rhs.distanceKM(to: lat, lng2: lng)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short freshness stamp for the dialog: "as of 6:30 AM".
|
||||||
|
/// Prefers the GOV.UK `data_updated` stamp carried by the relay (ISO 8601
|
||||||
|
/// with or without fractional seconds); falls back to the local
|
||||||
|
/// last-refresh date; returns "" when there is no data at all.
|
||||||
|
static func freshnessLabel(updated: String?, lastRefresh: Date?, now: Date = Date()) -> String {
|
||||||
|
let date: Date?
|
||||||
|
if let updated {
|
||||||
|
date = Self.parseISO(updated) ?? lastRefresh
|
||||||
|
} else {
|
||||||
|
date = lastRefresh
|
||||||
|
}
|
||||||
|
guard let date else { return "" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "h:mm a"
|
||||||
|
formatter.locale = Locale(identifier: "en_GB")
|
||||||
|
return "as of \(formatter.string(from: date))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseISO(_ string: String) -> Date? {
|
||||||
|
let withFraction = ISO8601DateFormatter()
|
||||||
|
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = withFraction.date(from: string) { return date }
|
||||||
|
let plain = ISO8601DateFormatter()
|
||||||
|
plain.formatOptions = [.withInternetDateTime]
|
||||||
|
return plain.date(from: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user