Redesign the More info sheet
The sheet leads with the station name and address inside the card, above the map, then a two-column facts row (fuel price on the left, distance and return on the right, split by a hairline) and the two station actions divided the same way. The Best Value block becomes a tinted card: verdict badge, the two figures the estimate is made of, the working and the total. "How Best Value works" moves to its own row below it, using the shared HowBestValueRow the Key sheet already shows. Presentation only: every figure still comes from the shared sources (FuelStore.priceText, stationDistanceDisplay, FuelSavingsResult), so the sheet cannot disagree with the listing row, the widget or the Live Activity, and no calculation, ranking or freshness rule changed. StationDistanceDisplay.returnLabel(unit:) and FuelSavingsFormat.extraTravelDistanceLabel(...) are split out of their caption forms so a surface printing the return or extra-travel figure on its own line takes it from the same place, confidence marker included; their existing output is unchanged, pinned by tests. FuelSavingsSummaryView is deleted - the sheet's card supersedes it and nothing referenced it. DEBUG-only QA hooks: -qaConfigureSavings configures a Fuel Savings plan (the Best Value card and the Worth the Drive sort are gated on it), -qaDetailBottom scrolls to that card (defaultScrollAnchor is a no-op on a List) and -qaCopyPostcode holds the copied confirmation long enough to screenshot.
This commit is contained in:
@@ -63,12 +63,78 @@ struct StationDetailView: View {
|
||||
FuelPriceFreshness.assessment(for: station)
|
||||
}
|
||||
|
||||
/// The selected grade's current price, when present. Kept separate from
|
||||
/// the price-age row so an old 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)
|
||||
/// 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
|
||||
@@ -90,8 +156,18 @@ struct StationDetailView: View {
|
||||
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: 2_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: hold)
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false }
|
||||
}
|
||||
@@ -99,8 +175,18 @@ struct StationDetailView: View {
|
||||
|
||||
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.
|
||||
//
|
||||
@@ -140,80 +226,26 @@ struct StationDetailView: View {
|
||||
// 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 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 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()
|
||||
}
|
||||
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
|
||||
.monospacedDigit()
|
||||
StationPriceAgeView(assessment: priceFreshness)
|
||||
// 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
|
||||
)
|
||||
ForEach(StationNotice.notices(for: station)) { notice in
|
||||
Label(notice.title, systemImage: notice.symbolName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
@@ -222,15 +254,19 @@ struct StationDetailView: View {
|
||||
.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) {
|
||||
// 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: {
|
||||
@@ -250,16 +286,23 @@ struct StationDetailView: View {
|
||||
// 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 {
|
||||
FuelSavingsSummaryView(
|
||||
BestValueDetailCard(
|
||||
availability: fuelSavings,
|
||||
amount: fuelSavingsAmount,
|
||||
baselineStationName: savingsBaselineStationName,
|
||||
distanceUnit: distanceUnit,
|
||||
showInfoAction: { showBestValueInfo = true }
|
||||
distanceUnit: distanceUnit
|
||||
)
|
||||
} header: {
|
||||
Text("Best Value")
|
||||
}
|
||||
.listRowBackground(Color.green.opacity(0.08))
|
||||
.id("bestValueCard")
|
||||
|
||||
Section {
|
||||
// The shared row — same component the Key sheet shows.
|
||||
HowBestValueRow { showBestValueInfo = true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,8 +319,19 @@ struct StationDetailView: View {
|
||||
#endif
|
||||
|
||||
}
|
||||
.navigationTitle(station.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
// 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") {
|
||||
@@ -319,6 +373,9 @@ struct StationDetailView: View {
|
||||
copyPostcode()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// Closes the ScrollViewReader wrapped around the List: kept last so
|
||||
// every List modifier above stays inside `proxy`'s scope.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user