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 /// Price display style, from the same Settings choice the listing rows read, /// so the sheet's price can never disagree with the row behind it. var priceStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle() /// Fuel whose price is being shown by the surrounding station list. var selectedFuel: FuelType = FuelStore.loadSelectedFuel() 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 "Postcode 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? /// The "How Best Value works" sheet, opened from the row at the bottom of the /// Best Value card (the Key sheet opens the same shared view). @State private var showBestValueInfo = 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) } /// 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) } private var priceFreshness: FuelPriceFreshness.Assessment { FuelPriceFreshness.assessment(for: station) } /// One-way and out-and-back distance from the current location, from the ONE /// shared display helper — so this sheet's figures carry the same confidence /// marker as the listing row, the widget and the Live Activity. private var distanceDisplay: StationDistanceDisplay? { guard let location else { return nil } return FuelStore.stationDistanceDisplay( station: station, userLat: location.lat, userLng: location.lng ) } /// Header title: the station name over its address, as the mockup shows. /// Tapping copies the postcode, and the "Postcode copied" capsule floats over /// the address line it confirms. /// /// The CARD'S FIRST ROW, above the map — deliberately not a toolbar /// principal item: a principal item is clamped to one line, so a long /// forecourt name ("Brighouse Bradford Rd Superstore - Petrol") showed an /// ellipsis and `minimumScaleFactor` was not honoured. Inside the card it /// wraps to two lines, and the confirmation capsule cannot be clipped by the /// nav bar. private var headerTitle: some View { Button { copyPostcode() } label: { VStack(alignment: .leading, spacing: 1) { Text(station.name) .font(.title3.weight(.semibold)) .multilineTextAlignment(.leading) .lineLimit(2) Text(addressLine) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.leading) .lineLimit(2) } .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) .disabled(postcodeForCopying.isEmpty) .accessibilityLabel(String(localized: "Address: \(addressLine)", comment: "Accessibility label for the address in the More info sheet header")) .accessibilityHint(postcodeForCopying.isEmpty ? "" : String(localized: "Copies the postcode", comment: "Accessibility hint for the address in the More info sheet header")) .overlay(alignment: .center) { copiedConfirmation } } /// The transient "Postcode copied" capsule. An overlay, so nothing moves /// while it is up, and hit testing stays off so the header keeps its tap. @ViewBuilder private var copiedConfirmation: some View { if showingCopied { HStack(spacing: 4) { Image(systemName: "checkmark") Text("Postcode copied") } .font(.caption.weight(.semibold)) .foregroundStyle(.green) .padding(.horizontal, 8) .padding(.vertical, 4) // 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) } } /// 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() #if DEBUG // QA hook: `-qaCopyPostcode` holds the confirmation up long enough for a // screenshot. The production window (2s) cannot be hit reliably — the app // takes several seconds to render, and simctl cannot tap. let hold: UInt64 = ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") ? 25_000_000_000 : 2_000_000_000 #else let hold: UInt64 = 2_000_000_000 #endif copyResetTask = Task { try? await Task.sleep(nanoseconds: hold) guard !Task.isCancelled else { return } withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false } } } var body: some View { NavigationStack { // Wrapper exists only so the DEBUG scroll hook below can reach a // section by id; it has no effect on layout or behaviour. ScrollViewReader { proxy in List { Section { // The sheet header is the CARD'S FIRST ROW, above the map: // the station name over its address, both wrapping rather // than truncating, on the card's own insets so it lines up // with the map and the rows below. Tapping it copies the // postcode. No hairline under it — the map follows. headerTitle .listRowSeparator(.hidden, edges: .bottom) // 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 facts row, 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 AND the address live in the sheet header, // which is also where a tap copies the postcode — see the // principal toolbar item below. // The price and the distance share one two-column row: each // figure comes from the same source it did before (the price // from FuelStore, the distance from the one shared display // helper), so the row cannot disagree with the listing row, // the widget or the Live Activity. StationFactsRow( station: station, selectedFuel: selectedFuel, priceAge: priceFreshness, distance: distanceDisplay, distanceUnit: distanceUnit, priceStyle: priceStyle ) 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))) } // Half the width each, on one line, with the list's hairline // running between them. HStack(spacing: 0) { Button { openDirections() } label: { Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill") .frame(maxWidth: .infinity) } Rectangle() .fill(Color(.separator)) .frame(width: 0.5, height: 22) .accessibilityHidden(true) 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 { // The card's own header row says "Best Value", so a section // header would repeat it; the green tint is what marks this // block as the savings card. Section { BestValueDetailCard( availability: fuelSavings, amount: fuelSavingsAmount, baselineStationName: savingsBaselineStationName, distanceUnit: distanceUnit ) } .listRowBackground(Color.green.opacity(0.08)) .id("bestValueCard") Section { // The shared row — same component the Key sheet shows. HowBestValueRow { showBestValueInfo = true } } } #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 } // No navigationTitle: the sheet's header row carries the name and // address, and an inline title would print the name a second time. #if DEBUG // QA hook: `-qaDetailBottom` scrolls to the Best Value card so its // lower half (Extra driving cost, Total, the estimate caveat) can be // screenshotted. `simctl` cannot scroll, and `.defaultScrollAnchor` // is a no-op on a List — only scrollTo moves it. .task { guard ProcessInfo.processInfo.arguments.contains("-qaDetailBottom") else { return } try? await Task.sleep(nanoseconds: 3_000_000_000) withAnimation { proxy.scrollTo("bestValueCard", anchor: .bottom) } } #endif .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.") } // Nested sheet: the More info sheet stays put behind the explanation. .sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() } .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 "Postcode 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 // "Postcode 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 } // Closes the ScrollViewReader wrapped around the List: kept last so // every List modifier above stays inside `proxy`'s scope. } } }