182 lines
7.4 KiB
Swift
182 lines
7.4 KiB
Swift
import SwiftUI
|
||
|
||
struct FuelSavingsAdjustmentView: View {
|
||
@Binding var amount: FuelSavingsFillAmount
|
||
|
||
@State private var showingComparisonHelp = false
|
||
|
||
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) {
|
||
HStack {
|
||
Text("How much fuel?")
|
||
.font(.subheadline.weight(.semibold))
|
||
Spacer()
|
||
Button {
|
||
showingComparisonHelp = true
|
||
} label: {
|
||
Image(systemName: "info.circle")
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityLabel("About comparison modes")
|
||
.popover(isPresented: $showingComparisonHelp) {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Comparison modes")
|
||
.font(.headline)
|
||
Text("£: Compare how much fuel your budget buys at each station.")
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Text("Litres: Compare the cost of buying the same amount of fuel.")
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.font(.footnote)
|
||
.frame(width: 280, alignment: .leading)
|
||
.padding()
|
||
.presentationCompactAdaptation(.popover)
|
||
}
|
||
}
|
||
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("Adjust your fill-up amount for this visit. Your default won’t change.")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
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,
|
||
// so switching modes starts from that mode's configured default.
|
||
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
|
||
}
|
||
)
|
||
}
|
||
|
||
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) }
|
||
)
|
||
}
|
||
}
|
||
|
||
/// The colour-coded monetary verdict capsule used by the detail sheet and other
|
||
/// full financial summaries. Listing rows use `ListingFuelSavingsCapsule`.
|
||
///
|
||
/// 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.
|
||
struct FuelSavingsCapsule: View {
|
||
let result: FuelSavingsResult
|
||
var compact: Bool = true
|
||
|
||
var body: some View {
|
||
HStack(spacing: compact ? 3 : 5) {
|
||
Image(systemName: Self.symbol(for: result.status))
|
||
.accessibilityHidden(true)
|
||
Text(result.capsuleHeadline())
|
||
.lineLimit(1)
|
||
// Measured on a 393pt screen: the capsule is handed ~101pt, of
|
||
// which the icon, its spacing and the 8pt padding either side
|
||
// take 32pt, leaving ~69pt for the text. "Closest station" needs
|
||
// ~90pt at full size, so a 0.8 floor — 72pt — could not reach it
|
||
// and the row showed "Closest stati…" instead. 0.7 leaves ~63pt,
|
||
// which clears it at ~10pt type: scaled, never truncated.
|
||
.minimumScaleFactor(0.7)
|
||
}
|
||
.font(compact ? .caption.weight(.semibold) : .headline)
|
||
.foregroundStyle(Self.color(for: result.status))
|
||
.padding(.horizontal, compact ? 8 : 12)
|
||
.padding(.vertical, compact ? 4 : 7)
|
||
.background(Capsule().fill(Self.color(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.
|
||
static func color(for status: FuelSavingsStatus) -> Color {
|
||
switch status {
|
||
case .worthTheDrive: return .green
|
||
case .notWorthTheDrive: return .red
|
||
case .breakEven: return .secondary
|
||
case .baseline: return .blue
|
||
}
|
||
}
|
||
|
||
static 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"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Amount-free, neutral verdict used by station listing rows only. Keeping this
|
||
/// separate from `FuelSavingsCapsule` preserves the detail sheet's monetary,
|
||
/// colour-coded verdict and comparison presentation.
|
||
struct ListingFuelSavingsCapsule: View {
|
||
let result: FuelSavingsResult
|
||
|
||
var body: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||
Image(systemName: symbol)
|
||
.accessibilityHidden(true)
|
||
Text(result.listingCapsuleHeadline())
|
||
.lineLimit(nil)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.multilineTextAlignment(.leading)
|
||
}
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.primary)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.background(Capsule().fill(Color.secondary.opacity(0.14)))
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel(result.listingCapsuleHeadline())
|
||
}
|
||
|
||
private var symbol: String {
|
||
switch result.status {
|
||
case .worthTheDrive: return "checkmark.circle"
|
||
case .notWorthTheDrive: return "arrow.up.circle"
|
||
case .breakEven: return "equal.circle"
|
||
case .baseline: return "location.circle"
|
||
}
|
||
}
|
||
} |