Files
fuelboard/FuelBoard/StationDetailSections.swift

284 lines
11 KiB
Swift

import SwiftUI
/// The More info sheet's facts row: the selected fuel's price on the left, the
/// distance on the right, separated by a hairline down the middle.
///
/// Every figure comes from the sheet's existing sources — the price through
/// `FuelStore.priceText` and the distance through
/// `FuelStore.stationDistanceDisplay(...)` — so this row can never disagree with
/// the listing row, the widget or the Live Activity, and both columns keep the
/// shared "Approx." confidence marking.
struct StationFactsRow: View {
let station: FuelStation
let selectedFuel: FuelType
let priceAge: FuelPriceFreshness.Assessment
let distance: StationDistanceDisplay?
let distanceUnit: DistanceUnit
/// The user's price display style, from the same Settings choice the listing
/// rows and the widgets read.
var priceStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
/// The price's point size, scaled with Dynamic Type: the price is built from
/// an explicit size (its "/L" suffix is sized relative to it inside
/// `priceTextAttributed`), so it needs a metric rather than a font style.
/// 22pt matches the listing row's price.
@ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22
var body: some View {
HStack(alignment: .top, spacing: 12) {
fuelColumn
StationFactsDivider()
distanceColumn
}
.padding(.vertical, 2)
}
private var fuelColumn: some View {
VStack(alignment: .leading, spacing: 3) {
Text(selectedFuel.displayName)
.font(.caption)
.foregroundStyle(.secondary)
priceView
StationPriceAgeView(assessment: priceAge, compact: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
private var distanceColumn: some View {
VStack(alignment: .leading, spacing: 3) {
Text("Distance")
.font(.caption)
.foregroundStyle(.secondary)
Text(distanceText)
.font(.title2.weight(.bold))
.monospacedDigit()
Text(returnText)
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
/// The selected grade's price, in the user's chosen display style — the same
/// `priceTextAttributed` the listing row uses, so the sheet prints the bare
/// forecourt figure ("167.9") in Station sign and the superscripted
/// "£1.67⁹/L" in Pounds & pence, and the two surfaces cannot disagree.
/// The returned Text carries its own fonts, so no `.font(...)` goes on top.
///
/// A missing grade shows an em dash rather than hiding the column, so the row
/// keeps its two-column shape.
@ViewBuilder
private var priceView: some View {
if let price = station.prices[selectedFuel] {
FuelStore.priceTextAttributed(price, style: priceStyle, size: priceSize, weight: .bold)
} else {
Text("—")
.font(.title2.weight(.bold))
.monospacedDigit()
}
}
/// One-way distance, with its confidence marker, from the shared display.
private var distanceText: String {
distance?.label(unit: distanceUnit) ?? "—"
}
/// Out-and-back distance, same source and same marker rule.
private var returnText: String {
distance?.returnLabel(unit: distanceUnit) ?? ""
}
}
/// A one-pixel vertical hairline between the facts row's two columns, drawn in
/// the List's own separator colour so it matches every other line on the sheet.
/// Decorative: VoiceOver reads each column as one phrase instead.
private struct StationFactsDivider: View {
var body: some View {
Rectangle()
.fill(Color(.separator))
.frame(width: 0.5)
.frame(maxHeight: .infinity)
.accessibilityHidden(true)
}
}
/// The More info sheet's Best Value card: the verdict, then the figures it is
/// built from.
///
/// Nothing is recomputed here — every number comes from the shared
/// `FuelSavingsResult`, the verdict capsule is the same `FuelSavingsCapsule` the
/// listing rows use, and the total is the shared `headline()`. Wording and
/// colour therefore cannot drift between the sheet and the list.
struct BestValueDetailCard: View {
let availability: FuelSavingsAvailability
let amount: FuelSavingsFillAmount
let baselineStationName: String?
let distanceUnit: DistanceUnit
var body: some View {
switch availability {
case .available(let result):
availableBody(result)
case .unavailable(let reason):
Text("Best Value unavailable: \(reason)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func availableBody(_ result: FuelSavingsResult) -> some View {
VStack(alignment: .leading, spacing: 14) {
header(result)
panels(result)
workings(result)
}
}
/// Card title on the left, the verdict on the right — ONE capsule, in the
/// same large form the sheet used to repeat on its own line above the
/// working. The comparison caption sits UNDER the capsule, trailing-aligned
/// with it, the same way the listing rows lay the pair out.
private func header(_ result: FuelSavingsResult) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("Best Value")
.font(.headline)
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 4) {
FuelSavingsCapsule(result: result, compact: false)
.accessibilityLabel(result.detailCapsuleSpokenHeadline())
comparison(result)
}
}
}
/// The compact comparison caption ("vs closest station" / "your baseline"),
/// under the capsule it belongs to.
private func comparison(_ result: FuelSavingsResult) -> some View {
Text(FuelSavingsFormat.comparisonLabel(status: result.status))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
/// The two figures the estimate is made OF, side by side: what the user
/// spends, and how much fuel that buys at this station.
private func panels(_ result: FuelSavingsResult) -> some View {
HStack(spacing: 10) {
panel(symbol: "fuelpump.fill",
title: "Your fill-up",
value: Self.amountText(amount))
panel(symbol: "drop.fill",
title: "Fuel you'll receive",
value: String(format: "%.2f litres", result.fillLitres))
}
}
private func panel(symbol: String, title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 5) {
Image(systemName: symbol)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Text(value)
.font(.title3.weight(.semibold))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.primary.opacity(0.06)))
.accessibilityElement(children: .combine)
}
/// The working: what the extra travel adds, what the pump saves, and the
/// total. A baseline station has nothing to save against itself, so it stops
/// after the panels — the capsule above already says "Closest station".
@ViewBuilder
private func workings(_ result: FuelSavingsResult) -> some View {
if result.status != .baseline {
Divider()
extraTravelRow(result)
savingRows(result)
Divider()
totalRow(result)
if let baselineStationName {
Text("Compared with \(baselineStationName).")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if result.journeyConfidence.isEstimated {
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
/// The extra driving a saving has to beat, named in the row label and
/// measured in the value — same figure and same "Approx." rule as the shared
/// caption, from the shared formatter.
@ViewBuilder
private func extraTravelRow(_ result: FuelSavingsResult) -> some View {
if let extraTravel = FuelSavingsFormat.extraTravelDistanceLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) {
LabeledContent("Extra return travel", value: extraTravel)
.monospacedDigit()
}
}
@ViewBuilder
private func savingRows(_ result: FuelSavingsResult) -> some View {
LabeledContent(
result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference",
value: result.grossSavingGBP >= 0
? FuelSavingsFormat.money(result.grossSavingGBP)
: "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more"
)
.monospacedDigit()
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
.monospacedDigit()
}
/// The Total row is the WORKINGS; the capsule above is the quick verdict.
/// Both take their wording and colour from the shared result.
private func totalRow(_ result: FuelSavingsResult) -> some View {
LabeledContent {
Text(result.headline())
.fontWeight(.semibold)
.foregroundStyle(FuelSavingsCapsule.color(for: result.status))
.monospacedDigit()
} label: {
Text("Total")
.fontWeight(.semibold)
}
}
/// "£60" or "60 litres" — the same strings the sheet printed before the
/// redesign, so the fill-up reads identically in both units.
static func amountText(_ amount: FuelSavingsFillAmount) -> String {
switch amount.unit {
case .pounds: return String(format: "£%.0f", amount.value)
case .litres: return String(format: "%.0f litres", amount.value)
}
}
}
/// "How Best Value works" lives in the shared `HowBestValueRow`
/// (HowBestValueSheet.swift) — the same row the Key sheet uses, so the sheet and
/// the Key cannot drift.