Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c8fd8b0de | ||
|
|
44cbd3b1e4 | ||
|
|
20fe251b8e | ||
|
|
465e6cd4d4 |
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("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() }
|
||||
|
||||
@@ -3,6 +3,7 @@ import WatchConnectivity
|
||||
|
||||
extension Notification.Name {
|
||||
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
||||
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -147,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)
|
||||
|
||||
@@ -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? {
|
||||
|
||||
Reference in New Issue
Block a user