diff --git a/FuelBoard/SiriShortcuts.swift b/FuelBoard/SiriShortcuts.swift index 6b924b4..77a0d22 100644 --- a/FuelBoard/SiriShortcuts.swift +++ b/FuelBoard/SiriShortcuts.swift @@ -125,6 +125,67 @@ struct CheapestFuelIntent: AppIntent { } } +// 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 & 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.") + } + + let url = SiriCheapestLookup.mapsURL(latitude: station.lat, longitude: station.lng) + return .result(opensIntent: OpenURLIntent(url)) + } +} + +/// 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: - Snippets /// Price card shown in Shortcuts / Siri results. @@ -227,5 +288,48 @@ struct FuelBoardShortcuts: AppShortcutsProvider { shortTitle: "Cheapest Diesel", 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. + AppShortcut( + intent: DirectionsToCheapestFuelIntent(), + phrases: [ + "Ask \(.applicationName) for directions to the cheapest \(\.$fuel) near me", + ], + shortTitle: "Directions to Cheapest Fuel", + systemImageName: "map" + ) + + AppShortcut( + intent: DirectionsToCheapestFuelIntent(fuel: .e10), + phrases: [ + "Ask \(.applicationName) for directions to the cheapest unleaded near me", + "Get directions to the cheapest petrol near me \(.applicationName)", + ], + shortTitle: "Directions to Cheapest Unleaded", + systemImageName: "map" + ) + + AppShortcut( + intent: DirectionsToCheapestFuelIntent(fuel: .e5), + phrases: [ + "Ask \(.applicationName) for directions to the cheapest premium near me", + "Get directions to the cheapest super unleaded near me \(.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", + systemImageName: "map" + ) } } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 06327a7..3007ddc 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -470,6 +470,21 @@ final class SiriCheapestLookupTests: XCTestCase { XCTAssertNil(SiriCheapestLookup.cheapest(in: [], fuel: .e10, fromLat: 53.01, lng: -1.01, withinMiles: 5)) } + func testMapsURLIsUniversalAppleMapsLink() { + let url = SiriCheapestLookup.mapsURL(latitude: 53.7538, longitude: -1.8177) + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(url.host, "maps.apple.com") + XCTAssertEqual(url.query, "daddr=53.7538,-1.8177") + } + + func testMapsURLUsesUniversalLinkNotCustomScheme() { + // OpenURLIntent only opens universal links — a maps:// custom scheme + // would be rejected at runtime, so the https form is mandatory. + let url = SiriCheapestLookup.mapsURL(latitude: 0, longitude: 0) + XCTAssertEqual(url.scheme, "https", "custom schemes like maps:// must not be used") + XCTAssertTrue(url.absoluteString.hasPrefix("https://maps.apple.com/")) + } + func testFreshnessLabelUsesDataUpdatedStamp() { let label = SiriCheapestLookup.freshnessLabel( updated: "2026-08-14T10:31:36.000Z", diff --git a/Shared/SiriCheapestLookup.swift b/Shared/SiriCheapestLookup.swift index 666c73a..bc39365 100644 --- a/Shared/SiriCheapestLookup.swift +++ b/Shared/SiriCheapestLookup.swift @@ -59,4 +59,12 @@ enum SiriCheapestLookup { plain.formatOptions = [.withInternetDateTime] return plain.date(from: string) } + + /// 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 + /// and the share sheet. + static func mapsURL(latitude: Double, longitude: Double) -> URL { + URL(string: "https://maps.apple.com/?daddr=\(latitude),\(longitude)")! + } }