feat: honour Tap station action on notification and Live Activity taps

Notification body/action taps route through StationTapAction:
Open map opens Apple Maps (existing); More info presents the
in-app detail sheet (falls back to the map sheet when the
station is not in cache).

Live Activity Lock Screen card and island station row pick
their Link destination the same way: Maps URL for Open map,
fuelboard://station deep link for More info, handled in
FuelBoardApp and observed by ContentView.

Also adds the small non-interactive map preview above the
Directions button in the More info sheet.
This commit is contained in:
FuelBoard Contributor
2026-09-16 13:11:36 +01:00
parent 1299b4a004
commit 465e6cd4d4
6 changed files with 108 additions and 14 deletions
+12
View File
@@ -235,6 +235,18 @@ struct ContentView: View {
.sheet(item: $monitor.pendingStationMap) { request in .sheet(item: $monitor.pendingStationMap) { request in
StationMapView(request: request) StationMapView(request: request)
} }
.sheet(item: $monitor.pendingDetailStation) { station in
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit
)
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
guard let id = note.userInfo?["stationID"] as? String,
let station = stations.first(where: { $0.id == id }) else { return }
monitor.pendingDetailStation = station
}
) )
} }
+18 -2
View File
@@ -69,9 +69,25 @@ struct FuelBoardApp: App {
/// `http://maps.apple.com` form is also handled, from very old timelines. /// `http://maps.apple.com` form is also handled, from very old timelines.
/// In CarPlay the widget uses the same `maps://` URL, which routes to /// In CarPlay the widget uses the same `maps://` URL, which routes to
/// Apple Maps without ever launching this app. /// Apple Maps without ever launching this app.
/// A `fuelboard://station?stationID=` link (from a Live Activity tap when
/// Settings Tap station = More info) posts a notification that
/// ContentView observes to present the detail sheet.
private func handleOpenURL(_ url: URL) { private func handleOpenURL(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let items = components.queryItems else { return }
if components.scheme == "fuelboard",
components.host == "station",
let id = components.queryItems?.first(where: { $0.name == "stationID" })?.value {
NotificationCenter.default.post(
name: .fuelBoardShowStationDetail,
object: nil,
userInfo: ["stationID": id]
)
return
}
guard let items = components.queryItems
else { return } else { return }
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com). // Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
+29 -5
View File
@@ -84,6 +84,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
@Published var lastAlert: String? @Published var lastAlert: String?
@Published private(set) var lastTestResult: String? @Published private(set) var lastTestResult: String?
@Published var pendingStationMap: StationMapRequest? @Published var pendingStationMap: StationMapRequest?
/// Station tapped via notification when Settings Tap station = More info.
/// ContentView presents this as a StationDetailView sheet.
@Published var pendingDetailStation: FuelStation?
/// Debug-only snapshot for the Settings Debug section. Recomputed on /// Debug-only snapshot for the Settings Debug section. Recomputed on
/// every location/stations change via `refreshDebugStatus()`. /// every location/stations change via `refreshDebugStatus()`.
@Published private(set) var debugStatus: DebugLocationStatus? @Published private(set) var debugStatus: DebugLocationStatus?
@@ -791,9 +794,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
} }
} }
/// Tapping a notification opens Apple Maps directions to the station from /// Tapping a notification honours Settings Tap station: Open map opens
/// the user's current location. If Apple Maps can't be opened (e.g. inside /// Apple Maps directions to the station from the user's current location;
/// LiveContainer), an in-app map sheet with directions is shown instead. /// More info opens the station's detail sheet in the app instead. If Apple
/// Maps can't be opened (e.g. inside LiveContainer), an in-app map sheet
/// with directions is shown instead.
/// ///
/// When the notification carried tie-choice action buttons, the tapped /// When the notification carried tie-choice action buttons, the tapped
/// button's identifier ("tie_0", "tie_1", ) selects the corresponding /// button's identifier ("tie_0", "tie_1", ) selects the corresponding
@@ -818,9 +823,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
let tieName = ties[index]["name"] as? String, let tieName = ties[index]["name"] as? String,
let tieLat = ties[index]["lat"] as? Double, let tieLat = ties[index]["lat"] as? Double,
let tieLng = ties[index]["lng"] as? Double { let tieLng = ties[index]["lng"] as? Double {
openDirections(to: tieName, latitude: tieLat, longitude: tieLng) routeNotificationTap(name: tieName, latitude: tieLat, longitude: tieLng)
} else if !isTie, let name, let lat, let lng { } else if !isTie, let name, let lat, let lng {
openDirections(to: name, latitude: lat, longitude: lng) routeNotificationTap(name: name, latitude: lat, longitude: lng)
} }
// Tie body tap: intentionally no directions the user must pick // Tie body tap: intentionally no directions the user must pick
// an option button to choose a station. // an option button to choose a station.
@@ -828,6 +833,25 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
} }
} }
/// Routes a resolved notification tap through the user's Tap station
/// preference: Open map Apple Maps; More info in-app detail sheet
/// (falling back to the map sheet when the station isn't in cache).
@MainActor
private func routeNotificationTap(name: String, latitude: Double, longitude: Double) {
guard FuelStore.loadStationTapAction() == .showDetails else {
openDirections(to: name, latitude: latitude, longitude: longitude)
return
}
if let station = stations.first(where: { $0.lat == latitude && $0.lng == longitude })
?? stations.first(where: { $0.name == name }) {
pendingDetailStation = station
} else {
// Station not in cache (e.g. data refreshed since the alert fired)
// fall back to the map sheet so the tap still shows something.
pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude)
}
}
/// Opens Apple Maps with driving directions to the station; falls back to /// Opens Apple Maps with driving directions to the station; falls back to
/// the in-app map sheet when the hand-off fails. /// the in-app map sheet when the hand-off fails.
@MainActor @MainActor
+21 -3
View File
@@ -1,19 +1,37 @@
import SwiftUI import SwiftUI
import MapKit
import UIKit import UIKit
/// Detail sheet for a tapped station (when Settings Tap station = More info). /// Detail sheet for a tapped station (when Settings Tap station = More info).
/// Shows the station name, address, distance from the current location, and a /// Shows a small map preview, the station name, address, distance from the
/// Directions button that opens Apple Maps the same destination a direct tap /// current location, and a Directions button that opens Apple Maps the same
/// would have opened. /// destination a direct tap would have opened.
struct StationDetailView: View { struct StationDetailView: View {
let station: FuelStation let station: FuelStation
let location: Coordinate? let location: Coordinate?
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
private var stationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
Section {
Map(position: .constant(.region(MKCoordinateRegion(
center: stationCoordinate,
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
)))) {
Marker(station.name, coordinate: stationCoordinate)
.tint(.red)
}
.frame(height: 180)
.clipShape(RoundedRectangle(cornerRadius: 12))
.allowsHitTesting(false)
.listRowInsets(EdgeInsets())
}
Section { Section {
LabeledContent("Name", value: station.name) LabeledContent("Name", value: station.name)
LabeledContent("Address", value: "\(station.address), \(station.postcode)") LabeledContent("Address", value: "\(station.address), \(station.postcode)")
+1
View File
@@ -3,6 +3,7 @@ import WatchConnectivity
extension Notification.Name { extension Notification.Name {
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested") static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
} }
@MainActor @MainActor
@@ -84,7 +84,7 @@ private struct FuelBoardLiveActivityView: View {
@State private var slotWidth: CGFloat = 400 @State private var slotWidth: CGFloat = 400
var body: some View { var body: some View {
Link(destination: context.state.mapsURL) { Link(destination: tapDestination) {
Group { Group {
// Branch on the ACTUAL proposed width. iPhone/iPad offer the // Branch on the ACTUAL proposed width. iPhone/iPad offer the
// full Lock Screen width (>= threshold) rich card, no matter // full Lock Screen width (>= threshold) rich card, no matter
@@ -210,10 +210,27 @@ private struct FuelBoardLiveActivityStationView: View {
let context: ActivityViewContext<FuelBoardLiveActivityAttributes> let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
var body: some View { var body: some View {
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
Text("\(context.state.stationName) · \(context.state.distanceText)") Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
} }
}
}
/// Tap routing shared by the Lock Screen card and the island: honours
/// Settings Tap station (Open map Maps, More info in-app detail).
enum FuelBoardLiveActivityTap {
static func tapDestination(for state: FuelBoardLiveActivityAttributes.ContentState) -> URL {
FuelStore.loadStationTapAction() == .showDetails ? state.detailURL : state.mapsURL
}
}
private extension FuelBoardLiveActivityView {
/// Lock Screen tap honours Settings Tap station.
var tapDestination: URL {
FuelBoardLiveActivityTap.tapDestination(for: context.state)
}
} }
// MARK: - ContentState display helpers // MARK: - ContentState display helpers
@@ -235,4 +252,10 @@ extension FuelBoardLiveActivityAttributes.ContentState {
var mapsURL: URL { var mapsURL: URL {
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")! URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
} }
/// In-app deep link to the station's detail sheet, used when
/// Settings Tap station = More info.
var detailURL: URL {
URL(string: "fuelboard://station?stationID=\(stationID)")!
}
} }