Siri: favourite price + favourite directions (top favourite per fuel); trim to 10 AppShortcuts (Apple cap)
This commit is contained in:
+13
-6
@@ -11,15 +11,16 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
|
||||
## P1 — Soon
|
||||
|
||||
- [ ] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
|
||||
- [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
|
||||
(App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first
|
||||
with freshness label ("as of 6am"), last-known-location fallback. Constraint:
|
||||
fresh-data path needs a reachable source off-LAN — cached answers work anywhere
|
||||
the app has previously fetched (see P3 data mirror). Interim: the relay on
|
||||
Tailscale (100.120.217.98:8789) is reachable off-LAN for testing; relay will
|
||||
move to a permanent box or the GitHub mirror long-term. *IN PROGRESS — MVP
|
||||
intent shipped (dialog + snippet + freshness label), closest phrase still
|
||||
pending.*
|
||||
move to a permanent box or the GitHub mirror long-term. *DONE — radius-scoped
|
||||
lookup `037c241` (real-data regression: GULF HISTON 100.9p 132 mi away),
|
||||
per-fuel literal-word shortcuts `e55078c`, names = app words `a3b6f37`, plus
|
||||
Directions-to-cheapest `caadf18` + spoken hand-off `aed99e3`.*
|
||||
- [ ] **Siri: "Closest [fuel] station"** — same plumbing as cheapest, sort by
|
||||
distance instead of price. Free second phrase in the same `AppShortcutsProvider`.
|
||||
|
||||
@@ -28,8 +29,14 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
- [ ] **Shortcuts: station price lookup** — `StationEntity` + `EntityQuery` so
|
||||
Shortcuts/Siri pick a station by name; `ReturnsValue<Double>` enables user
|
||||
automations ("get cheapest diesel → notify").
|
||||
- [ ] **Siri: favourite station price** — "What's diesel at my favourite station?"
|
||||
(cheap once `StationEntity` exists).
|
||||
- [x] **Siri: favourite station price** — "How much is my favourite unleaded?"
|
||||
`FavouriteFuelPriceIntent`: TOP favourite per fuel (first in the manual
|
||||
Favourites order — `SiriCheapestLookup.topFavourite`, refresh-vs-dump with
|
||||
offline cached-snapshot fallback); no location required (distance shown only
|
||||
when the saved fix exists). Also `DirectionsToFavouriteFuelIntent` (Maps
|
||||
hand-off, station announced first). 4+4 App Shortcuts (generic + per-fuel).
|
||||
*DONE — favourites already carried station snapshots, so no `StationEntity`
|
||||
dependency.*
|
||||
- [ ] **Siri: on-screen awareness (View Annotations + StationEntity)** — iOS
|
||||
26/27 Siri prefers the visible screen over the App Shortcut when the app is
|
||||
frontmost; without view annotations it answers from the rendered screen
|
||||
|
||||
+201
-33
@@ -67,7 +67,7 @@ struct CheapestFuelIntent: AppIntent {
|
||||
return .result(
|
||||
value: "No price data yet",
|
||||
dialog: IntentDialog(stringLiteral: dialog),
|
||||
view: CheapestFuelMessage(text: "No price data yet — open FuelBoard to download prices.")
|
||||
view: FuelMessage(text: "No price data yet — open FuelBoard to download prices.")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ struct CheapestFuelIntent: AppIntent {
|
||||
return .result(
|
||||
value: "Location unavailable",
|
||||
dialog: IntentDialog(stringLiteral: dialog),
|
||||
view: CheapestFuelMessage(text: "Location unavailable — open FuelBoard to share your location.")
|
||||
view: FuelMessage(text: "Location unavailable — open FuelBoard to share your location.")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ struct CheapestFuelIntent: AppIntent {
|
||||
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).")
|
||||
view: FuelMessage(text: "No \(fuelName) stations within \(radiusText).")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ struct CheapestFuelIntent: AppIntent {
|
||||
return .result(
|
||||
value: summary,
|
||||
dialog: IntentDialog(stringLiteral: dialog),
|
||||
view: CheapestFuelCard(
|
||||
view: FuelPriceCard(
|
||||
stationName: station.name,
|
||||
fuelName: fuel.displayName,
|
||||
price: priceText,
|
||||
@@ -195,10 +195,150 @@ private struct DirectionsError: LocalizedError {
|
||||
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 CheapestFuelCard: View {
|
||||
struct FuelPriceCard: View {
|
||||
let stationName: String
|
||||
let fuelName: String
|
||||
let price: String
|
||||
@@ -214,7 +354,7 @@ struct CheapestFuelCard: View {
|
||||
Text(stationName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text("\(fuelName) · \(distance)")
|
||||
Text(distance.isEmpty ? fuelName : "\(fuelName) · \(distance)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if !freshness.isEmpty {
|
||||
@@ -232,8 +372,9 @@ struct CheapestFuelCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback message card for the no-data / no-location / no-station cases.
|
||||
struct CheapestFuelMessage: View {
|
||||
/// Fallback message card for the no-data / no-location / no-station / no-
|
||||
/// favourite cases.
|
||||
struct FuelMessage: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
@@ -298,46 +439,73 @@ struct FuelBoardShortcuts: AppShortcutsProvider {
|
||||
systemImageName: "fuelpump"
|
||||
)
|
||||
|
||||
// Directions — separate intent so the price queries stay answer-only
|
||||
// and only the explicit directions query hands off to Apple Maps.
|
||||
// Phrases use direction verbs ("directions to", "get directions") to
|
||||
// stay distinct from the price shortcut phrases.
|
||||
// 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: DirectionsToCheapestFuelIntent(),
|
||||
intent: FavouriteFuelPriceIntent(fuel: .e10),
|
||||
phrases: [
|
||||
"Ask \(.applicationName) for directions to the cheapest \(\.$fuel) near me",
|
||||
"Ask \(.applicationName) how much my favourite unleaded costs",
|
||||
"Ask \(.applicationName) the price of my favourite petrol",
|
||||
],
|
||||
shortTitle: "Directions to Cheapest Fuel",
|
||||
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: DirectionsToCheapestFuelIntent(fuel: .e10),
|
||||
intent: DirectionsToFavouriteFuelIntent(fuel: .e5),
|
||||
phrases: [
|
||||
"Ask \(.applicationName) for directions to the cheapest unleaded near me",
|
||||
"Get directions to the cheapest petrol near me \(.applicationName)",
|
||||
"Ask \(.applicationName) for directions to my favourite premium station",
|
||||
"Get directions to my favourite super unleaded station \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Directions to Cheapest Unleaded",
|
||||
shortTitle: "Directions to Favourite Premium",
|
||||
systemImageName: "map"
|
||||
)
|
||||
|
||||
AppShortcut(
|
||||
intent: DirectionsToCheapestFuelIntent(fuel: .e5),
|
||||
intent: DirectionsToFavouriteFuelIntent(fuel: .diesel),
|
||||
phrases: [
|
||||
"Ask \(.applicationName) for directions to the cheapest premium near me",
|
||||
"Get directions to the cheapest super unleaded near me \(.applicationName)",
|
||||
"Ask \(.applicationName) for directions to my favourite diesel station",
|
||||
"Get directions to my favourite diesel station \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Directions to Cheapest Premium",
|
||||
systemImageName: "map"
|
||||
)
|
||||
|
||||
AppShortcut(
|
||||
intent: DirectionsToCheapestFuelIntent(fuel: .diesel),
|
||||
phrases: [
|
||||
"Ask \(.applicationName) for directions to the cheapest diesel near me",
|
||||
"Get directions to the cheapest diesel near me \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Directions to Cheapest Diesel",
|
||||
shortTitle: "Directions to Favourite Diesel",
|
||||
systemImageName: "map"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -485,6 +485,55 @@ final class SiriCheapestLookupTests: XCTestCase {
|
||||
XCTAssertTrue(url.absoluteString.hasPrefix("https://maps.apple.com/"))
|
||||
}
|
||||
|
||||
// MARK: Favourite selection
|
||||
|
||||
private func favourite(_ id: String, _ fuel: FuelType, _ prices: [FuelType: Double]) -> FavouriteEntry {
|
||||
FavouriteEntry(station: station(id, lat: 53.7, lng: -1.8, prices), fuel: fuel)
|
||||
}
|
||||
|
||||
// The top favourite for a fuel is the FIRST entry in the manual favourites
|
||||
// order for that fuel (the drag-and-drop order from the Favourites tab;
|
||||
// per-fuel blocks keep their position, so array order = per-fuel order).
|
||||
func testTopFavouriteIsFirstInManualOrderPerFuel() {
|
||||
let favs = [
|
||||
favourite("a", .e10, [.e10: 130]),
|
||||
favourite("b", .diesel, [.diesel: 145]),
|
||||
favourite("c", .e10, [.e10: 120]),
|
||||
]
|
||||
XCTAssertEqual(SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .e10)?.station.id, "a")
|
||||
XCTAssertEqual(SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .diesel)?.station.id, "b")
|
||||
XCTAssertNil(SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .e5))
|
||||
}
|
||||
|
||||
// The favourite's cached price snapshot is refreshed from the current
|
||||
// station dump when the station is present.
|
||||
func testTopFavouriteRefreshesPriceFromDump() {
|
||||
let favs = [favourite("a", .e10, [.e10: 130])]
|
||||
let dump = [station("a", lat: 53.7, lng: -1.8, [.e10: 122.9])]
|
||||
let top = SiriCheapestLookup.topFavourite(in: favs, from: dump, fuel: .e10)
|
||||
XCTAssertEqual(top?.station.prices[.e10], 122.9)
|
||||
}
|
||||
|
||||
// Stations missing from the dump keep their cached snapshot, so the
|
||||
// favourite still answers offline or when the station isn't in the
|
||||
// GOV.UK set.
|
||||
func testTopFavouriteKeepsCachedSnapshotWhenMissingFromDump() {
|
||||
let favs = [favourite("a", .e10, [.e10: 130])]
|
||||
let top = SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .e10)
|
||||
XCTAssertEqual(top?.station.prices[.e10], 130)
|
||||
}
|
||||
|
||||
// Manual reorder (the Favourites tab's drag-and-drop) changes which
|
||||
// favourite is "top" — the Siri answer follows the widget order.
|
||||
func testTopFavouriteFollowsManualReorder() {
|
||||
let favs = [
|
||||
favourite("a", .e10, [.e10: 130]),
|
||||
favourite("c", .e10, [.e10: 120]),
|
||||
]
|
||||
let reordered = FuelStore.reorderedFavourites(favs, fuel: .e10, fromOffsets: IndexSet(integer: 0), toOffset: 2)
|
||||
XCTAssertEqual(SiriCheapestLookup.topFavourite(in: reordered, from: [], fuel: .e10)?.station.id, "c")
|
||||
}
|
||||
|
||||
func testFreshnessLabelUsesDataUpdatedStamp() {
|
||||
let label = SiriCheapestLookup.freshnessLabel(
|
||||
updated: "2026-08-14T10:31:36.000Z",
|
||||
|
||||
@@ -60,6 +60,23 @@ enum SiriCheapestLookup {
|
||||
return plain.date(from: string)
|
||||
}
|
||||
|
||||
/// The user's TOP favourite for `fuel`: the first entry in the manual
|
||||
/// favourites order (the Favourites tab's drag-and-drop order IS the
|
||||
/// widget/priority order — the first station of a fuel is its "single
|
||||
/// widget" favourite). Favourites are refreshed against the current
|
||||
/// station dump first so prices are as fresh as the cache allows;
|
||||
/// stations missing from the dump keep their cached snapshot, so the
|
||||
/// answer still works fully offline. Returns nil when the user has no
|
||||
/// favourite for that fuel.
|
||||
static func topFavourite(
|
||||
in favourites: [FavouriteEntry],
|
||||
from stations: [FuelStation],
|
||||
fuel: FuelType
|
||||
) -> FavouriteEntry? {
|
||||
let refreshed = FuelStore.refreshedFavourites(favourites, from: stations)
|
||||
return refreshed.first { $0.fuel == fuel }
|
||||
}
|
||||
|
||||
/// Apple Maps URL for turn-by-turn directions to a point. MUST be the
|
||||
/// universal-link form (https://maps.apple.com) — OpenURLIntent rejects
|
||||
/// custom schemes like `maps://`, and the https form survives copy/paste
|
||||
|
||||
Reference in New Issue
Block a user