Two things the listing row was saying twice, or too loosely.
- The verdict capsule used a Label, and a Label lays its icon and title out
across the width it is given: in a listing row that left 22pt of empty space
between the symbol and "Save £1.07 after travel". It is now an HStack with
an explicit 3pt spacing (5pt in the detail sheet), so the text sits beside its
icon and the capsule background hugs the pair. Measured on the same row: the
capsule text moved x 412 -> 341 and the pill 270-831 -> 270-691.
- The address line printed the station name a second time. The official Fuel
Finder address field usually opens with the station's own name ("CHIDSWELL
SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW" for "Jet Chidswell"): 14% of
the dump's addresses start with the exact name, and 13% also end with the
postcode the row appended again. StationAddressLine (new, in Shared/, so the
row and the detail sheet share one rule) drops a leading repeat of the name
and writes the postcode exactly once.
The rules are deliberately conservative: a leading segment carrying a house
number is a real street address and is left alone ("41 ANNACLOY RD ANNACLOY"),
and a partial-word match ("Jet" against "JETSON FILLING STATION") never cuts
into a word. 14 new tests (169 total, 0 failures) pin the shapes found in the
real dump as well as the examples above.
Verified on the simulator: the first listing row's second line went from
"CHIDSWELL SERVICE STA..." to "LEEDS ROAD, DEWSBURY...", and the detail sheet
reads "Sainsbury's Halifax / WADE STREET, HX1 1SB".
204 lines
9.1 KiB
Swift
204 lines
9.1 KiB
Swift
import SwiftUI
|
|
|
|
struct FuelSavingsAdjustmentView: View {
|
|
@Binding var amount: FuelSavingsFillAmount
|
|
let referencePricePencePerLitre: Double?
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
private var unitBinding: Binding<FuelSavingsFillUnit> {
|
|
Binding(
|
|
get: { amount.unit },
|
|
set: { newUnit in
|
|
guard newUnit != amount.unit else { return }
|
|
if let referencePricePencePerLitre,
|
|
let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) {
|
|
amount = converted
|
|
} else {
|
|
amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: 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) }
|
|
)
|
|
}
|
|
}
|
|
|
|
struct FuelSavingsSummaryView: View {
|
|
let availability: FuelSavingsAvailability?
|
|
let amount: FuelSavingsFillAmount
|
|
let baselineStationName: String?
|
|
var compact: Bool = false
|
|
/// Sizes the "more driving than closest" 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 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 ("Save £2.75 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) {
|
|
capsule(for: result, compact: true)
|
|
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
|
extraDistanceMiles: result.extraDistanceMiles,
|
|
unit: distanceUnit
|
|
) {
|
|
Text(extraTravel)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.85)
|
|
}
|
|
}
|
|
// 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) {
|
|
capsule(for: result, compact: false)
|
|
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
|
extraDistanceMiles: result.extraDistanceMiles,
|
|
unit: distanceUnit
|
|
) {
|
|
Text(extraTravel)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if result.status == .baseline {
|
|
Text("Your closest station with a price for this fuel — every other saving is measured against it.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary)
|
|
LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP))
|
|
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
|
|
if result.distanceBasis.isEstimate {
|
|
Text("Estimated from straight-line distance — refined once the route is measured.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text("Estimate covers fuel cost only. It excludes time, parking, tolls and wear.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
case .unavailable(let reason):
|
|
Text(compact ? "Savings unavailable" : "Worth the Drive 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)
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
.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"
|
|
}
|
|
}
|
|
}
|