Compare commits

...
Author SHA1 Message Date
FuelBoard Contributor fcb11a8f52 Bump the build number to 30
29 was used for two different binaries during the listing refinement, so
the next install would have been indistinguishable from the last one.
Raised in all eight configurations — app, widget, watch app and watch
extension, Debug and Release — so the whole bundle reports one number.
2026-10-18 20:18:00 +01:00
FuelBoard Contributor ab93f6286a Add the Key button to the Favourites tab
Favourites gains the same info button the Stations list has, last in
the trailing toolbar group so it sits to the right of Edit. It stays
visible when the list is empty, because the legend explains the prices
and labels before anything is starred — Trends and Edit still hide
with no favourites.

The legend itself is now one view, KeySheetView, lifted out of
StationsView's private computed property so both tabs present the
same sheet and cannot drift apart. It lives in StationsView.swift
because the key's row types are file-private there, which keeps the
Xcode project untouched. Stations passes its selected fuel's tint and
Favourites its active tab's, so the station-label pump badge matches
the rows on screen; everything else — detents, the two nested sheets,
and the -showKeySheet / -showBestValueInfo / -showImportantInfo QA
launch hooks — behaves exactly as before.
2026-10-18 20:16:30 +01:00
FuelBoard Contributor 6edab35400 Give the closest capsule the rounded rect whenever it wraps
The capsule's shape was keyed to the verdict's declared line count, so
"Closest station" — one declared line — always drew as a Capsule while
its text wrapped to two lines in the row's narrow column, leaving the
semicircular ends crowding the words they wrapped. Saves never showed
this because its amount line makes the declared count two.

The shape now follows the DRAWN height: a GeometryReader behind the
text block reports what it actually rendered, divided by the caption's
line height taken through ScaledMetric so the test holds at every
Dynamic Type size. One drawn line keeps the capsule; two or more get
the same 8pt rounded rect the other verdicts use. The first layout
pass, before there is a height, falls back to the declared count.
2026-10-18 20:16:30 +01:00
FuelBoard Contributor 244f9be640 Refine the station listing: muted verdict capsules, three-line names
The listing's verdict capsule now carries white text on muted semantic
fills instead of the neutral secondary tint: Saves #45825E, Costs
#A9635B, Break even #707070, Closest #58789D.

Fixed tones, not translucent tints, because white text on a 15% tint
over a white row measures ~1.8:1. Each tone sits at a relative
luminance of ~0.18 — the band where white clears 4.5:1 AND the pill
still separates ~3:1 from both a white row and the dark card fill — so
one value each works in light and dark without a dynamic colour. The
saturated palette colours cannot carry white text at all: systemGreen
measures 2.2:1, systemRed 3.6:1.

"Save" becomes "Saves", matching the detail sheet and reading as a
statement of the outcome; amounts, symbols, padding, radius and the
shape-follows-line-count rule are unchanged. Saves and Costs share an
84pt minimum width so their trailing edges line up down the list.

The price and its capsule are now one vertically grouped element (5pt),
with the badge keeping the column's full 12pt above, so the row no
longer reads as three equal lines.

Station names cap at three lines with a tail ellipsis: unbounded wrap
let one very long forecourt name set the height of the whole row. 59 of
the 8,073 names in the dump exceed three lines and now ellipsise; the
full name is always in Station Details. Addresses still wrap freely.

The baseline pill moves onto ListingFuelSavingsCapsule so the listing
shows the same soft blue and white text as its siblings, leaving
FuelSavingsCapsule to the detail sheet, which it now alone uses.
Badges keep their tinted styling: they annotate a price rather than
report an outcome.

Verified: 261/261 swift test; Debug simulator and Release device
builds; all four fills and their white text measured from simulator
pixels in both appearances, and the detail sheet's .blue absent from
the listing entirely.
2026-10-18 19:45:19 +01:00
8 changed files with 277 additions and 126 deletions
+8 -8
View File
@@ -533,7 +533,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -599,7 +599,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -663,7 +663,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -728,7 +728,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -826,7 +826,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -851,7 +851,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -875,7 +875,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
@@ -900,7 +900,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
+46 -37
View File
@@ -1941,14 +1941,18 @@ struct StationRow: View {
// Name, full address, distance // Name, full address, distance
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
// The name WRAPS instead of truncating: the row is tall // The name wraps, capped at THREE lines. The row is tall
// enough now, and a name cut to "Z A Service Stat…" cannot // enough for a two-line name, but an unbounded wrap let one
// be told apart from the station next door. Baseline // very long forecourt name set the height of the whole row —
// alignment keeps the notice symbols on the name's FIRST // the excessive row heights this refinement removes. Past
// line instead of floating beside a two-line name. // three lines the name ellipsises; the full name is always
// in Station Details. Baseline alignment keeps the notice
// symbols on the name's FIRST line instead of floating
// beside a two-line name.
HStack(alignment: .firstTextBaseline, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(station.name) Text(station.name)
.font(.headline) .font(.headline)
.lineLimit(3)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
ForEach(StationNotice.notices(for: station)) { notice in ForEach(StationNotice.notices(for: station)) { notice in
@@ -2019,27 +2023,33 @@ struct StationRow: View {
if let badge = recommendationBadge { if let badge = recommendationBadge {
badgeCapsule(badge) badgeCapsule(badge)
} }
// Price line: the status dot and the price, leading-aligned on // The price and its verdict capsule are ONE vertically grouped
// the column's one axis. The star no longer ends this line — it // element: 5pt binds them as "this price, this outcome", while
// sits under the station logo — so this whole column width now // the badge keeps the column's full 12pt above so the three
// belongs to the price, which is what lets the wide pounds & // lines do not read as equals.
// pence form (£1.58⁷/L) render at full size rather than being VStack(alignment: .leading, spacing: 5) {
// scaled down or truncated. // Price line: the status dot and the price, leading-aligned
if let price = station.prices[fuel] { // on the column's one axis. The star no longer ends this
HStack(spacing: 5) { // line — it sits under the station logo — so this whole
PriceRatingDot(color: ragColor) // column width now belongs to the price, which is what lets
FuelStore.priceTextAttributed(price, style: priceDisplayStyle, // the wide pounds & pence form (£1.58⁷/L) render at full
size: priceSize, weight: .bold) // size rather than being scaled down or truncated.
.lineLimit(1) if let price = station.prices[fuel] {
.minimumScaleFactor(0.8) HStack(spacing: 5) {
PriceRatingDot(color: ragColor)
FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
size: priceSize, weight: .bold)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
// The visible "+1.8p vs cheapest" line is gone with the
// redesign, so the comparison rides on the price's label.
.accessibilityElement(children: .ignore)
.accessibilityLabel(priceSpokenLabel(price))
}
if let result = fuelSavings?.result {
savingsCapsule(result)
} }
// The visible "+1.8p vs cheapest" line is gone with the
// redesign, so the comparison rides on the price's label.
.accessibilityElement(children: .ignore)
.accessibilityLabel(priceSpokenLabel(price))
}
if let result = fuelSavings?.result {
savingsCapsule(result)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -2065,21 +2075,20 @@ struct StationRow: View {
.buttonStyle(.borderless) .buttonStyle(.borderless)
} }
/// Listing-only verdict. It deliberately omits money and the repeated /// The listing's verdict capsule — ONE component for every status, so the
/// comparison caption; the full monetary result remains in More info. /// baseline's "Closest station" pill carries the same soft blue fill, white
@ViewBuilder /// text and shape rules as Saves, Costs and Break even. The detail sheet
/// keeps `FuelSavingsCapsule` to itself, unchanged.
private func savingsCapsule(_ result: FuelSavingsResult) -> some View { private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
if result.status == .baseline { ListingFuelSavingsCapsule(result: result)
FuelSavingsCapsule(result: result)
} else {
ListingFuelSavingsCapsule(result: result)
}
} }
/// One recommendation badge, above the price it annotates. Tinted rather /// One recommendation badge, above the price it annotates. Badges stay
/// than the mockup's solid fill, like every other badge and capsule in the /// TINTED with coloured text — unlike the verdict capsules, which are filled
/// app: coloured text keeps its contrast in both appearances, at every /// with white text: a badge annotates the price rather than reporting an
/// Dynamic Type size, without introducing a colour outside the palette. /// outcome, so it should stay quieter than the pill beneath it. Tinted text
/// also keeps its contrast in both appearances at every Dynamic Type size
/// without introducing a colour outside the palette.
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View { private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
StationLabelBadge(label: badge.title, color: badge.tint) StationLabelBadge(label: badge.title, color: badge.tint)
} }
+27 -2
View File
@@ -48,6 +48,14 @@ struct FavouritesView: View {
/// Trends sheet (price history chart) presentation state. /// Trends sheet (price history chart) presentation state.
@State private var showTrends = false @State private var showTrends = false
/// The Key (legend) sheet — the same sheet the Stations tab presents, opened
/// from the info button beside Edit.
@State private var showKey = false
/// The Key sheet's two nested sheets, owned here because its rows set them
/// (same arrangement as StationsView, so the QA hooks behave identically).
@State private var showBestValueInfo = false
@State private var showImportantInformation = false
/// Station picked for the More-info sheet (tap action = showDetails). /// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation? @State private var detailStation: FuelStation?
@@ -206,8 +214,8 @@ struct FavouritesView: View {
} }
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title")) .navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
.toolbar { .toolbar {
if !favourites.isEmpty { ToolbarItemGroup(placement: .topBarTrailing) {
ToolbarItemGroup(placement: .topBarTrailing) { if !favourites.isEmpty {
Button { Button {
showTrends = true showTrends = true
} label: { } label: {
@@ -216,8 +224,25 @@ struct FavouritesView: View {
} }
EditButton() EditButton()
} }
// Same button and same slot as the Stations tab: last in the
// group, so it sits to the right of Edit. Shown even with no
// favourites — the legend explains the prices and labels, which
// matter before anything is starred.
Button {
showKey = true
} label: {
Image(systemName: "info.circle")
}
.accessibilityLabel(String(localized: "Key", comment: "Key button accessibility label"))
} }
} }
.sheet(isPresented: $showKey) {
KeySheetView(
fuelTint: activeFuel.tintColor,
showBestValueInfo: $showBestValueInfo,
showImportantInformation: $showImportantInformation
)
}
.sheet(isPresented: $showTrends) { .sheet(isPresented: $showTrends) {
TrendsView( TrendsView(
favourites: favourites, favourites: favourites,
+90 -5
View File
@@ -160,6 +160,12 @@ struct ListingFuelSavingsCapsule: View {
/// Station rows carry the figure; the Key sheet's legend shows the wording only. /// Station rows carry the figure; the Key sheet's legend shows the wording only.
var showsAmount: Bool = true var showsAmount: Bool = true
/// The height the text actually drew, so the shape can follow the drawn line
/// count rather than the data's. See `drawnLineCount`.
@State private var textHeight: CGFloat = 0
/// One caption line's height, scaled with Dynamic Type, to divide that by.
@ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14
private var verdict: FuelSavingsResult.ListingVerdict { private var verdict: FuelSavingsResult.ListingVerdict {
result.listingVerdict(showsAmount: showsAmount) result.listingVerdict(showsAmount: showsAmount)
} }
@@ -185,22 +191,61 @@ struct ListingFuelSavingsCapsule: View {
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
// Never compress or truncate: the capsule wraps and the row grows taller. // Never compress or truncate: the capsule wraps and the row grows taller.
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.background(
GeometryReader { proxy in
Color.clear
.preference(key: CapsuleTextHeightKey.self,
value: proxy.size.height)
}
)
} }
// White on the muted fill. These capsules carry a figure the eye is asked
// to read at a glance, which is why they are the only filled-and-white
// surfaces in the app; every badge stays tinted with coloured text.
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(.primary) .foregroundStyle(.white)
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(shape.fill(Color.secondary.opacity(0.14))) // Saves and Costs share one minimum width, so their trailing edges line
// up down the list whatever the figure's length. Break even and Closest
// station carry no figure and keep their natural width.
.frame(minWidth: minimumWidth, alignment: .leading)
.background(shape.fill(fill))
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityLabel(verdict.spokenText) .accessibilityLabel(verdict.spokenText)
.onPreferenceChange(CapsuleTextHeightKey.self) { textHeight = $0 }
}
/// Saves and Costs only — the two verdicts that carry a figure. See the
/// frame above: 84pt is the width the widest common amount needs
/// ("£123.45 more"), so shorter figures pad rather than shrink the pill.
private var minimumWidth: CGFloat {
switch result.status {
case .worthTheDrive, .notWorthTheDrive: return 84
case .breakEven, .baseline: return 0
}
}
private var fill: Color {
ListingCapsuleFill.color(for: result.status)
}
/// How many lines the text DREW, not how many the verdict declares.
/// "Closest station" is one declared line that wraps to two in the row's
/// narrow column, and the shape has to follow what is on screen or the pill's
/// semicircular ends crowd the text they are wrapping. Falls back to the
/// declared count on the first layout pass, before there is a height.
private var drawnLineCount: Int {
guard textHeight > 0, captionLineHeight > 0 else { return verdict.lines.count }
return max(1, Int((textHeight / captionLineHeight).rounded()))
} }
/// One line keeps the capsule. Two or more switch to a rounded rect: a pill's /// One line keeps the capsule. Two or more switch to a rounded rect: a pill's
/// semicircular ends crowd the text corners at that height, while an 8pt /// semicircular ends crowd the text corners at that height, while an 8pt
/// radius does not. Keyed to the LINE COUNT, so Break even flips shape only /// radius does not. Keyed to the DRAWN line count, so a wrapped "Closest
/// when "(approx.)" pushes it onto a second line. /// station" gets the same rounded rect as a figure pushed onto its own line.
private var shape: AnyShape { private var shape: AnyShape {
verdict.lines.count > 1 drawnLineCount > 1
? AnyShape(RoundedRectangle(cornerRadius: 8)) ? AnyShape(RoundedRectangle(cornerRadius: 8))
: AnyShape(Capsule()) : AnyShape(Capsule())
} }
@@ -214,3 +259,43 @@ struct ListingFuelSavingsCapsule: View {
} }
} }
} }
/// Carries the drawn height of a capsule's text block up to `drawnLineCount`.
private struct CapsuleTextHeightKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
/// The station listing's verdict-capsule fills: muted semantic colours carrying
/// white text and icon.
///
/// Muted AND fixed, deliberately not the translucent tints used elsewhere in the
/// app: white text on a 15% tint over a white row measures ~1.8:1 and is
/// unreadable. Each tone below sits at a relative luminance of ~0.18 — the band
/// where white text clears 4.5:1 AND the pill still separates ~3:1 from BOTH a
/// white row and the dark card fill — so one value each works in both
/// appearances without a dynamic colour.
///
/// The saturated palette colours cannot carry white text at all: measured
/// white-on-fill is 2.2:1 for `systemGreen`, 3.6:1 for `systemRed` and 4.0:1 for
/// `systemBlue`, every one of them below the 4.5:1 a caption needs. Muting is
/// therefore a contrast requirement here, not only the mockup's taste.
enum ListingCapsuleFill {
// Trailing comments record, per colour: white-on-fill / fill-vs-white-row /
// fill-vs-dark-row, measured rather than estimated.
static let saves = Color(red: 0x45 / 255, green: 0x82 / 255, blue: 0x5E / 255) // 4.56 / 4.56 / 3.06
static let costs = Color(red: 0xA9 / 255, green: 0x63 / 255, blue: 0x5B / 255) // 4.54 / 4.54 / 3.07
static let breakEven = Color(red: 0x70 / 255, green: 0x70 / 255, blue: 0x70 / 255) // 4.95 / 4.95 / 2.81
static let closest = Color(red: 0x58 / 255, green: 0x78 / 255, blue: 0x9D / 255) // 4.52 / 4.52 / 3.09
static func color(for status: FuelSavingsStatus) -> Color {
switch status {
case .worthTheDrive: return saves
case .notWorthTheDrive: return costs
case .breakEven: return breakEven
case .baseline: return closest
}
}
}
+100 -67
View File
@@ -1,6 +1,94 @@
import SwiftUI import SwiftUI
import WidgetKit 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 {
/// The selected fuel's colour. `KeyStationLabelIndicator` draws its pump badge
/// in it, so the legend matches the rows the reader is looking at.
let fuelTint: Color
@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(KeySheetContent.stationLabels, id: \.label) { item in
KeySheetDescriptionRow(item: item) {
KeyStationLabelIndicator(label: item.label, fuelColor: fuelTint)
}
}
} 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))
}
}
/// Stations tab — the main list with fuel picker, distance filter, sort, pagination. /// Stations tab — the main list with fuel picker, distance filter, sort, pagination.
struct StationsView: View { struct StationsView: View {
let stations: [FuelStation] let stations: [FuelStation]
@@ -296,73 +384,15 @@ struct StationsView: View {
) )
} }
/// The legend sheet, shared with the Favourites tab. Only the fuel differs:
/// its colour tints the station-label row, so the legend matches the rows the
/// reader is actually looking at.
private var keySheet: some View { private var keySheet: some View {
NavigationStack { KeySheetView(
List { fuelTint: selectedFuel.tintColor,
Section { showBestValueInfo: $showBestValueInfo,
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[0]) { showImportantInformation: $showImportantInformation
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(KeySheetContent.stationLabels, id: \.label) { item in
KeySheetDescriptionRow(item: item) {
KeyStationLabelIndicator(label: item.label, fuelColor: selectedFuel.tintColor)
}
}
} 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") { showKey = false }
}
}
// 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))
} }
private func stationRow(_ station: FuelStation) -> some View { private func stationRow(_ station: FuelStation) -> some View {
@@ -521,7 +551,10 @@ private struct KeyStationLabelIndicator: View {
case "CHEAPEST": case "CHEAPEST":
StationLabelBadge(label: label, color: .blue) StationLabelBadge(label: label, color: .blue)
case "CLOSEST": case "CLOSEST":
FuelSavingsCapsule(result: baselineResult) // The listing's own capsule, so the Key demonstrates the same soft
// blue fill and white text the listing shows. `FuelSavingsCapsule`
// remains the detail sheet's alone.
ListingFuelSavingsCapsule(result: baselineResult)
default: default:
Text("TOP") Text("TOP")
.font(.caption2.bold()) .font(.caption2.bold())
-1
View File
@@ -40,7 +40,6 @@
"Costs more" = "Costs more"; "Costs more" = "Costs more";
"Filling up here costs more than at your closest station, including travel." = "Filling up here costs more than at your closest station, including travel."; "Filling up here costs more than at your closest station, including travel." = "Filling up here costs more than at your closest station, including travel.";
/* Station listing verdict capsule: the words above the figure */ /* Station listing verdict capsule: the words above the figure */
"Save" = "Save";
"Costs" = "Costs"; "Costs" = "Costs";
"(approx.)" = "(approx.)"; "(approx.)" = "(approx.)";
"%@ more" = "%@ more"; "%@ more" = "%@ more";
@@ -34,9 +34,9 @@ final class FuelSavingsEstimateTests: XCTestCase {
// The figure takes its own line, so the capsule's shape is decided by the // The figure takes its own line, so the capsule's shape is decided by the
// data rather than by how much width the price column hands it. // data rather than by how much width the price column hands it.
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingVerdict().lines, XCTAssertEqual(result(.worthTheDrive, .confirmed).listingVerdict().lines,
["Save", "£12.50"]) ["Saves", "£12.50"])
XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines, XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines,
["Save", "£12.50", "(approx.)"]) ["Saves", "£12.50", "(approx.)"])
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingVerdict().lines, XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingVerdict().lines,
["Costs", "£123.45 more"]) ["Costs", "£123.45 more"])
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingVerdict().lines, XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingVerdict().lines,
@@ -57,7 +57,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
// VoiceOver keeps a single line: a drawn break is a spoken pause. // VoiceOver keeps a single line: a drawn break is a spoken pause.
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(), XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(),
"Save £12.50 (approx.)") "Saves £12.50 (approx.)")
} }
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() { func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
+2 -2
View File
@@ -271,7 +271,7 @@ extension FuelSavingsResult {
/// never straddle two lines, and a taller verdict grows the capsule downward /// never straddle two lines, and a taller verdict grows the capsule downward
/// instead of widening the row's fixed price column. /// instead of widening the row's fixed price column.
struct ListingVerdict: Equatable { struct ListingVerdict: Equatable {
/// "Save", "Costs", "Break even" or "Closest station". /// "Saves", "Costs", "Break even" or "Closest station".
let headline: String let headline: String
/// "£3.10" / "£1.20 more" — nil when there is no figure to show. /// "£3.10" / "£1.20 more" — nil when there is no figure to show.
let amount: String? let amount: String?
@@ -293,7 +293,7 @@ extension FuelSavingsResult {
switch status { switch status {
case .worthTheDrive: case .worthTheDrive:
return ListingVerdict( return ListingVerdict(
headline: String(localized: "Save"), headline: String(localized: "Saves"),
amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil, amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil,
qualifier: qualifier qualifier: qualifier
) )