Files
fuelboard/FuelBoard/HowBestValueSheet.swift
FuelBoard Contributor c69bdb3c5f Show ranking capsules and value breakdown in station details
The More info sheet now lists every ranking a station qualifies for, directly under the map and above the price and distance: the same StationLabelBadge the listing rows render, in a flow layout so two or three capsules wrap to a new line rather than truncating at large Dynamic Type.

The rules are not restated. StationRanking defers to StationRecommendationBadge.badge for the Best Value gate, and takes Cheapest and Top as the flags the host list already computed for its own rows, so the sheet cannot award a capsule the row behind it withheld. A host passes one of the two, never both: Stations sends isCheapestInRange, Favourites sends isTopFavourite. No calculation, ranking, eligibility or savings logic changed.

Rename the card heading Best Value to Value breakdown, since the working behind it can end in Costs rather than a saving, and drop the green section tint so the card matches the neutral background used elsewhere in the sheet. The Saves, Break even and Costs capsules and every breakdown row are untouched.

Rename the explanation link to How does FuelBoard calculate value? It names the calculation rather than the feature, of which the Best Value gate is only the last step. The sheet's own title stays short: the full question truncated to 'How does FuelBoard...' as a navigation title at both inline and large display modes, so the row carries the question and the sheet names the topic.

Tests: 269 pass, 5 new covering capsule selection, order and sheet-vs-row agreement on Best Value.
2026-10-19 13:20:59 +01:00

158 lines
8.1 KiB
Swift

import SwiftUI
/// The "How does FuelBoard calculate value?" explanation as its own sheet.
///
/// It used to sit inline in the Key sheet as a `Section`; it is one shared view
/// now so the Key sheet and the More info sheet can both open it, and the copy is
/// unchanged (the same `Localizable.strings` keys are reused — the old section
/// header string is the sheet's title).
struct HowBestValueSheet: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
Text("Your fill-up amount and MPG are used to show whether you could save overall.")
// The whole point of the sheet is to explain the feature,
// so the way to switch it on lives here rather than only
// in Settings. Full-width: this is the card's one action.
Button {
openBestValueSettings()
} label: {
Text("Setup Best Value")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
}
// `.large` is the standard iOS button height (~50pt): the
// default `.borderedProminent` is considerably shorter and
// read as a compact control rather than a button.
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.top, 2)
}
.font(.subheadline)
.padding(.vertical, 4)
}
Section("The working, step by step") {
VStack(alignment: .leading, spacing: 12) {
step("1. The baseline",
"The closest station by journey distance. It is the trip you would make anyway, so everything else is measured against it.")
step("2. The return trip",
"For each station we work out the journey there and back. Where the roads have been measured we use the real driving route; until then we double the straight-line distance and mark the figure “Approx.”.")
step("3. Extra return travel",
"This station's return trip minus the baseline's. It is the extra you add on top of the journey you were already making — not the whole trip. Because both figures are return trips, it can read larger than the one-way distance shown above the map.")
step("4. Extra driving cost",
"What that extra distance costs in fuel: the extra miles, divided by your MPG, priced at the station you are driving to.")
step("5. Fuel-price saving",
"What the difference in price per litre is worth on your fill-up.")
step("6. Total",
"The fuel-price saving minus the extra driving cost. If the drive costs more than the price saves, the total is shown as an extra cost instead, and the station is not Best Value.")
step("7. Best Value",
"The total clears the minimum saving you set in Settings → Fuel Savings.")
}
.font(.subheadline)
.padding(.vertical, 4)
}
Section {
VStack(alignment: .leading, spacing: 10) {
Text("With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.")
Text("With Litres, every station is compared for the fixed quantity you selected.")
Text("Results are marked “Approx.” until road distances are available.")
.foregroundStyle(.secondary)
}
.font(.subheadline)
.padding(.vertical, 4)
}
}
.navigationTitle(KeySheetContent.helpSheetTitle)
// `.inline`, the mode this sheet has always used. The sheet's title is a
// short noun phrase and the ROW that opens it carries the full question
// — a nav title is clamped to the bar and even `.large` truncated the
// full question to "How does FuelBoard…" rather than wrapping it.
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.presentationDetents([.fraction(0.72)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
#if DEBUG
// QA hook: `-qaSetupBestValue` runs the Setup Best Value action without a
// tap (simctl cannot tap), so the tab switch can be screenshotted.
.task {
guard ProcessInfo.processInfo.arguments.contains("-qaSetupBestValue") else { return }
try? await Task.sleep(nanoseconds: 2_000_000_000)
openBestValueSettings()
}
#endif
}
/// Closes this sheet and asks the app to land on Settings → Best Value, whose
/// section is the first thing in its list and holds the fill-up amount, MPG
/// and minimum saving. This sheet sits two levels below the tab view — More
/// info sheet, or the Key sheet — so it cannot switch tabs itself: it closes
/// itself and posts, and ContentView does the rest.
private func openBestValueSettings() {
dismiss()
// Announce only once this sheet has animated away. The host then closes
// the sheet this one was presented from — the More info sheet, or the Key
// sheet — and switches to Settings. Asking for that while the child was
// still dismissing could drop one of the two dismissals, which is what
// left the Settings tab covered by a sheet.
Task { @MainActor in
try? await Task.sleep(nanoseconds: 500_000_000)
NotificationCenter.default.post(name: .fuelBoardOpenBestValueSettings, object: nil)
}
}
/// One numbered step: a lead-in, then the plain explanation. Same words
/// either way — this only sets the hierarchy.
private func step(_ title: String, _ explanation: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(explanation)
.foregroundStyle(.secondary)
}
}
}
/// The row that opens `HowBestValueSheet`. A tappable row with a chevron, like
/// the other rows in both sheets. `.buttonStyle(.plain)` keeps it from becoming a
/// row-wide tap target that could swallow a neighbouring control (see the
/// two-buttons-in-one-row rule).
struct HowBestValueRow: View {
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Text(KeySheetContent.helpTitle)
Spacer(minLength: 8)
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
}
}
#if DEBUG
/// QA hook support: `-showBestValueInfo` opens this sheet without a tap, so it can
/// be screenshotted (simctl has no tap command) — the same pattern as
/// `-showKeySheet` / `-showDetail`.
enum BestValueInfoQA {
static var isRequested: Bool {
ProcessInfo.processInfo.arguments.contains("-showBestValueInfo")
}
}
#endif