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.
This commit is contained in:
FuelBoard Contributor
2026-10-19 13:20:59 +01:00
parent 5d9e71f7ac
commit c69bdb3c5f
10 changed files with 286 additions and 18 deletions
+42 -10
View File
@@ -26,6 +26,17 @@ struct StationDetailView: View {
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsBaselineStationName: String? = nil
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
/// Ranking flags, supplied by the list that opened this sheet — the SAME flags
/// it hands its own rows, so this sheet cannot award a capsule the row behind it
/// did not. `isCheapestInRange` is the Stations tab's cheapest price in range;
/// `isTopFavourite` is the cheapest of your Favourites. A host passes one or the
/// other, never both, so one achievement is never labelled twice.
var isCheapestInRange: Bool = false
var isTopFavourite: Bool = false
/// The user's Best Value alert threshold — the app's existing gate, defaulted
/// exactly as `StationRow` defaults it, so the capsule here and the badge on the
/// list agree to the penny.
var bestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
/// Whether this station is starred for the fuel currently being viewed, and
/// the toggle that flips it. Passed from the list that opened the sheet, so
/// the star here and the star on the row are the same fuel-scoped
@@ -38,8 +49,8 @@ struct StationDetailView: View {
/// the caption fades out on its own.
@State private var showingCopied = false
@State private var copyResetTask: Task<Void, Never>?
/// The "How Best Value works" sheet, opened from the row at the bottom of the
/// Best Value card (the Key sheet opens the same shared view).
/// The "How does FuelBoard calculate value?" sheet, opened from the row at the
/// bottom of the value-breakdown card (the Key sheet opens the same shared view).
@State private var showBestValueInfo = false
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
@@ -66,6 +77,18 @@ struct StationDetailView: View {
FuelPriceFreshness.assessment(for: station)
}
/// Which ranking capsules this station qualifies for. All the rules live in the
/// shared `StationRanking` / `StationRecommendationBadge` pair — nothing is
/// recomputed here, and no threshold is invented.
private var rankingKinds: [StationLabelKind] {
StationRanking.kinds(
netSavingGBP: fuelSavings?.result?.netSavingGBP,
isCheapestInRange: isCheapestInRange,
isTopFavourite: isTopFavourite,
bestValueThresholdGBP: bestValueThresholdGBP
)
}
/// One-way and out-and-back distance from the current location, from the ONE
/// shared display helper — so this sheet's figures carry the same confidence
/// marker as the listing row, the widget and the Live Activity.
@@ -229,10 +252,17 @@ struct StationDetailView: View {
// clutter. The Address/Distance and Distance/actions lines
// stay.
.listRowSeparator(.hidden, edges: .bottom)
// The map, the facts row, any notices and the two station
// actions all share ONE card (the list's own row hairlines
// separate them), so no hand-rolled Divider here — adding one
// would double the line.
// Ranking capsules directly under the map, above the price and
// distance. Nothing renders when the station qualifies for no
// label, so an ordinary station's layout is untouched.
//
// No hairline under this row either: the map already suppressed
// its bottom separator so the header and map read as one block,
// and the facts row below keeps its own line.
if !rankingKinds.isEmpty {
StationRankingCapsules(kinds: rankingKinds)
.listRowSeparator(.hidden, edges: .bottom)
}
//
// The station name AND the address live in the sheet header,
// which is also where a tap copies the postcode — see the
@@ -290,9 +320,12 @@ struct StationDetailView: View {
// is not configured — so the heading cannot be left dangling
// over an empty section.
if let fuelSavings {
// The card's own header row says "Best Value", so a section
// header would repeat it; the green tint is what marks this
// block as the savings card.
// The card carries its own "Value breakdown" heading, so a
// section header would repeat it. Neutral card background: the
// green tint marked the block as the savings card, but it also
// tinted a card whose verdict can be "Costs", and it made this
// one card look like a different material from the station card
// above it.
Section {
BestValueDetailCard(
availability: fuelSavings,
@@ -301,7 +334,6 @@ struct StationDetailView: View {
distanceUnit: distanceUnit
)
}
.listRowBackground(Color.green.opacity(0.08))
.id("bestValueCard")
Section {