The confirmation belongs to the address row, so hovering it over the map read as belonging to the map. It now overlays the address row itself, horizontally centred on the address line, as .overlay(alignment: .center) on the row with allowsHitTesting(false) still keeping the row tappable through the capsule. Verified: with the capsule up, the OCR box sits at y 1189-1231 inside the address band (map bottom is 1075), the tint blob is centred at x 602.5 against a screen centre of 603, the row's ink runs outside the capsule footprint are byte-identical to the idle frame, and a real tap on the capsule's own footprint still copies.
302 lines
15 KiB
Swift
302 lines
15 KiB
Swift
import SwiftUI
|
|
import MapKit
|
|
import UIKit
|
|
|
|
/// DEBUG-only explanation of the Best Value numbers for one station. It carries
|
|
/// no precise user coordinates and is never persisted.
|
|
struct FuelSavingsJourneyDiagnostics {
|
|
let rows: [(label: String, value: String)]
|
|
}
|
|
|
|
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
|
|
/// Shows a small map preview, the address, distance from the current location,
|
|
/// and a Directions button that opens Apple Maps — the same destination a direct
|
|
/// tap would have opened. The station name lives in the sheet header, not in a
|
|
/// row.
|
|
struct StationDetailView: View {
|
|
let station: FuelStation
|
|
let location: Coordinate?
|
|
let distanceUnit: DistanceUnit
|
|
var fuelSavings: FuelSavingsAvailability? = nil
|
|
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
|
var savingsBaselineStationName: String? = nil
|
|
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
|
|
/// Whether this station is starred for the fuel currently being viewed, and
|
|
/// the toggle that flips it. Passed from the list that opened the sheet, so
|
|
/// the star here and the star on the row are the same fuel-scoped
|
|
/// favourite and cannot drift apart.
|
|
var isFavourite: Bool = false
|
|
var onToggleFavourite: (() -> Void)? = nil
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var showingReportIssueConfirmation = false
|
|
/// Transient "Copied" confirmation on the Address row. Cleared by a task so
|
|
/// the caption fades out on its own.
|
|
@State private var showingCopied = false
|
|
@State private var copyResetTask: Task<Void, Never>?
|
|
|
|
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)
|
|
}
|
|
|
|
/// The row's address text — the same shared line the listing row uses, with
|
|
/// any leading repeat of the station name stripped.
|
|
private var addressLine: String {
|
|
StationAddressLine.display(address: station.address,
|
|
postcode: station.postcode,
|
|
name: station.name)
|
|
}
|
|
|
|
/// The station's postcode, trimmed. Empty means there is nothing to copy and
|
|
/// the Address row stays inert.
|
|
private var postcodeForCopying: String {
|
|
station.postcode.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
/// Opens Apple Maps driving directions — the same destination (and the same
|
|
/// URL) the Directions button below uses, so the map and the button can
|
|
/// never point at two different places.
|
|
private func openDirections() {
|
|
if let url = station.mapsDirectionsURL {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}
|
|
|
|
/// Copies ONLY the postcode, so it can be pasted into another navigation app
|
|
/// without dragging the station name and street along with it. Confirms with
|
|
/// a light haptic and a caption that fades after ~2s.
|
|
private func copyPostcode() {
|
|
let postcode = postcodeForCopying
|
|
guard !postcode.isEmpty else { return }
|
|
UIPasteboard.general.string = postcode
|
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
withAnimation(.easeInOut(duration: 0.2)) { showingCopied = true }
|
|
copyResetTask?.cancel()
|
|
copyResetTask = Task {
|
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
|
guard !Task.isCancelled else { return }
|
|
withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false }
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
// Tapping anywhere on the map opens the same Apple Maps
|
|
// directions the Directions button below opens.
|
|
//
|
|
// The tap target is an OVERLAY Button laid ON TOP of the
|
|
// Map, not a Button wrapping it. A Map runs its own UIKit
|
|
// view, and inside a List row the wrapped-Map form left the
|
|
// card untappable — the touch never reached the button. An
|
|
// overlay sits above the map, owns an explicit rounded-rect
|
|
// content shape, and cannot be intercepted by MapKit. The
|
|
// Map itself stays non-interactive: it is a preview, not
|
|
// something to pan.
|
|
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)
|
|
.overlay {
|
|
Button {
|
|
openDirections()
|
|
} label: {
|
|
Color.clear
|
|
.contentShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(String(localized: "Directions to \(station.name)",
|
|
comment: "Accessibility label for the tappable map preview in the More info sheet"))
|
|
.accessibilityHint(String(localized: "Opens Apple Maps with driving directions",
|
|
comment: "Accessibility hint for the tappable map preview in the More info sheet"))
|
|
}
|
|
// No hairline between the map and the Address row: the map
|
|
// already reads as its own block, so the line only added
|
|
// clutter. The Address/Distance and Distance/actions lines
|
|
// stay.
|
|
.listRowSeparator(.hidden, edges: .bottom)
|
|
// The map, the address, the distance, any notices and the two
|
|
// station actions all share ONE card (the list's own row
|
|
// hairlines separate them), so no hand-rolled Divider here —
|
|
// adding one would double the line.
|
|
//
|
|
// The station name is NOT repeated: the sheet header already
|
|
// shows it.
|
|
//
|
|
// The whole row is a copy control: it puts JUST the postcode
|
|
// on the clipboard so it can be pasted into another
|
|
// navigation app. The copy glyph sits ON the address line,
|
|
// and the "Copied" confirmation is a capsule that floats OVER
|
|
// THIS ROW — it confirms the address, so it must not hover
|
|
// over the map. It is an overlay, so nothing moves while it is
|
|
// up, and `allowsHitTesting(false)` keeps the row tappable
|
|
// through the capsule.
|
|
Button {
|
|
copyPostcode()
|
|
} label: {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text("Address")
|
|
Spacer(minLength: 8)
|
|
Text(addressLine)
|
|
.multilineTextAlignment(.trailing)
|
|
.foregroundStyle(.secondary)
|
|
// Stays the plain copy glyph — the capsule carries
|
|
// the confirmation, so swapping this to a checkmark
|
|
// would say it twice.
|
|
Image(systemName: "doc.on.doc")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(postcodeForCopying.isEmpty)
|
|
.accessibilityLabel(String(localized: "Address: \(addressLine)",
|
|
comment: "Accessibility label for the address row in the More info sheet"))
|
|
.accessibilityHint(postcodeForCopying.isEmpty
|
|
? ""
|
|
: String(localized: "Copies the postcode",
|
|
comment: "Accessibility hint for the address row in the More info sheet"))
|
|
// Horizontally centred over the address line itself.
|
|
.overlay(alignment: .center) {
|
|
if showingCopied {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "checkmark")
|
|
Text("Copied")
|
|
}
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.green)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
// A material base keeps it legible where the tint
|
|
// alone (the notice-capsule recipe) washes out.
|
|
.background {
|
|
Capsule()
|
|
.fill(.regularMaterial)
|
|
.overlay(Capsule().fill(Color.green.opacity(0.22)))
|
|
}
|
|
.allowsHitTesting(false)
|
|
.accessibilityHidden(true)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
if let location {
|
|
let distance = FuelStore.stationDistanceDisplay(
|
|
station: station, userLat: location.lat, userLng: location.lng
|
|
)
|
|
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
|
|
.monospacedDigit()
|
|
}
|
|
ForEach(StationNotice.notices(for: station)) { notice in
|
|
Label(notice.title, systemImage: notice.symbolName)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.orange)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Capsule().fill(Color.orange.opacity(0.14)))
|
|
}
|
|
// The two station actions are the LAST row of the card: half
|
|
// the width each, on one line, below Address and Distance.
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
openDirections()
|
|
} label: {
|
|
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
Button(role: .destructive) {
|
|
showingReportIssueConfirmation = true
|
|
} label: {
|
|
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
// REQUIRED: with the default button style a `List` row has ONE
|
|
// row-wide tap target, so two buttons in the same row BOTH fire
|
|
// on any tap inside the row (tapping Directions also showed the
|
|
// report-issue alert and vice versa). `.borderless` gives each
|
|
// button its own hit area — the half-row frame above.
|
|
.buttonStyle(.borderless)
|
|
}
|
|
// Only shown while Fuel Savings backs it. `fuelSavings` is nil
|
|
// when the feature is off — `savingsLookup` returns nil when it
|
|
// is not configured — so the heading cannot be left dangling
|
|
// over an empty section.
|
|
if let fuelSavings {
|
|
Section {
|
|
FuelSavingsSummaryView(
|
|
availability: fuelSavings,
|
|
amount: fuelSavingsAmount,
|
|
baselineStationName: savingsBaselineStationName,
|
|
distanceUnit: distanceUnit
|
|
)
|
|
} header: {
|
|
Text("Best Value")
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
if let journeyDiagnostics {
|
|
Section("Best Value Diagnostics") {
|
|
ForEach(journeyDiagnostics.rows, id: \.label) { row in
|
|
LabeledContent(row.label, value: row.value)
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
}
|
|
.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() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button {
|
|
onToggleFavourite?()
|
|
} label: {
|
|
Image(systemName: isFavourite ? "star.fill" : "star")
|
|
}
|
|
.tint(isFavourite ? Color.yellow : nil)
|
|
.disabled(onToggleFavourite == nil)
|
|
.accessibilityLabel(
|
|
isFavourite
|
|
? String(localized: "Remove from favourites", comment: "Unstar a station from the More info sheet")
|
|
: String(localized: "Add to favourites", comment: "Star a station from the More info sheet")
|
|
)
|
|
}
|
|
}
|
|
// The "Copied" fade-out task must not outlive the sheet.
|
|
.onDisappear { copyResetTask?.cancel() }
|
|
#if DEBUG
|
|
// QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the
|
|
// clipboard value (read back with `xcrun simctl pbpaste`) and the
|
|
// "Copied" confirmation can be verified without a tap — simctl
|
|
// cannot drive taps into the Simulator.
|
|
.task {
|
|
guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return }
|
|
copyPostcode()
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
}
|