Top and Cheapest were two words for one state: both meant "the lowest price among the stations on screen", differing only in the candidate set (radius vs Favourites). Which word a surface used had drifted by device — the phone's rows said Cheapest while the watch said Top for the identical state. Top is gone from the enum, the Key legend, the badge and the watch's hand-rolled label; Cheapest survives because it names the measure and is already the word the favourites copy uses. Closest is now a real station label rather than a verdict. StationRecommendationBadge gains .closest with a total precedence — Best value, then Cheapest, then Closest — because a row has room for one label and proximity is the weakest claim: a short drive says nothing about the price, and unlike the other two it reports no saving at all. The badge sits above the price, where the other two sit. The closest row's verdict capsule is suppressed: its only word for that state was "Closest", so printing it below as well would state the same fact twice, three lines apart. Other rows keep their Saves / Break even / Costs capsules unchanged. "Closest station" becomes "Closest", read from the single FuelSavingsResult.baselineHeadline constant so the badge, the verdict and the Key cannot word it differently. The Key legend renders all three station labels with the shared StationLabelBadge; the old KeyStationLabelIndicator, which special-cased the closest station to a wider verdict pill so it read as a different class of thing from the two above it, is deleted. One closest icon everywhere. The listing and detail verdict symbols used location.circle and location.circle.fill for the state the Key taught as location.north.fill; both now read StationLabelKind.closest.symbol, so the three surfaces resolve one glyph from one place. The detail sheet gains the Closest capsule below the map, ordered to match the Key. Favourites gains the nearest favourite as its Closest, measured on the same one-way distance its rows print and restricted to favourites with a price for the active fuel — matching the radius tab, where the closest station is by definition the savings baseline. closestStationID flows from the one scaffold baseline the savings lookup already compares against, so a row's badge and the reference station cannot disagree. No savings calculation, threshold, ranking, eligibility or station-selection logic changed. The Key sheet's detent is now bound rather than fixed, so dragging it up to read the Station labels section sticks instead of snapping back; a QA launch argument opens it at full height, since the 0.6 detent puts that section below the fold on every device. That argument is read unconditionally, unlike the other harness hooks which are #if DEBUG-gated. Tests: 278 pass, 9 new — the label precedence ladder across every combination, capsule order in the sheet, Closest alone, and the retired label pinned absent.
689 lines
30 KiB
Swift
689 lines
30 KiB
Swift
import SwiftUI
|
|
import WidgetKit
|
|
|
|
/// The "Key" legend: price ratings, savings capsules, station labels, the
|
|
/// favourite glyph, then the two deeper explanations. One sheet for both list
|
|
/// tabs — Stations opens it from the info button in the navigation bar,
|
|
/// Favourites from the button beside Edit — so the legend cannot drift between
|
|
/// them.
|
|
///
|
|
/// Dismisses through the environment rather than the presenting view's state, and
|
|
/// takes the two nested-sheet flags as bindings, so each tab keeps owning its own
|
|
/// QA launch-argument hooks.
|
|
struct KeySheetView: View {
|
|
@Binding var showBestValueInfo: Bool
|
|
@Binding var showImportantInformation: Bool
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
/// Where the user last dragged the sheet. Bound rather than fixed at 0.6 so
|
|
/// dragging it up to read the Station labels section sticks, instead of
|
|
/// snapping back every time the sheet is reopened.
|
|
///
|
|
/// The QA launch argument sets the INITIAL detent rather than assigning one
|
|
/// after the sheet appears: assigning a new selection while the sheet is still
|
|
/// presenting is ignored, which left the legend stuck at 0.6 and the Station
|
|
/// labels section off screen.
|
|
@State private var detent: PresentationDetent =
|
|
ProcessInfo.processInfo.arguments.contains("-qaKeySheetLarge") ? .large : .fraction(0.6)
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[0]) {
|
|
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[0], color: .green)
|
|
}
|
|
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[1]) {
|
|
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[1], color: .orange)
|
|
}
|
|
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[2]) {
|
|
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[2], color: .red)
|
|
}
|
|
} header: {
|
|
Text("Price ratings")
|
|
}
|
|
|
|
Section {
|
|
ForEach(KeySheetContent.savings, id: \.label) { item in
|
|
KeySheetDescriptionRow(item: item) {
|
|
KeySavingsCapsule(label: item.label)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Savings")
|
|
}
|
|
|
|
Section {
|
|
ForEach(StationLabelKind.allCases, id: \.self) { kind in
|
|
KeySheetDescriptionRow(item: item(for: kind)) {
|
|
StationLabelBadge(kind: kind)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Station labels")
|
|
}
|
|
|
|
Section {
|
|
KeySheetDescriptionRow(item: KeySheetContent.favourite) {
|
|
FavouriteStarGlyph(isFavourite: true)
|
|
}
|
|
} header: {
|
|
Text("Favourites")
|
|
}
|
|
|
|
Section {
|
|
HowBestValueRow { showBestValueInfo = true }
|
|
}
|
|
|
|
Section {
|
|
ImportantInformationRow { showImportantInformation = true }
|
|
}
|
|
}
|
|
.navigationTitle("Key")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") { dismiss() }
|
|
}
|
|
}
|
|
// Nested sheet: the Key sheet stays behind the explanation.
|
|
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
|
|
// Nested sheet: the Key sheet stays behind the disclaimer.
|
|
.importantInformationSheet(isPresented: $showImportantInformation)
|
|
}
|
|
// QA hook: `-qaKeySheetLarge` opens the legend at full height — see the
|
|
// `detent` state. The 0.6 detent puts the Station labels section below the
|
|
// fold on every device, including the tallest iPhone, so the three label
|
|
// badges cannot otherwise be seen on screen.
|
|
.presentationDetents([.fraction(0.6), .large], selection: $detent)
|
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
|
}
|
|
|
|
/// The Key row's copy for a label kind. Fails loudly in debug rather than
|
|
/// silently rendering a blank row if a kind is ever added without its entry.
|
|
private func item(for kind: StationLabelKind) -> KeySheetContent.Item {
|
|
if let match = KeySheetContent.stationLabels.first(where: { $0.label == kind.label }) {
|
|
return match
|
|
}
|
|
assertionFailure("No KeySheetContent.stationLabels entry for \(kind)")
|
|
return KeySheetContent.Item(label: kind.label, description: "")
|
|
}
|
|
}
|
|
|
|
/// Stations tab — the main list with fuel picker, distance filter, sort, pagination.
|
|
struct StationsView: View {
|
|
let stations: [FuelStation]
|
|
let totalCount: Int
|
|
let isLoading: Bool
|
|
@Binding var selectedFuel: FuelType
|
|
@Binding var sortMode: SortMode
|
|
@Binding var stationLimit: Int
|
|
let distanceUnit: DistanceUnit
|
|
let priceDisplayStyle: PriceDisplayStyle
|
|
let baselinePrice: Double?
|
|
let cheapestStationID: String?
|
|
/// The station the savings are measured against — the closest station with an
|
|
/// eligible price. Passed in from the owner of the scaffold baseline so this
|
|
/// list's Closest badge and the reference station cannot disagree.
|
|
let closestStationID: String?
|
|
let location: Coordinate?
|
|
let favouriteIDs: Set<String>
|
|
let stationTapAction: StationTapAction
|
|
let fuelSavingsSettings: FuelSavingsSettings
|
|
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
|
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
|
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
|
var onRefresh: () async -> Void = {}
|
|
|
|
/// Pagination: one page = 10 rows, reset whenever the underlying list
|
|
/// changes (new fetch, fuel/sort/radius switch). RAG/badge/deltas still come
|
|
/// from the whole radius pool via `cheapestPrice` — paging never changes
|
|
/// which station is "best".
|
|
@State private var pageSize = 10
|
|
@State private var visibleCount = 10
|
|
|
|
/// Price-rating legend (the "Key") lives in a sheet, opened from the
|
|
/// info button in the header — keeps the list focused on stations.
|
|
@State private var showKey = false
|
|
|
|
/// The "How Best Value works" sheet. The explanation used to be a section in
|
|
/// the Key sheet; it is its own sheet now, opened from a row at the bottom of
|
|
/// the Key sheet and from the More info sheet.
|
|
@State private var showBestValueInfo = false
|
|
|
|
/// The shared "Important information" disclaimer sheet. It lives at the
|
|
/// bottom of the Key sheet here; the same sheet is reused wherever else the
|
|
/// disclaimer is needed.
|
|
@State private var showImportantInformation = false
|
|
|
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
|
@State private var detailStation: FuelStation?
|
|
|
|
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
|
/// directions context + "N/TOTAL stations updated". The numerator is the
|
|
/// current list pool; the denominator is the full UK station total from
|
|
/// the last chain fetch (8,022 mirror snapshot) — hidden until known.
|
|
private var footerCaption: String {
|
|
let miles = distanceUnit.displayMiles(stationLimit)
|
|
let unit = distanceUnit.label(for: Double(miles))
|
|
let fuel = selectedFuel.displayName
|
|
let mode: String
|
|
switch sortMode {
|
|
case .closest: mode = "Closest"
|
|
case .cheapest: mode = "Cheapest"
|
|
case .worthTheDrive: mode = "Best Value"
|
|
}
|
|
let tapHint = stationTapAction == .openMap
|
|
? "tap a station for directions."
|
|
: "tap a station for details."
|
|
let ratio: String
|
|
if let total = FuelStore.loadStationCount() {
|
|
ratio = "\(totalCount)/\(total)"
|
|
} else {
|
|
ratio = "\(totalCount)"
|
|
}
|
|
if location != nil {
|
|
if sortMode == .closest {
|
|
return "\(mode) \(fuel) stations — closest first, lowest price within \(miles) \(unit) · \(ratio) stations updated"
|
|
}
|
|
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
|
|
}
|
|
return "\(mode) \(fuel) — \(ratio) stations updated · \(tapHint)"
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section("Fuel type") {
|
|
FuelTypeSegmentedPicker(selection: $selectedFuel) { newValue in
|
|
FuelStore.saveSelectedFuel(newValue)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
}
|
|
|
|
Section("Distance") {
|
|
Picker("Distance", selection: $stationLimit) {
|
|
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
|
let shown = distanceUnit.displayMiles(miles)
|
|
Text("\(shown) \(distanceUnit.label(for: Double(shown)))").tag(miles)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
// In Closest mode the whole country is the pool — the
|
|
// radius is meaningless ("closest" must never return an
|
|
// empty state), so the picker is disabled but its value is
|
|
// kept for when the user switches back to Cheapest.
|
|
.disabled(sortMode == .closest)
|
|
.opacity(sortMode == .closest ? 0.5 : 1)
|
|
.onChange(of: stationLimit) { _, newValue in
|
|
FuelStore.saveStationLimit(newValue)
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
Section("Stations") {
|
|
Picker("Sort by", selection: $sortMode) {
|
|
ForEach(availableSortModes) { mode in
|
|
Text(mode.displayName).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.onChange(of: sortMode) { _, newValue in
|
|
FuelStore.saveSortMode(newValue)
|
|
}
|
|
.padding(.vertical, 2)
|
|
|
|
// Only offered while the feature is actually configured — a
|
|
// stale stored sort mode must not keep the control on screen
|
|
// for a switched-off feature.
|
|
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
|
|
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
|
}
|
|
|
|
if fuelSavingsSettings.isConfigured && !stations.isEmpty {
|
|
Text(String(localized: "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs.", comment: "Station listing price and savings explanation"))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
if isLoading && stations.isEmpty {
|
|
HStack(spacing: 10) {
|
|
ProgressView()
|
|
Text("Fetching prices…")
|
|
}
|
|
} else if stations.isEmpty {
|
|
let miles = distanceUnit.displayMiles(stationLimit)
|
|
let unit = distanceUnit.label(for: Double(miles))
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(String(format: NSLocalizedString("No %@ stations within %lld %@.", comment: "Empty state when the radius finds nothing nearby"), selectedFuel.displayName, miles, unit))
|
|
.foregroundStyle(.secondary)
|
|
Text("FuelBoard covers the whole UK — try Closest to browse every station.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Button("Browse all UK stations") {
|
|
sortMode = .closest
|
|
}
|
|
.font(.caption)
|
|
}
|
|
.padding(.vertical, 4)
|
|
} else {
|
|
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
|
|
stationRow(station)
|
|
}
|
|
|
|
if visibleCount < stations.count {
|
|
Button {
|
|
visibleCount += pageSize
|
|
} label: {
|
|
HStack {
|
|
Spacer()
|
|
Text("Show \(min(pageSize, stations.count - visibleCount)) more (\(stations.count - visibleCount) remaining)")
|
|
Spacer()
|
|
}
|
|
}
|
|
} else {
|
|
Text("All \(stations.count) stations shown")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bottom-of-tab explainer (moved from the top 2026-08-16),
|
|
// styled like the Favourites tab's cheapest callout: a caption
|
|
// paragraph in its own section cell.
|
|
Section {
|
|
Text(footerCaption)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
}
|
|
.refreshable {
|
|
// Manual override for the twice-a-day cache policy.
|
|
await onRefresh()
|
|
}
|
|
.onChange(of: stations) { _, _ in
|
|
// New fetch or filter switch → back to the first page.
|
|
visibleCount = pageSize
|
|
}
|
|
// Native large-title behaviour (Mail-style): the system shows the
|
|
// large title at rest, scrolls it away and fades the compact
|
|
// inline title in. No custom toolbar needed — the native mechanism
|
|
// only renders text, which is exactly what we want here.
|
|
// No numeric field is left on this screen — the Worth the Drive
|
|
// fill-up control is a slider — so no keyboard should appear here.
|
|
// The Done bar and interactive dismissal stay as the escape hatch
|
|
// for any field that returns.
|
|
.keyboardDoneButton()
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.navigationTitle("FuelBoard")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.onAppear {
|
|
#if DEBUG
|
|
// QA hook: launch with `-showKeySheet` to verify the legend
|
|
// sheet without driving a tap (simctl has no tap command).
|
|
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
|
showKey = true
|
|
}
|
|
// QA hook: `-showBestValueInfo` opens the Key sheet and then the
|
|
// explanation on top of it, so the copy can be screenshotted
|
|
// without a tap. It has to chain: the explanation's `.sheet` lives
|
|
// inside the Key sheet's content, so it cannot present while the
|
|
// Key sheet is shut.
|
|
if BestValueInfoQA.isRequested {
|
|
showKey = true
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
|
showBestValueInfo = true
|
|
}
|
|
}
|
|
// QA hook: `-showImportantInfo` opens the Key sheet and then the
|
|
// shared disclaimer on top of it, so the copy can be
|
|
// screenshotted without a tap. Same chained requirement as
|
|
// `-showBestValueInfo` above.
|
|
if ImportantInformationQA.isRequested {
|
|
showKey = true
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
|
showImportantInformation = true
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
|
|
// "Setup Best Value" from the explanation sheet. That sheet closes
|
|
// itself and ContentView switches to Settings, but both of the
|
|
// sheets this view can have open are presented from HERE and each
|
|
// survived the tab switch, floating over Settings: the Key sheet
|
|
// and the More info sheet. Close whichever is up.
|
|
showKey = false
|
|
detailStation = nil
|
|
}
|
|
.task(id: stations.count) {
|
|
#if DEBUG
|
|
// QA hook: `-showDetail` opens the More info sheet for the first
|
|
// listed station, so the sheet can be screenshotted without a
|
|
// tap. Waits for the list to arrive first.
|
|
guard ProcessInfo.processInfo.arguments.contains("-showDetail"),
|
|
detailStation == nil,
|
|
let first = stations.first else { return }
|
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
|
detailStation = first
|
|
#endif
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
showKey = true
|
|
} label: {
|
|
Image(systemName: "info.circle")
|
|
}
|
|
.accessibilityLabel("Key")
|
|
}
|
|
}
|
|
.sheet(isPresented: $showKey) {
|
|
keySheet
|
|
}
|
|
.sheet(item: $detailStation) { station in
|
|
detailSheet(for: station)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func detailSheet(for station: FuelStation) -> some View {
|
|
StationDetailView(
|
|
station: station,
|
|
location: location,
|
|
distanceUnit: distanceUnit,
|
|
priceStyle: priceDisplayStyle,
|
|
selectedFuel: selectedFuel,
|
|
fuelSavings: savingsForStation(station),
|
|
fuelSavingsAmount: fuelSavingsAmount,
|
|
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
|
// The same flag the row gets from `cheapestStationID`.
|
|
isCheapestInRange: station.id == cheapestStationID,
|
|
// The same flag the row gets from `closestStationID`.
|
|
isClosestInRange: station.id == closestStationID,
|
|
isFavourite: favouriteIDs.contains(station.id),
|
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
|
)
|
|
}
|
|
|
|
/// The legend sheet, shared with the Favourites tab. Nothing varies by fuel:
|
|
/// every station label has a fixed semantic tone, so the legend reads the same
|
|
/// whichever grade the reader is looking at.
|
|
private var keySheet: some View {
|
|
KeySheetView(
|
|
showBestValueInfo: $showBestValueInfo,
|
|
showImportantInformation: $showImportantInformation
|
|
)
|
|
}
|
|
|
|
private func stationRow(_ station: FuelStation) -> some View {
|
|
StationRow(
|
|
station: station,
|
|
fuel: selectedFuel,
|
|
location: location,
|
|
distanceUnit: distanceUnit,
|
|
priceDisplayStyle: priceDisplayStyle,
|
|
baselinePrice: baselinePrice,
|
|
isCheapestResult: station.id == cheapestStationID,
|
|
isClosestResult: station.id == closestStationID,
|
|
isFavourite: favouriteIDs.contains(station.id),
|
|
tapAction: stationTapAction,
|
|
fuelSavings: savingsForStation(station),
|
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
|
onShowDetails: { detailStation = $0 }
|
|
)
|
|
}
|
|
|
|
private var availableSortModes: [SortMode] {
|
|
var modes: [SortMode] = [.closest, .cheapest]
|
|
if fuelSavingsSettings.isConfigured {
|
|
modes.append(.worthTheDrive)
|
|
}
|
|
return modes
|
|
}
|
|
|
|
}
|
|
|
|
/// The station list's real RAG dot, paired with the rating name. The range lives
|
|
/// in the aligned description column rather than as a separate trailing value.
|
|
private struct KeyPriceRatingIndicator: View {
|
|
let item: KeySheetContent.Item
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
HStack(spacing: 5) {
|
|
PriceRatingDot(color: color)
|
|
Text(LocalizedStringKey(item.label))
|
|
.font(.caption.weight(.semibold))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every Key section shares this one two-column row. The scaled first-column
|
|
/// width keeps every description on the same leading axis while preserving the
|
|
/// inline layout at accessibility sizes; descriptions own the flexible column
|
|
/// and grow vertically without truncating.
|
|
private struct KeySheetDescriptionRow<Indicator: View>: View {
|
|
private let indicatorColumnWidth: CGFloat = 128
|
|
|
|
let item: KeySheetContent.Item
|
|
@ViewBuilder let indicator: Indicator
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
indicator
|
|
.frame(width: indicatorColumnWidth, alignment: .leading)
|
|
description
|
|
}
|
|
.padding(.vertical, 3)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("\(item.label). \(item.description)")
|
|
}
|
|
|
|
private var description: some View {
|
|
Text(LocalizedStringKey(item.description))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
/// Uses the same listing capsule component as real station rows, with neutral
|
|
/// fixture numbers: only the verdict is visible in this compact variant.
|
|
private struct KeySavingsCapsule: View {
|
|
let label: String
|
|
|
|
/// Keyed on the shared label so the legend cannot demonstrate a word the
|
|
/// listing capsule never draws.
|
|
private var status: FuelSavingsStatus {
|
|
switch label {
|
|
case String(localized: "Saves"): return .worthTheDrive
|
|
case String(localized: "Costs"): return .notWorthTheDrive
|
|
default: return .breakEven
|
|
}
|
|
}
|
|
|
|
private var result: FuelSavingsResult {
|
|
let netSaving = status == .worthTheDrive ? 1.0 : (status == .notWorthTheDrive ? -1.0 : 0)
|
|
return FuelSavingsResult(
|
|
fillLitres: 30,
|
|
grossSavingGBP: netSaving,
|
|
extraDrivingCostGBP: 0,
|
|
extraDistanceMiles: 0,
|
|
netSavingGBP: netSaving,
|
|
status: status,
|
|
journeyConfidence: .confirmed
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
// The legend demonstrates the wording, not a figure: `result` above is a
|
|
// placeholder verdict, and its £1.00 must never reach the screen.
|
|
ListingFuelSavingsCapsule(result: result, showsAmount: false)
|
|
}
|
|
}
|
|
|
|
/// Shared RAG dot used beside live station prices and in the Key.
|
|
///
|
|
/// 12pt is the app's one diameter: it reads at a glance beside a 22pt price
|
|
/// without competing with it, and it is large enough that the three colours are
|
|
/// distinguishable for colour-vision-deficient users at arm's length. The widget
|
|
/// keeps a smaller dot of its own — its rows are far tighter than a phone row —
|
|
/// so this size is deliberately the APP's.
|
|
struct PriceRatingDot: View {
|
|
let color: Color
|
|
var diameter: CGFloat = PriceRatingDot.standardDiameter
|
|
|
|
/// The app's standard dot diameter, in points.
|
|
static let standardDiameter: CGFloat = 12
|
|
|
|
var body: some View {
|
|
Circle()
|
|
.fill(color)
|
|
.frame(width: diameter, height: diameter)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
/// Shared favourite glyph used by station rows and the Key.
|
|
struct FavouriteStarGlyph: View {
|
|
let isFavourite: Bool
|
|
|
|
var body: some View {
|
|
Image(systemName: isFavourite ? "star.fill" : "star")
|
|
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
|
}
|
|
}
|
|
|
|
/// Shared station-label capsule. `StationRow` uses the same view for its live
|
|
/// BEST VALUE/CHEAPEST badge, so the Key cannot drift from the listing style.
|
|
///
|
|
/// One tone per label kind, from the shared palette: purple for best value, blue
|
|
/// for cheapest and top (a top favourite IS the cheapest favourite, so they share
|
|
/// a class), neutral for proximity. The symbol is the label's own meaning, sized
|
|
/// to sit on the caption's cap height rather than to dominate it.
|
|
struct StationLabelBadge: View {
|
|
let kind: StationLabelKind
|
|
|
|
private var tone: CapsuleTone {
|
|
switch kind {
|
|
case .bestValue: return .bestValue
|
|
case .cheapest: return .cheapest
|
|
case .closest: return .closest
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: kind.symbol)
|
|
.font(.caption2.weight(.semibold))
|
|
.accessibilityHidden(true)
|
|
Text(LocalizedStringKey(kind.label))
|
|
.font(.caption.weight(.semibold))
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
}
|
|
.foregroundStyle(tone.foreground)
|
|
.padding(.horizontal, 7)
|
|
.padding(.vertical, 3)
|
|
.background(Capsule().fill(tone.background))
|
|
}
|
|
}
|
|
|
|
/// Every station label in the Key is the SAME `StationLabelBadge` the listing rows
|
|
/// and the More info sheet draw.
|
|
///
|
|
/// The closest station used to be the exception — the legend rendered the real
|
|
/// verdict capsule for it, a wider pill with different fill and shape, so it read as
|
|
/// a different class of thing from Best value and Cheapest sitting above it. Now
|
|
/// that its label is the single word "Closest", it belongs in the same column, in
|
|
/// the same capsule, at the same size.
|
|
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
|
|
|
|
extension FuelType {
|
|
/// Short segment label ("Unleaded" / "Premium" / "Diesel") for
|
|
/// the picker and description.
|
|
var shortName: String {
|
|
switch self {
|
|
case .e10: return "Unleaded"
|
|
case .e5: return "Premium"
|
|
case .diesel: return "Diesel"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fuel-type selector styled like a segmented control, with a coloured pump
|
|
/// icon per fuel (green = unleaded, yellow = premium, cyan = diesel). Built
|
|
/// custom because the native `.segmented` picker tints every segment the same
|
|
/// accent colour — it can't show per-fuel pump colours. Shared by the Stations
|
|
/// and Favourites tabs.
|
|
struct FuelTypeSegmentedPicker: View {
|
|
@Binding var selection: FuelType
|
|
/// Which fuels to show. Defaults to all; the Favourites tab passes only
|
|
/// the fuels that actually have favourites.
|
|
var fuels: [FuelType] = FuelType.allCases
|
|
var onSelect: (FuelType) -> Void = { _ in }
|
|
|
|
@Namespace private var selectionSlider
|
|
|
|
var body: some View {
|
|
HStack(spacing: 3) {
|
|
ForEach(fuels) { fuel in
|
|
let isSelected = fuel == selection
|
|
Button {
|
|
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
|
|
selection = fuel
|
|
}
|
|
onSelect(fuel)
|
|
} label: {
|
|
HStack(spacing: 5) {
|
|
Image(systemName: "fuelpump.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(fuel.tintColor)
|
|
.scaleEffect(isSelected ? 1.08 : 0.94)
|
|
Text(fuel.shortName)
|
|
.font(.subheadline.weight(isSelected ? .semibold : .regular))
|
|
.contentTransition(.opacity)
|
|
}
|
|
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 7)
|
|
.background {
|
|
if isSelected {
|
|
Capsule()
|
|
.fill(Color(UIColor.systemBackground))
|
|
.overlay {
|
|
Capsule()
|
|
.stroke(fuel.tintColor.opacity(0.28), lineWidth: 1)
|
|
}
|
|
.shadow(color: fuel.tintColor.opacity(0.16), radius: 6, y: 2)
|
|
.matchedGeometryEffect(id: "fuelSelectionSlider", in: selectionSlider)
|
|
}
|
|
}
|
|
.scaleEffect(isSelected ? 1.02 : 0.98)
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(3)
|
|
.background(Capsule().fill(Color(.secondarySystemBackground)))
|
|
.animation(.spring(response: 0.42, dampingFraction: 0.78), value: selection)
|
|
}
|
|
}
|
|
|
|
#Preview("Fuel Picker") {
|
|
@Previewable @State var selectedFuel: FuelType = .e10
|
|
FuelTypeSegmentedPicker(selection: $selectedFuel)
|
|
.padding()
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|