284 lines
11 KiB
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.
|