Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c8fd8b0de | ||
|
|
44cbd3b1e4 | ||
|
|
20fe251b8e | ||
|
|
465e6cd4d4 | ||
|
|
1299b4a004 | ||
|
|
cc948dac3a | ||
|
|
ac44cb4ce7 |
@@ -21,6 +21,7 @@ struct ContentView: View {
|
||||
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
||||
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
||||
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
||||
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
||||
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||
@@ -234,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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -742,6 +767,7 @@ struct ContentView: View {
|
||||
topStationID: topStationID,
|
||||
location: location,
|
||||
favouriteIDs: favouriteIDs,
|
||||
stationTapAction: stationTapAction,
|
||||
onToggleFavourite: toggleFavourite,
|
||||
onRefresh: { await refresh(force: true) }
|
||||
)
|
||||
@@ -756,6 +782,7 @@ struct ContentView: View {
|
||||
location: location,
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
stationTapAction: stationTapAction,
|
||||
onToggleFavourite: toggleFavourite,
|
||||
onReorder: reorderFavourites,
|
||||
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
||||
@@ -794,6 +821,7 @@ struct ContentView: View {
|
||||
tipStore: tipStore,
|
||||
distanceUnit: $distanceUnit,
|
||||
priceDisplayStyle: $priceDisplayStyle,
|
||||
stationTapAction: $stationTapAction,
|
||||
dataRefreshMode: $dataRefreshMode,
|
||||
alertsFuel: alertsFuel,
|
||||
alertsRadiusKM: alertsRadius,
|
||||
@@ -931,7 +959,9 @@ struct StationRow: View {
|
||||
let baselinePrice: Double?
|
||||
let isTopResult: Bool
|
||||
let isFavourite: Bool
|
||||
var tapAction: StationTapAction = .openMap
|
||||
var onToggleFavourite: () -> Void = {}
|
||||
var onShowDetails: (FuelStation) -> Void = { _ in }
|
||||
|
||||
private var ragColor: Color {
|
||||
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
||||
@@ -1030,9 +1060,14 @@ struct StationRow: View {
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
switch tapAction {
|
||||
case .openMap:
|
||||
if let url = station.mapsDirectionsURL {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
case .showDetails:
|
||||
onShowDetails(station)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct FavouritesView: View {
|
||||
let location: Coordinate?
|
||||
let distanceUnit: DistanceUnit
|
||||
let priceDisplayStyle: PriceDisplayStyle
|
||||
let stationTapAction: StationTapAction
|
||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||
/// Persists a reordered favourites array (after drag-and-drop).
|
||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||
@@ -37,6 +38,9 @@ struct FavouritesView: View {
|
||||
/// Trends sheet (price history chart) presentation state.
|
||||
@State private var showTrends = false
|
||||
|
||||
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||
@State private var detailStation: FuelStation?
|
||||
|
||||
private var activeFuel: FuelType {
|
||||
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
||||
}
|
||||
@@ -65,6 +69,7 @@ struct FavouritesView: View {
|
||||
location: Coordinate?,
|
||||
distanceUnit: DistanceUnit,
|
||||
priceDisplayStyle: PriceDisplayStyle,
|
||||
stationTapAction: StationTapAction,
|
||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||
onHistoryUnavailable: (() -> Void)? = nil,
|
||||
@@ -74,6 +79,7 @@ struct FavouritesView: View {
|
||||
self.location = location
|
||||
self.distanceUnit = distanceUnit
|
||||
self.priceDisplayStyle = priceDisplayStyle
|
||||
self.stationTapAction = stationTapAction
|
||||
self.onToggleFavourite = onToggleFavourite
|
||||
self.onReorder = onReorder
|
||||
self.onHistoryUnavailable = onHistoryUnavailable
|
||||
@@ -129,7 +135,9 @@ struct FavouritesView: View {
|
||||
baselinePrice: cheapestPrice,
|
||||
isTopResult: index == 0,
|
||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
||||
tapAction: stationTapAction,
|
||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||
onShowDetails: { detailStation = $0 }
|
||||
)
|
||||
}
|
||||
.onMove(perform: moveFavourite)
|
||||
@@ -171,6 +179,13 @@ struct FavouritesView: View {
|
||||
onHistoryRecovered: onHistoryRecovered
|
||||
)
|
||||
}
|
||||
.sheet(item: $detailStation) { station in
|
||||
StationDetailView(
|
||||
station: station,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
#if DEBUG
|
||||
// QA hook: launch with `-showTrends` to open the sheet
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ struct SettingsView: View {
|
||||
@ObservedObject var tipStore: TipStore
|
||||
@Binding var distanceUnit: DistanceUnit
|
||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||
@Binding var stationTapAction: StationTapAction
|
||||
@Binding var dataRefreshMode: DataRefreshMode
|
||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||
/// tab) so the test notification matches what real alerts will say.
|
||||
@@ -123,6 +124,22 @@ struct SettingsView: View {
|
||||
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Tap station", selection: $stationTapAction) {
|
||||
ForEach(StationTapAction.allCases) { action in
|
||||
Text(action.displayName).tag(action)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: stationTapAction) { _, newValue in
|
||||
FuelStore.saveStationTapAction(newValue)
|
||||
}
|
||||
} header: {
|
||||
Text("Tap action")
|
||||
} footer: {
|
||||
Text("Tapping a station either opens directions straight away or shows its details first.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Data checking", selection: $dataRefreshMode) {
|
||||
ForEach(DataRefreshMode.allCases) { mode in
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
import UIKit
|
||||
|
||||
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
|
||||
/// 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)")
|
||||
if let location {
|
||||
let km = FuelStore.displayDistanceKM(
|
||||
station: station, userLat: location.lat, userLng: location.lng
|
||||
)
|
||||
LabeledContent("Distance", value: distanceUnit.format(km))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
if let url = station.mapsDirectionsURL {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} 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("Open GOV.UK") {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ struct StationsView: View {
|
||||
let topStationID: String?
|
||||
let location: Coordinate?
|
||||
let favouriteIDs: Set<String>
|
||||
let stationTapAction: StationTapAction
|
||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||
var onRefresh: () async -> Void = {}
|
||||
|
||||
@@ -29,6 +30,9 @@ struct StationsView: View {
|
||||
/// info button in the header — keeps the list focused on stations.
|
||||
@State private var showKey = false
|
||||
|
||||
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||
@State private var detailStation: FuelStation?
|
||||
|
||||
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
||||
/// directions context + "N/TOTAL stations updated". The numerator is the
|
||||
/// current list pool; the denominator is the full UK station total from
|
||||
@@ -38,6 +42,9 @@ struct StationsView: View {
|
||||
let unit = distanceUnit.label(for: Double(miles))
|
||||
let fuel = selectedFuel.displayName
|
||||
let mode = sortMode == .closest ? "Closest" : "Cheapest"
|
||||
let tapHint = stationTapAction == .openMap
|
||||
? "tap a station for directions."
|
||||
: "tap a station for details."
|
||||
let ratio: String
|
||||
if let total = FuelStore.loadStationCount() {
|
||||
ratio = "\(totalCount)/\(total)"
|
||||
@@ -48,9 +55,9 @@ struct StationsView: View {
|
||||
if sortMode == .closest {
|
||||
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||
}
|
||||
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · tap a station for directions."
|
||||
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
|
||||
}
|
||||
return "\(mode) \(fuel) — \(ratio) stations updated · tap a station for directions."
|
||||
return "\(mode) \(fuel) — \(ratio) stations updated · \(tapHint)"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -126,7 +133,9 @@ struct StationsView: View {
|
||||
baselinePrice: baselinePrice,
|
||||
isTopResult: station.id == topStationID,
|
||||
isFavourite: favouriteIDs.contains(station.id),
|
||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
||||
tapAction: stationTapAction,
|
||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
||||
onShowDetails: { detailStation = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -260,6 +269,13 @@ struct StationsView: View {
|
||||
.presentationDetents([.fraction(0.6)])
|
||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||
}
|
||||
.sheet(item: $detailStation) { station in
|
||||
StationDetailView(
|
||||
station: station,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import WatchConnectivity
|
||||
|
||||
extension Notification.Name {
|
||||
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
||||
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -56,6 +56,14 @@
|
||||
"Units" = "Units";
|
||||
"Price display" = "Price display";
|
||||
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
|
||||
"Tap station" = "Tap station";
|
||||
"Tap action" = "Tap action";
|
||||
"Tapping a station either opens directions straight away or shows its details first." = "Tapping a station either opens directions straight away or shows its details first.";
|
||||
"Open map" = "Open map";
|
||||
"More info" = "More info";
|
||||
"Name" = "Name";
|
||||
"Address" = "Address";
|
||||
"Distance" = "Distance";
|
||||
"Show introduction" = "Replay onboarding";
|
||||
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
|
||||
"Test alert notification (real data)" = "Test alert notification (real data)";
|
||||
@@ -139,6 +147,11 @@
|
||||
"You" = "You";
|
||||
"Done" = "Done";
|
||||
"Directions" = "Directions";
|
||||
"Report issue" = "Report issue";
|
||||
"Report an error?" = "Report an error?";
|
||||
"Cancel" = "Cancel";
|
||||
"Open GOV.UK" = "Open GOV.UK";
|
||||
"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,24 +32,34 @@ struct FuelBoardLiveActivity: Widget {
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||
FuelBoardLiveActivityCompactView(context: context)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||
FuelBoardLiveActivityPriceView(context: context)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
FuelBoardLiveActivityStationView(context: context)
|
||||
}
|
||||
} compactLeading: {
|
||||
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||
Image(systemName: "fuelpump.fill")
|
||||
.foregroundStyle(context.state.fuel.tintColor)
|
||||
}
|
||||
} compactTrailing: {
|
||||
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||
FuelBoardLiveActivityPriceView(context: context)
|
||||
}
|
||||
} minimal: {
|
||||
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||
Text(context.state.priceText)
|
||||
.font(.caption2.bold().monospacedDigit())
|
||||
}
|
||||
}
|
||||
}
|
||||
.supplementalActivityFamilies([.small])
|
||||
// Why `.small` is kept: it lets the SHARED body render a compact form
|
||||
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
|
||||
@@ -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,10 +222,27 @@ private struct FuelBoardLiveActivityStationView: View {
|
||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||
|
||||
var body: some View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ContentState display helpers
|
||||
@@ -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!
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -208,6 +208,25 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Station tap action
|
||||
|
||||
/// What happens when a station row is tapped in the Stations/Favourites tabs:
|
||||
/// open Apple Maps directions immediately (historic behaviour, default), or
|
||||
/// show a detail sheet (name, address, distance, Directions button).
|
||||
enum StationTapAction: String, Codable, CaseIterable, Identifiable {
|
||||
case openMap // tap → Apple Maps directions straight away
|
||||
case showDetails // tap → detail sheet, Directions button inside
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .openMap: return "Open map"
|
||||
case .showDetails: return "More info"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Station model
|
||||
|
||||
struct FuelStation: Identifiable, Codable, Equatable {
|
||||
@@ -252,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? {
|
||||
@@ -542,6 +585,22 @@ struct FuelStore {
|
||||
saveString(style.rawValue, service: priceDisplayStyleKey)
|
||||
}
|
||||
|
||||
// MARK: Station tap action — open map vs detail sheet. Stored raw value;
|
||||
// default open map preserves the historic tap behaviour for existing installs.
|
||||
|
||||
static let stationTapActionKey = "fuelboard.stationTapAction"
|
||||
|
||||
static func loadStationTapAction() -> StationTapAction {
|
||||
if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) {
|
||||
return action
|
||||
}
|
||||
return .openMap
|
||||
}
|
||||
|
||||
static func saveStationTapAction(_ action: StationTapAction) {
|
||||
saveString(action.rawValue, service: stationTapActionKey)
|
||||
}
|
||||
|
||||
/// Superscript digit glyphs for the pounds & pence format's small raised
|
||||
/// third digit (the forecourt style: "£1.29⁹").
|
||||
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||
|
||||
Reference in New Issue
Block a user