Author SHA1 Message Date
FuelBoard Contributor 3c83c9b518 fix: shorten report confirmation action 2026-09-17 22:30:25 +01:00
FuelBoard Contributor 6c8fd8b0de feat: add GOV.UK report issue action 2026-09-17 22:18:27 +01:00
FuelBoard Contributor 44cbd3b1e4 feat: route all Live Activity island taps through Tap station action
Expanded leading/trailing, compact and minimal island views had
no Link, so tapping them just opened the app with no destination.
Every island region now links to the same tap destination as the
Lock Screen card (Maps vs in-app detail).

Tap hint captions follow the setting too: Tap for details vs
Tap for directions.
2026-09-16 16:30:27 +01:00
FuelBoard Contributor 20fe251b8e feat: honour Tap station action in widgets
Small widgetURL and medium list Links route through
StationTapAction via FuelStation.tapDestinationURL: Open map
keeps the Maps URL, More info uses a fuelboard://station
deep link (stationID plus coordinate/name fallback).

Deep link carries fallback coords; ContentView presents the
detail sheet when the station is cached, else the map sheet.
Live Activity detailURL upgraded to the same rich link.
2026-09-16 16:15:07 +01:00
FuelBoard Contributor 465e6cd4d4 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.
2026-09-16 13:11:36 +01:00
10 changed files with 219 additions and 27 deletions
+24
View File
@@ -235,6 +235,30 @@ struct ContentView: View {
.sheet(item: $monitor.pendingStationMap) { request in
StationMapView(request: request)
}
.sheet(item: $monitor.pendingDetailStation) { station in
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit
)
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
let info = note.userInfo ?? [:]
if let id = info["stationID"] as? String,
let station = stations.first(where: { $0.id == id }) {
monitor.pendingDetailStation = station
return
}
// Station left the cache fall back to the map sheet so
// the tap still shows something.
if let lat = info["stationLat"] as? Double,
let lng = info["stationLng"] as? Double {
let name = info["stationName"] as? String ?? "Station"
monitor.pendingStationMap = StationMapRequest(
name: name, latitude: lat, longitude: lng
)
}
}
)
}
+33 -2
View File
@@ -69,9 +69,40 @@ struct FuelBoardApp: App {
/// `http://maps.apple.com` form is also handled, from very old timelines.
/// In CarPlay the widget uses the same `maps://` URL, which routes to
/// Apple Maps without ever launching this app.
/// A `fuelboard://station?stationID=&lat=&lng=&name=` link (from a
/// widget or Live Activity tap when Settings Tap station = More info)
/// posts a notification that ContentView observes to present the detail
/// sheet (falling back to the map sheet when the station has left
/// the cache).
private func handleOpenURL(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let items = components.queryItems
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return }
if components.scheme == "fuelboard",
components.host == "station" {
let items = components.queryItems ?? []
var userInfo: [String: Any] = [:]
if let id = items.first(where: { $0.name == "stationID" })?.value {
userInfo["stationID"] = id
}
if let lat = items.first(where: { $0.name == "lat" })?.value.flatMap(Double.init) {
userInfo["stationLat"] = lat
}
if let lng = items.first(where: { $0.name == "lng" })?.value.flatMap(Double.init) {
userInfo["stationLng"] = lng
}
if let name = items.first(where: { $0.name == "name" })?.value {
userInfo["stationName"] = name
}
NotificationCenter.default.post(
name: .fuelBoardShowStationDetail,
object: nil,
userInfo: userInfo
)
return
}
guard let items = components.queryItems
else { return }
// 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 private(set) var lastTestResult: String?
@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
/// every location/stations change via `refreshDebugStatus()`.
@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
/// the user's current location. If Apple Maps can't be opened (e.g. inside
/// LiveContainer), an in-app map sheet with directions is shown instead.
/// Tapping a notification honours Settings Tap station: Open map opens
/// Apple Maps directions to the station from the user's current location;
/// 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
/// 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 tieLat = ties[index]["lat"] 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 {
openDirections(to: name, latitude: lat, longitude: lng)
routeNotificationTap(name: name, latitude: lat, longitude: lng)
}
// Tie body tap: intentionally no directions the user must pick
// 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
/// the in-app map sheet when the hand-off fails.
@MainActor
+37 -3
View File
@@ -1,19 +1,40 @@
import SwiftUI
import MapKit
import UIKit
/// Detail sheet for a tapped station (when Settings Tap station = More info).
/// Shows the station name, address, distance from the current location, and a
/// Directions button that opens Apple Maps the same destination a direct tap
/// would have opened.
/// Shows a small map preview, the station name, address, distance from the
/// current location, and a Directions button that opens Apple Maps the same
/// destination a direct tap would have opened.
struct StationDetailView: View {
let station: FuelStation
let location: Coordinate?
let distanceUnit: DistanceUnit
@Environment(\.dismiss) private var dismiss
@State private var showingReportIssueConfirmation = false
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
private var stationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
}
var body: some View {
NavigationStack {
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 {
LabeledContent("Name", value: station.name)
LabeledContent("Address", value: "\(station.address), \(station.postcode)")
@@ -33,10 +54,23 @@ struct StationDetailView: View {
} label: {
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
}
Button(role: .destructive) {
showingReportIssueConfirmation = true
} label: {
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
}
}
}
.navigationTitle(station.name)
.navigationBarTitleDisplayMode(.inline)
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Report") {
UIApplication.shared.open(reportIssueURL)
}
} message: {
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
+1
View File
@@ -3,6 +3,7 @@ import WatchConnectivity
extension Notification.Name {
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
}
@MainActor
+5
View File
@@ -147,6 +147,11 @@
"You" = "You";
"Done" = "Done";
"Directions" = "Directions";
"Report issue" = "Report issue";
"Report an error?" = "Report an error?";
"Cancel" = "Cancel";
"Report" = "Report";
"Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser." = "Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.";
/* Station row */
"best" = "best";
@@ -2,7 +2,8 @@
//
// Lives in the widget extension (the standard host for ActivityConfiguration).
// Shows the cheapest station for the pinned fuel within the app's chosen
// radius. Tapping anywhere opens Apple Maps directions to that station.
// radius. Tapping honours Settings Tap station: Open map opens Apple Maps
// directions to that station, More info opens the detail sheet in the app.
//
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
@@ -31,22 +32,32 @@ struct FuelBoardLiveActivity: Widget {
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
FuelBoardLiveActivityCompactView(context: context)
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
FuelBoardLiveActivityCompactView(context: context)
}
}
DynamicIslandExpandedRegion(.trailing) {
FuelBoardLiveActivityPriceView(context: context)
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
FuelBoardLiveActivityPriceView(context: context)
}
}
DynamicIslandExpandedRegion(.bottom) {
FuelBoardLiveActivityStationView(context: context)
}
} compactLeading: {
Image(systemName: "fuelpump.fill")
.foregroundStyle(context.state.fuel.tintColor)
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
Image(systemName: "fuelpump.fill")
.foregroundStyle(context.state.fuel.tintColor)
}
} compactTrailing: {
FuelBoardLiveActivityPriceView(context: context)
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
FuelBoardLiveActivityPriceView(context: context)
}
} minimal: {
Text(context.state.priceText)
.font(.caption2.bold().monospacedDigit())
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
Text(context.state.priceText)
.font(.caption2.bold().monospacedDigit())
}
}
}
.supplementalActivityFamilies([.small])
@@ -84,7 +95,7 @@ private struct FuelBoardLiveActivityView: View {
@State private var slotWidth: CGFloat = 400
var body: some View {
Link(destination: context.state.mapsURL) {
Link(destination: tapDestination) {
Group {
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
// full Lock Screen width (>= threshold) rich card, no matter
@@ -138,7 +149,8 @@ private struct FuelBoardLiveActivityView: View {
FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle,
size: 22, weight: .bold)
Text("Tap for directions")
Text(FuelStore.loadStationTapAction() == .showDetails
? "Tap for details" : "Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
@@ -210,9 +222,26 @@ private struct FuelBoardLiveActivityStationView: View {
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
var body: some View {
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.caption)
.lineLimit(1)
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.caption)
.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)
}
}
@@ -235,4 +264,21 @@ extension FuelBoardLiveActivityAttributes.ContentState {
var mapsURL: URL {
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. Carries coordinate/name fallback
/// so the app can still show the map sheet when the station has left
/// the cache.
var detailURL: URL {
var components = URLComponents()
components.scheme = "fuelboard"
components.host = "station"
components.queryItems = [
URLQueryItem(name: "stationID", value: stationID),
URLQueryItem(name: "lat", value: String(lat)),
URLQueryItem(name: "lng", value: String(lng)),
URLQueryItem(name: "name", value: stationName),
]
return components.url!
}
}
+3 -1
View File
@@ -9,7 +9,9 @@
// in Settings Live Activity (INDEPENDENT of the Stations-tab selection).
// Everything dynamic lives in ContentState so updates including a fuel
// change apply to the running activity without needing to restart it.
// Tapping the activity opens Apple Maps driving directions (maps://).
// Tapping the activity honours Settings Tap station: Open map opens Apple
// Maps driving directions (maps://), More info opens the detail sheet in
// the app (fuelboard://station).
import ActivityKit
import Foundation
+4 -3
View File
@@ -93,13 +93,14 @@ struct FuelPriceWidgetContent: View {
.font(.caption2)
.foregroundStyle(.secondary)
} else {
Text("Tap for directions")
Text(FuelStore.loadStationTapAction() == .showDetails
? "Tap for details" : "Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.modifier(WidgetURLModifier(url: isWidget ? station.widgetDirectionsURL : nil))
.modifier(WidgetURLModifier(url: isWidget ? station.tapDestinationURL : nil))
}
/// How many station rows each family can fit: medium widgets are short
@@ -129,7 +130,7 @@ struct FuelPriceWidgetContent: View {
}
ForEach(entry.stations.prefix(maxRows)) { station in
if isWidget {
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
Link(destination: station.tapDestinationURL ?? URL(string: "maps://")!) {
row(station, cheapest: cheapest)
}
.buttonStyle(.plain)
+24
View File
@@ -271,6 +271,30 @@ struct FuelStation: Identifiable, Codable, Equatable {
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
}
/// In-app deep link to this station's More info sheet. Carries the
/// stationID plus coordinate/name fallback so the app can still show the
/// map sheet when the station has left the cache.
var detailDeepLinkURL: URL? {
var components = URLComponents()
components.scheme = "fuelboard"
components.host = "station"
components.queryItems = [
URLQueryItem(name: "stationID", value: id),
URLQueryItem(name: "lat", value: String(lat)),
URLQueryItem(name: "lng", value: String(lng)),
URLQueryItem(name: "name", value: name),
]
return components.url
}
/// Widget/Live Activity tap destination honouring Settings Tap station:
/// Open map Apple Maps directions; More info in-app detail deep link.
var tapDestinationURL: URL? {
FuelStore.loadStationTapAction() == .showDetails
? detailDeepLinkURL
: widgetDirectionsURL
}
/// Name of the bundled brand logo asset, or nil if unknown.
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" "shell").
var brandImageName: String? {