One adaptive tone palette and one shape rule now drive every capsule on Stations, Favourites, the detail sheet and the Key. - CapsuleTone: the light/dark palette pairs in one place, resolved through a UIColor dynamic provider so a List row that re-renders without rebuilding its body still follows an appearance switch. Every pair clears 4.5:1 foreground-on-background in both appearances (4.96-6.72:1). - AdaptiveCapsule: capsule at one drawn line, rounded rect at two or more, radius 8 -> 10, measuring content before padding so insets cannot inflate the line count. - Listing verdict capsules move from white-on-muted-solid to the tinted palette, so Saves, Break even, Costs and Closest station read the same in the rows and in the detail sheet. - Best Value is purple, not green: green stays reserved for a financial saving, which is what the Saves capsule beside it means. - Price dots standardise to 12pt in the app (widget keeps its own 6pt dot; PriceRatingDot now takes a diameter so the difference is deliberate). - Station labels are sentence case with their recommended symbols (medal, tag, location.north.fill, trophy) and StationLabelKind replaces the Key's string-literal dispatch, so renaming a label can no longer drop a row. - Key says "Costs", matching the capsule's own headline (was "Costs more").
687 lines
28 KiB
Swift
687 lines
28 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
|
|
|
|
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)) {
|
|
KeyStationLabelIndicator(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)
|
|
}
|
|
.presentationDetents([.fraction(0.6), .large])
|
|
.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 topStationID: 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/TOP/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),
|
|
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,
|
|
isTopResult: station.id == topStationID,
|
|
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 .closestStation: return .closestStation
|
|
case .top: return .top
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
/// The Key sheet's indicator for a station label. The closest station is the one
|
|
/// exception: it renders the listing's real verdict capsule, so the legend
|
|
/// demonstrates the same fill, text and shape the rows show. Everything else is
|
|
/// the shared badge.
|
|
private struct KeyStationLabelIndicator: View {
|
|
let kind: StationLabelKind
|
|
|
|
var body: some View {
|
|
if kind == .closestStation {
|
|
ListingFuelSavingsCapsule(result: baselineResult)
|
|
} else {
|
|
StationLabelBadge(kind: kind)
|
|
}
|
|
}
|
|
|
|
private var baselineResult: FuelSavingsResult {
|
|
FuelSavingsResult(
|
|
fillLitres: 30,
|
|
grossSavingGBP: 0,
|
|
extraDrivingCostGBP: 0,
|
|
extraDistanceMiles: 0,
|
|
netSavingGBP: 0,
|
|
status: .baseline,
|
|
journeyConfidence: .confirmed
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|