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
+98
View File
@@ -153,3 +153,101 @@ extension Color {
)
}
}
/// Lays its children left to right and wraps to a new line when the next one
/// would overflow, so two or three capsules never squeeze or truncate each other
/// on a narrow screen or at large Dynamic Type.
///
/// A flow layout rather than `ViewThatFits`: a fallback would stack the capsules
/// vertically as soon as one did not fit, which reads as three separate labels
/// rather than one row of rankings. This keeps them on a line while they fit and
/// only moves the overflowing one down.
///
/// Children are measured with `.unspecified` width so a long label takes the width
/// it needs (the badge sets `fixedSize` horizontally) instead of being clipped to
/// the container.
struct CapsuleFlowLayout: Layout {
var spacing: CGFloat = 6
var lineSpacing: CGFloat = 6
private struct Row {
var range: Range<Int>
var width: CGFloat
var height: CGFloat
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
let rows = rows(maxWidth: maxWidth, subviews: subviews)
let width = rows.map(\.width).max() ?? 0
let height = rows.reduce(0) { $0 + $1.height }
+ CGFloat(max(0, rows.count - 1)) * lineSpacing
return CGSize(width: min(width, maxWidth), height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var y = bounds.minY
for row in rows(maxWidth: bounds.width, subviews: subviews) {
var x = bounds.minX
for index in row.range {
let size = subviews[index].sizeThatFits(.unspecified)
subviews[index].place(
at: CGPoint(x: x, y: y),
anchor: .topLeading,
proposal: ProposedViewSize(size)
)
x += size.width + spacing
}
y += row.height + lineSpacing
}
}
private func rows(maxWidth: CGFloat, subviews: Subviews) -> [Row] {
var rows: [Row] = []
var start = 0
var used: CGFloat = 0
var height: CGFloat = 0
for index in subviews.indices {
let size = subviews[index].sizeThatFits(.unspecified)
let needed = used == 0 ? size.width : used + spacing + size.width
// `used > 0` guards the first child: a single capsule wider than the
// container must still be placed (and may wrap its own text) rather
// than starting an empty line above itself.
if used > 0, needed > maxWidth {
rows.append(Row(range: start..<index, width: used, height: height))
start = index
used = size.width
height = size.height
} else {
used = needed
height = max(height, size.height)
}
}
if start < subviews.count {
rows.append(Row(range: start..<subviews.count, width: used, height: height))
}
return rows
}
}
/// The More info sheet's row of ranking capsules: every label the station
/// qualifies for, wrapped when the screen is narrow.
///
/// Renders as the SAME `StationLabelBadge` the listing rows use — same tone, same
/// symbol, same sentence-case copy — so a station carrying "Best value" here and
/// on the Stations list cannot look like two different things.
struct StationRankingCapsules: View {
let kinds: [StationLabelKind]
var body: some View {
CapsuleFlowLayout(spacing: 6, lineSpacing: 6) {
ForEach(kinds, id: \.self) { kind in
StationLabelBadge(kind: kind)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// One phrase per capsule, read in the order they are shown.
.accessibilityElement(children: .contain)
}
}