The verdict capsule shared a line with "vs closest eligible station"; the
caption's layoutPriority squeezed the capsule until it truncated
("Approx. £2.75 better off after tra…"), and in this sort mode the verdict
is the point of the row.
- The capsule now owns the line and the caption sits under it.
- The capsule text also shrinks rather than truncating at large Dynamic
Type sizes, since nothing competes with it for width.
- The detail sheet keeps its side-by-side layout: it has the room, and the
verdict there is the sheet's headline.
319 lines
15 KiB
Swift
319 lines
15 KiB
Swift
import SwiftUI
|
|
|
|
struct FuelSavingsAdjustmentView: View {
|
|
@Binding var amount: FuelSavingsFillAmount
|
|
|
|
@State private var unitChangeNotice: String?
|
|
|
|
private var range: ClosedRange<Double> {
|
|
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
|
}
|
|
|
|
private var step: Double {
|
|
FuelSavingsCalculator.fillAmountSliderStep(for: amount.unit)
|
|
}
|
|
|
|
/// Read-out of the figure the slider is on. Deliberately NOT a text field:
|
|
/// a typed figure could sit outside the slider's own bounds, leaving the
|
|
/// control and the value it claimed to show disagreeing. Slider-only means
|
|
/// what you see is the figure the estimate actually uses.
|
|
private var valueText: String {
|
|
switch amount.unit {
|
|
case .pounds: return String(format: "£%.0f", amount.value)
|
|
case .litres: return String(format: "%.0f L", amount.value)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("How much are you filling up?")
|
|
.font(.subheadline.weight(.semibold))
|
|
Picker("Fill-up unit", selection: unitBinding) {
|
|
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
|
Text(unit.displayName).tag(unit)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
HStack {
|
|
Slider(value: sliderBinding, in: range, step: step)
|
|
.accessibilityLabel("Fill-up amount")
|
|
.accessibilityValue(valueText)
|
|
Text(valueText)
|
|
.monospacedDigit()
|
|
.frame(width: 72, alignment: .trailing)
|
|
}
|
|
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
Text("£ compares the litres your budget buys at each station; Litres compares one fixed quantity.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
if let unitChangeNotice {
|
|
Text(unitChangeNotice)
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var unitBinding: Binding<FuelSavingsFillUnit> {
|
|
Binding(
|
|
get: { amount.unit },
|
|
set: { newUnit in
|
|
guard newUnit != amount.unit else { return }
|
|
// No one litre figure equals a budget at every candidate price.
|
|
// A unit change starts a new, explicitly labelled comparison.
|
|
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
|
|
unitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
|
|
}
|
|
)
|
|
}
|
|
|
|
private var sliderBinding: Binding<Double> {
|
|
Binding(
|
|
get: { min(max(amount.value, range.lowerBound), range.upperBound) },
|
|
set: { amount.value = min(max($0, range.lowerBound), range.upperBound) }
|
|
)
|
|
}
|
|
}
|
|
|
|
struct FuelSavingsSummaryView: View {
|
|
let availability: FuelSavingsAvailability?
|
|
let amount: FuelSavingsFillAmount
|
|
/// Candidate/baseline price confidence for every Best Value result.
|
|
var priceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
|
|
var priceWarning: String? = nil
|
|
var baselineStationName: String? = nil
|
|
var compact: Bool = false
|
|
/// Sizes the "extra return travel" caption in the user's own unit (the
|
|
/// stored figure is miles) — used by both the row banner and the detail sheet.
|
|
var distanceUnit: DistanceUnit = .miles
|
|
var showInfoAction: (() -> Void)? = nil
|
|
|
|
private func comparisonLabel(for result: FuelSavingsResult) -> String {
|
|
FuelSavingsFormat.comparisonLabel(status: result.status)
|
|
}
|
|
|
|
var body: some View {
|
|
switch availability {
|
|
case .available(let result):
|
|
if compact {
|
|
// FULL-WIDTH banner under the row. Inline inside the middle
|
|
// column the capsule truncated ("Approx. £2.75 better off after
|
|
// tra…") because
|
|
// the column is squeezed by the price block — the verdict is the
|
|
// point of this sort mode, so it gets its own line with room.
|
|
// Colour carries the verdict: green save, red worse off, grey
|
|
// break even, blue baseline. lineLimit + minimumScaleFactor keep
|
|
// it to one line, so rows stay even.
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
// The capsule gets the WHOLE line and the comparison caption
|
|
// sits beneath it. Side by side, the caption's layoutPriority
|
|
// squeezed the verdict until it truncated ("Approx. £2.75
|
|
// better off after tra…"), and in this sort mode the verdict
|
|
// is the point of the row.
|
|
capsule(for: result, compact: true)
|
|
.accessibilityLabel(accessibilityHeadline(for: result))
|
|
Text(comparisonLabel(for: result))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.85)
|
|
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
|
if let warning = priceWarning ?? priceConfidence.warningText {
|
|
Text(warning)
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
.accessibilityLabel(warning)
|
|
}
|
|
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
|
extraDistanceMiles: result.extraDistanceMiles,
|
|
unit: distanceUnit,
|
|
confidence: result.journeyConfidence
|
|
) {
|
|
Text(extraTravel)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.85)
|
|
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
|
|
}
|
|
}
|
|
// Aligns the banner with the row's text column (46 pt logo + 12
|
|
// pt spacing) so it reads as part of the row, not the cell.
|
|
.padding(.leading, 58)
|
|
} else {
|
|
// Detail sheet: the SAME verdict capsule and the SAME caption as
|
|
// the listing rows (one implementation, so wording and colour
|
|
// cannot drift), followed by the breakdown only this view has
|
|
// room for. The old shape led with a status word + headline that
|
|
// matched neither the listing nor each other.
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
capsule(for: result, compact: false)
|
|
.accessibilityLabel(accessibilityHeadline(for: result))
|
|
Text(comparisonLabel(for: result))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.layoutPriority(1)
|
|
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
|
}
|
|
LabeledContent("Your fill-up", value: amountText)
|
|
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
|
|
.monospacedDigit()
|
|
if amount.unit == .pounds {
|
|
Text("You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let warning = priceWarning ?? priceConfidence.warningText {
|
|
Text(warning)
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
.accessibilityLabel(warning)
|
|
}
|
|
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
|
extraDistanceMiles: result.extraDistanceMiles,
|
|
unit: distanceUnit,
|
|
confidence: result.journeyConfidence
|
|
) {
|
|
Text(extraTravel)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
|
|
}
|
|
if result.status == .baseline {
|
|
Text("Your closest eligible station by journey distance is the baseline — every other saving is measured against it.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text(baselineStationName.map { "Compared with \($0)." } ?? "Compared with your closest eligible station.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
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"
|
|
)
|
|
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
|
|
Divider()
|
|
LabeledContent {
|
|
Text(result.headline())
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(capsuleColor(for: result.status))
|
|
.monospacedDigit()
|
|
} label: {
|
|
Text("Total")
|
|
.fontWeight(.semibold)
|
|
}
|
|
if result.journeyConfidence.isEstimated {
|
|
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text("Estimate covers fuel cost only. It excludes time, parking, tolls and wear.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let showInfoAction {
|
|
Rectangle()
|
|
.fill(Color(.separator))
|
|
.frame(height: 0.5)
|
|
.padding(.vertical, 4)
|
|
Button(action: showInfoAction) {
|
|
HStack(spacing: 8) {
|
|
Text("How Best Value works")
|
|
Spacer(minLength: 8)
|
|
Image(systemName: "chevron.forward")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
case .unavailable(let reason):
|
|
Text(compact ? "Savings unavailable" : "Best Value unavailable: \(reason)")
|
|
.font(compact ? .caption2 : .caption)
|
|
.foregroundStyle(.secondary)
|
|
case nil:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
private var amountText: String {
|
|
switch amount.unit {
|
|
case .pounds: return String(format: "£%.0f", amount.value)
|
|
case .litres: return String(format: "%.0f litres", amount.value)
|
|
}
|
|
}
|
|
|
|
/// VoiceOver form of the extra-travel caption ("Approximately 6.4 miles extra
|
|
/// return travel"): the abbreviation is spelled out and the unit is a word.
|
|
/// Falls back to the visible string if no spoken form exists.
|
|
private func spokenExtraTravel(for result: FuelSavingsResult, fallback: String) -> String {
|
|
FuelSavingsFormat.extraTravelSpokenLabel(
|
|
extraDistanceMiles: result.extraDistanceMiles,
|
|
unit: distanceUnit,
|
|
confidence: result.journeyConfidence
|
|
) ?? fallback
|
|
}
|
|
|
|
/// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for
|
|
/// and leaves the plain words ("Break even", "Closest eligible
|
|
/// station") alone — one source, so visible and spoken copy cannot drift.
|
|
private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
|
|
result.spokenHeadline()
|
|
}
|
|
|
|
/// The colour-coded verdict capsule — ONE implementation for the listing rows
|
|
/// and the detail sheet, so the verdict reads identically in both; the sheet
|
|
/// just gets the larger font and padding.
|
|
///
|
|
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
|
|
/// a Label lays its icon and title out across the row and leaves a visible
|
|
/// gap between them (22pt in the listing row), which read as the text having
|
|
/// drifted away from its symbol. The icon is decorative here — the verdict
|
|
/// is in the text — so VoiceOver reads the capsule as one phrase.
|
|
private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View {
|
|
HStack(spacing: compact ? 3 : 5) {
|
|
Image(systemName: symbol(for: result.status))
|
|
.accessibilityHidden(true)
|
|
Text(result.capsuleHeadline())
|
|
.lineLimit(1)
|
|
// Belt and braces: now that the capsule owns its line it has no
|
|
// rival for width, and shrinking beats truncating at large
|
|
// Dynamic Type sizes.
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.font(compact ? .caption.weight(.semibold) : .headline)
|
|
.foregroundStyle(capsuleColor(for: result.status))
|
|
.padding(.horizontal, compact ? 8 : 12)
|
|
.padding(.vertical, compact ? 4 : 7)
|
|
.background(Capsule().fill(capsuleColor(for: result.status).opacity(0.15)))
|
|
}
|
|
|
|
/// Verdict colour: green save, red worse off, grey break even, blue
|
|
/// baseline — the same green/amber/red family the price dot already uses.
|
|
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
|
|
switch status {
|
|
case .worthTheDrive: return .green
|
|
case .notWorthTheDrive: return .red
|
|
case .breakEven: return .secondary
|
|
case .baseline: return .blue
|
|
}
|
|
}
|
|
|
|
private func symbol(for status: FuelSavingsStatus) -> String {
|
|
switch status {
|
|
case .worthTheDrive: return "checkmark.circle.fill"
|
|
case .breakEven: return "equal.circle.fill"
|
|
case .notWorthTheDrive: return "xmark.circle.fill"
|
|
case .baseline: return "location.circle.fill"
|
|
}
|
|
}
|
|
}
|