Files
fuelboard/FuelBoard/StationDetailView.swift
T
FuelBoard Contributor 9e77ee2947 Best Value: explain price age per surface instead of one blanket warning
- Station detail now separates the station's own report age from the
  selected fuel's update time, so a recent station-wide report no longer
  reads as an old price.
- Warning copy names the fuel and says which side is weak: the station's
  price is not individually dated, or the baseline's is. The misleading
  "unverified or older than 7 days" line is gone.
- Wording stays "Closest" throughout; new copy uses Unleaded / Premium /
  Diesel rather than the internal grade codes.
- Localisation: new and updated keys for the added copy.
2026-10-15 21:37:35 +01:00

340 lines
17 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
/// 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 savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var savingsPriceWarning: String? = nil
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<Void, Never>?
/// 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, fuel: selectedFuel)
}
/// The selected grade's current price, when present. Kept separate from
/// the freshness label so a stale price remains visible rather than being
/// silently hidden.
private var selectedFuelPriceText: String {
guard let price = station.prices[selectedFuel] else { return "—" }
return FuelStore.priceText(price, style: .stationSign)
}
/// 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 "Postcode 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("Postcode 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()
}
if let reportAge = priceFreshness.stationReportAgeLabel {
LabeledContent("Station price report", value: reportAge)
.monospacedDigit()
}
LabeledContent("\(selectedFuel.displayName) update time", value: priceFreshness.selectedFuelUpdateLabel)
.monospacedDigit()
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
.monospacedDigit()
if priceFreshness.warning {
Label("Price freshness is not confirmed for \(selectedFuel.displayName)",
systemImage: "exclamationmark.triangle.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
}
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,
priceConfidence: savingsPriceConfidence,
priceWarning: savingsPriceWarning,
baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit,
showInfoAction: { showBestValueInfo = true }
)
} 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.")
}
// 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
}
}
}