Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c83c9b518 | ||
|
|
6c8fd8b0de | ||
|
|
44cbd3b1e4 | ||
|
|
20fe251b8e | ||
|
|
465e6cd4d4 | ||
|
|
1299b4a004 | ||
|
|
cc948dac3a | ||
|
|
ac44cb4ce7 | ||
|
|
abc737eb2d | ||
|
|
3d5c999dc2 | ||
|
|
d899409b78 | ||
|
|
8bbcb0b4dc | ||
|
|
767080a983 | ||
|
|
7a84364238 |
@@ -30,7 +30,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.1</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -406,7 +406,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -472,7 +472,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -537,7 +537,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -602,7 +602,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -699,7 +699,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -707,7 +707,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -722,7 +722,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -730,7 +730,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -745,7 +745,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
@@ -754,7 +754,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -770,7 +770,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
@@ -779,7 +779,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -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("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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -46,9 +47,11 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
||||
if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) {
|
||||
context[FuelStore.favouritesKey] = favourites
|
||||
}
|
||||
if let stations = try? JSONEncoder().encode(FuelStore.loadStations()) {
|
||||
context[FuelStore.stationsKey] = stations
|
||||
}
|
||||
// Do NOT send the full station dump to watchOS via WatchConnectivity.
|
||||
// The UK dataset is megabytes large and exceeds application-context
|
||||
// payload limits, which prevents any snapshot from arriving. The
|
||||
// favourites payload already carries station snapshots + prices, so the
|
||||
// watch can render favourites without the full list.
|
||||
|
||||
context[FuelStore.fuelKey] = FuelStore.loadSelectedFuel().rawValue
|
||||
context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue
|
||||
|
||||
@@ -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";
|
||||
"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";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../../Shared/DataRefreshMode.swift
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
import MapKit
|
||||
|
||||
private enum WatchFuelType: String, CaseIterable, Identifiable, Codable {
|
||||
case e10
|
||||
@@ -102,6 +103,8 @@ private struct WatchFavouriteRow: Identifiable {
|
||||
let priceText: String
|
||||
let distanceText: String?
|
||||
let isCheapest: Bool
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
}
|
||||
|
||||
private enum WatchRefreshState {
|
||||
@@ -109,6 +112,7 @@ private enum WatchRefreshState {
|
||||
case stale(lastUpdated: String)
|
||||
case checking
|
||||
case requested(lastUpdated: String)
|
||||
case checkedNoNewData(lastUpdated: String)
|
||||
case needsPhone
|
||||
case noData
|
||||
|
||||
@@ -117,7 +121,8 @@ private enum WatchRefreshState {
|
||||
case .idle(let lastUpdated): return "Updated \(lastUpdated)"
|
||||
case .stale(let lastUpdated): return "Cache from \(lastUpdated)"
|
||||
case .checking: return "Checking…"
|
||||
case .requested(let lastUpdated): return "Requested · \(lastUpdated)"
|
||||
case .requested(let lastUpdated): return "Updated · \(lastUpdated)"
|
||||
case .checkedNoNewData(let lastUpdated): return "No newer data · \(lastUpdated)"
|
||||
case .needsPhone: return "Refresh on iPhone"
|
||||
case .noData: return "No cached prices yet"
|
||||
}
|
||||
@@ -129,6 +134,7 @@ private enum WatchRefreshState {
|
||||
case .stale: return .yellow
|
||||
case .checking: return .yellow
|
||||
case .requested: return .green
|
||||
case .checkedNoNewData: return .secondary
|
||||
case .needsPhone: return .orange
|
||||
case .noData: return .secondary
|
||||
}
|
||||
@@ -140,6 +146,7 @@ private enum WatchRefreshState {
|
||||
case .stale: return "exclamationmark.circle"
|
||||
case .checking: return "arrow.triangle.2.circlepath"
|
||||
case .requested: return "checkmark.circle"
|
||||
case .checkedNoNewData: return "clock.badge.exclamationmark"
|
||||
case .needsPhone: return "iphone"
|
||||
case .noData: return "tray"
|
||||
}
|
||||
@@ -283,14 +290,30 @@ struct ContentView: View {
|
||||
@State private var distanceUnit: WatchDistanceUnit = WatchFuelCache.loadDistanceUnit()
|
||||
@State private var priceStyle: WatchPriceStyle = WatchFuelCache.loadPriceStyle()
|
||||
|
||||
private var displayedFuels: [WatchFuelType] {
|
||||
let fuels = Set(favourites.map(\.fuel))
|
||||
let sorted = fuels.sorted { $0.sortRank < $1.sortRank }
|
||||
return sorted.isEmpty ? [fuel] : sorted
|
||||
}
|
||||
|
||||
private var hasAnyFavourites: Bool {
|
||||
!favourites.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if hasAnyFavourites {
|
||||
TabView(selection: $fuel) {
|
||||
ForEach(WatchFuelType.allCases) { fuelCase in
|
||||
ForEach(displayedFuels) { fuelCase in
|
||||
fuelPage(for: fuelCase)
|
||||
.tag(fuelCase)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.verticalPage)
|
||||
} else {
|
||||
emptyStateView
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
reloadFromCache()
|
||||
WatchSyncManager.shared.requestSnapshot()
|
||||
@@ -301,6 +324,7 @@ struct ContentView: View {
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active {
|
||||
reloadFromCache()
|
||||
WatchSyncManager.shared.requestSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,8 +341,6 @@ struct ContentView: View {
|
||||
|
||||
if let cheapest {
|
||||
cheapestCard(for: fuelCase, favourite: cheapest)
|
||||
} else {
|
||||
emptyCheapestCard(for: fuelCase)
|
||||
}
|
||||
|
||||
statusRow
|
||||
@@ -340,17 +362,11 @@ struct ContentView: View {
|
||||
Text("Favourites")
|
||||
.font(.headline)
|
||||
|
||||
if rows.isEmpty {
|
||||
Text("No favourites for \(fuelCase.displayName.lowercased()) yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(rows) { row in
|
||||
favouriteRow(row, fuel: fuelCase)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
@@ -372,7 +388,9 @@ struct ContentView: View {
|
||||
name: favourite.station.name,
|
||||
priceText: priceText,
|
||||
distanceText: distanceText,
|
||||
isCheapest: favourite.id == cheapest?.id
|
||||
isCheapest: favourite.id == cheapest?.id,
|
||||
latitude: favourite.station.lat,
|
||||
longitude: favourite.station.lng
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -392,7 +410,52 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Image(systemName: "fuelpump.fill")
|
||||
.foregroundStyle(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
|
||||
Text("FuelBoard")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
statusRow
|
||||
|
||||
Button {
|
||||
runCheckNow()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: refreshState.icon)
|
||||
Text("Check now")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("No favourites yet")
|
||||
.font(.headline)
|
||||
Text("Add favourites on iPhone and they’ll appear here.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private func cheapestCard(for fuel: WatchFuelType, favourite: WatchFavouriteEntry) -> some View {
|
||||
Button {
|
||||
openMaps(latitude: favourite.station.lat, longitude: favourite.station.lng)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Cheapest favourite")
|
||||
.font(.caption2)
|
||||
@@ -414,22 +477,9 @@ struct ContentView: View {
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
|
||||
private func emptyCheapestCard(for fuel: WatchFuelType) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Cheapest favourite")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No favourite saved")
|
||||
.font(.headline)
|
||||
Text("Add favourites on iPhone to see them here.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.buttonStyle(.plain)
|
||||
.background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
|
||||
@@ -441,6 +491,9 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func favouriteRow(_ favourite: WatchFavouriteRow, fuel: WatchFuelType) -> some View {
|
||||
Button {
|
||||
openMaps(latitude: favourite.latitude, longitude: favourite.longitude)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
@@ -469,6 +522,9 @@ struct ContentView: View {
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
@@ -499,6 +555,7 @@ struct ContentView: View {
|
||||
|
||||
private func runCheckNow() {
|
||||
refreshState = .checking
|
||||
let previousLastRefresh = WatchFuelCache.loadLastRefresh()
|
||||
let requestDate = Date()
|
||||
WatchFuelCache.requestRefresh(requestDate)
|
||||
WatchSyncManager.shared.requestRefresh(at: requestDate)
|
||||
@@ -508,8 +565,16 @@ struct ContentView: View {
|
||||
if WatchFuelCache.wasRefreshHandled(since: requestDate) {
|
||||
await MainActor.run {
|
||||
reloadFromCache()
|
||||
let label = WatchFuelCache.loadLastRefresh().map(relativeRefreshText(for:)) ?? "just now"
|
||||
refreshState = .requested(lastUpdated: label)
|
||||
let currentLastRefresh = WatchFuelCache.loadLastRefresh()
|
||||
if let currentLastRefresh,
|
||||
currentLastRefresh >= requestDate,
|
||||
currentLastRefresh > (previousLastRefresh ?? .distantPast) {
|
||||
refreshState = .requested(lastUpdated: relativeRefreshText(for: currentLastRefresh))
|
||||
} else if let currentLastRefresh {
|
||||
refreshState = .checkedNoNewData(lastUpdated: relativeRefreshText(for: currentLastRefresh))
|
||||
} else {
|
||||
refreshState = .noData
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -526,6 +591,17 @@ struct ContentView: View {
|
||||
formatter.unitsStyle = .short
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
|
||||
private func openMaps(latitude: Double, longitude: Double) {
|
||||
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
let destination = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
|
||||
_ = MKMapItem.openMaps(
|
||||
with: [MKMapItem.forCurrentLocation(), destination],
|
||||
launchOptions: [
|
||||
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -59,9 +59,6 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
||||
if let favourites = payload["fuelboard.favourites"] as? Data {
|
||||
defaults.set(favourites, forKey: "fuelboard.favourites")
|
||||
}
|
||||
if let stations = payload["fuelboard.stations"] as? Data {
|
||||
defaults.set(stations, forKey: "fuelboard.stations")
|
||||
}
|
||||
if let fuel = payload["fuelboard.selectedFuel"] as? String {
|
||||
defaults.set(fuel, forKey: "fuelboard.selectedFuel")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -147,9 +159,9 @@ private struct FuelBoardLiveActivityView: View {
|
||||
}
|
||||
|
||||
/// Minimal strip for small space (CarPlay small / Watch smart stack):
|
||||
/// fuel type + bold price on one line, station · distance below.
|
||||
/// Deliberately no app name and no "Tap for directions" — CarPlay is
|
||||
/// display-only, and the user's asks here are just fuel + price + distance.
|
||||
/// fuel type + distance + bold price on one line, station name below.
|
||||
/// Deliberately no app name and no full address — the watch-sized slot is
|
||||
/// too tight, so the second line is reserved for the station name only.
|
||||
private var compactBody: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 5) {
|
||||
@@ -157,16 +169,21 @@ private struct FuelBoardLiveActivityView: View {
|
||||
.font(.caption.bold())
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(context.state.distanceText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||
style: context.state.priceDisplayStyle,
|
||||
size: 15, weight: .bold)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||
.font(.system(size: 9))
|
||||
Text(context.state.stationName)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.minimumScaleFactor(0.75)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.padding(8)
|
||||
@@ -205,11 +222,28 @@ 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
|
||||
|
||||
@@ -230,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