From 94d66f4c30e4582d59161c9f963fe164e128a4b8 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 14 Oct 2026 11:11:12 +0100 Subject: [PATCH] More info sheet: tappable map opens directions, address copies postcode --- FuelBoard/StationDetailView.swift | 133 ++++++++++++++++++++++--- FuelBoard/en.lproj/Localizable.strings | 5 + 2 files changed, 123 insertions(+), 15 deletions(-) diff --git a/FuelBoard/StationDetailView.swift b/FuelBoard/StationDetailView.swift index 1e597a4..ffa6b95 100644 --- a/FuelBoard/StationDetailView.swift +++ b/FuelBoard/StationDetailView.swift @@ -29,6 +29,10 @@ struct StationDetailView: View { 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? private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")! @@ -36,20 +40,77 @@ struct StationDetailView: View { 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 { - Map(position: .constant(.region(MKCoordinateRegion( - center: stationCoordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - )))) { - Marker(station.name, coordinate: stationCoordinate) - .tint(.red) + // Tapping the map opens the same Apple Maps directions the + // Directions button below opens. The Map still ignores + // hit testing itself so the wrapping Button — not MapKit — + // owns the tap; the map is a preview, not something to pan. + Button { + openDirections() + } label: { + 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) } - .frame(height: 180) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .allowsHitTesting(false) + .buttonStyle(.plain) + // The hit area is declared on the BUTTON, not inside the + // label: the label's Map has hit testing switched off, so + // without this the card could fall through to the row. + .contentShape(RoundedRectangle(cornerRadius: 12)) + .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")) .listRowInsets(EdgeInsets()) } Section { @@ -57,9 +118,41 @@ struct StationDetailView: View { // (navigationTitle, inline) already shows it, and the address // line below has any leading repeat of the name stripped, so // a "Name" row only said it twice. - LabeledContent("Address", value: StationAddressLine.display(address: station.address, - postcode: station.postcode, - name: station.name)) + // + // 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 trailing icon marks it tappable and + // becomes the "Copied" confirmation for a couple of seconds. + Button { + copyPostcode() + } label: { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Address") + Spacer(minLength: 8) + Text(addressLine) + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + HStack(spacing: 4) { + if showingCopied { + Text("Copied") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.green) + .transition(.opacity) + } + Image(systemName: showingCopied ? "checkmark" : "doc.on.doc") + .font(.footnote.weight(.semibold)) + .foregroundStyle(showingCopied ? Color.green : Color.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")) if let location { let distance = FuelStore.stationDistanceDisplay( station: station, userLat: location.lat, userLng: location.lng @@ -107,9 +200,7 @@ struct StationDetailView: View { Section { Button { - if let url = station.mapsDirectionsURL { - UIApplication.shared.open(url) - } + openDirections() } label: { Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill") } @@ -149,6 +240,18 @@ struct StationDetailView: View { ) } } + // 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 } } } diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index 29b2248..6487386 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -71,6 +71,11 @@ "Name" = "Name"; "Address" = "Address"; "Distance" = "Distance"; +"Copied" = "Copied"; +"Copies the postcode" = "Copies the postcode"; +"Address: %@" = "Address: %@"; +"Directions to %@" = "Directions to %@"; +"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions"; "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)";