Files
fuelboard/FuelBoard/StationDetailView.swift
T
FuelBoard Contributor c69bdb3c5f Show ranking capsules and value breakdown in station details
The More info sheet now lists every ranking a station qualifies for, directly under the map and above the price and distance: the same StationLabelBadge the listing rows render, in a flow layout so two or three capsules wrap to a new line rather than truncating at large Dynamic Type.

The rules are not restated. StationRanking defers to StationRecommendationBadge.badge for the Best Value gate, and takes Cheapest and Top as the flags the host list already computed for its own rows, so the sheet cannot award a capsule the row behind it withheld. A host passes one of the two, never both: Stations sends isCheapestInRange, Favourites sends isTopFavourite. No calculation, ranking, eligibility or savings logic changed.

Rename the card heading Best Value to Value breakdown, since the working behind it can end in Costs rather than a saving, and drop the green section tint so the card matches the neutral background used elsewhere in the sheet. The Saves, Break even and Costs capsules and every breakdown row are untouched.

Rename the explanation link to How does FuelBoard calculate value? It names the calculation rather than the feature, of which the Best Value gate is only the last step. The sheet's own title stays short: the full question truncated to 'How does FuelBoard...' as a navigation title at both inline and large display modes, so the row carries the question and the sheet names the topic.

Tests: 269 pass, 5 new covering capsule selection, order and sheet-vs-row agreement on Best Value.
2026-10-19 13:20:59 +01:00

426 lines
21 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
/// 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
/// Ranking flags, supplied by the list that opened this sheet — the SAME flags
/// it hands its own rows, so this sheet cannot award a capsule the row behind it
/// did not. `isCheapestInRange` is the Stations tab's cheapest price in range;
/// `isTopFavourite` is the cheapest of your Favourites. A host passes one or the
/// other, never both, so one achievement is never labelled twice.
var isCheapestInRange: Bool = false
var isTopFavourite: Bool = false
/// The user's Best Value alert threshold — the app's existing gate, defaulted
/// exactly as `StationRow` defaults it, so the capsule here and the badge on the
/// list agree to the penny.
var bestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
/// 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 does FuelBoard calculate value?" sheet, opened from the row at the
/// bottom of the value-breakdown 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)
}
/// Which ranking capsules this station qualifies for. All the rules live in the
/// shared `StationRanking` / `StationRecommendationBadge` pair — nothing is
/// recomputed here, and no threshold is invented.
private var rankingKinds: [StationLabelKind] {
StationRanking.kinds(
netSavingGBP: fuelSavings?.result?.netSavingGBP,
isCheapestInRange: isCheapestInRange,
isTopFavourite: isTopFavourite,
bestValueThresholdGBP: bestValueThresholdGBP
)
}
/// 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)
// Ranking capsules directly under the map, above the price and
// distance. Nothing renders when the station qualifies for no
// label, so an ordinary station's layout is untouched.
//
// No hairline under this row either: the map already suppressed
// its bottom separator so the header and map read as one block,
// and the facts row below keeps its own line.
if !rankingKinds.isEmpty {
StationRankingCapsules(kinds: rankingKinds)
.listRowSeparator(.hidden, edges: .bottom)
}
//
// 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 carries its own "Value breakdown" heading, so a
// section header would repeat it. Neutral card background: the
// green tint marked the block as the savings card, but it also
// tinted a card whose verdict can be "Costs", and it made this
// one card look like a different material from the station card
// above it.
Section {
BestValueDetailCard(
availability: fuelSavings,
amount: fuelSavingsAmount,
baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit
)
}
.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) }
}
.task {
// QA hook: `-qaDetailShowBestValueInfo` opens the explanation from
// THIS sheet (the More info path), so the two-sheet dismissal can
// be screenshotted — simctl has no tap.
guard ProcessInfo.processInfo.arguments.contains("-qaDetailShowBestValueInfo") else { return }
try? await Task.sleep(nanoseconds: 1_500_000_000)
showBestValueInfo = true
}
#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.
}
}
}