Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcb11a8f52 | ||
|
|
ab93f6286a | ||
|
|
6edab35400 | ||
|
|
244f9be640 | ||
|
|
c46a7748e6 |
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -147,28 +147,107 @@ struct FuelSavingsCapsule: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Amount-free, neutral verdict used by station listing rows only. Keeping this
|
/// The station listing's verdict capsule: the words on the first line, the figure
|
||||||
/// separate from `FuelSavingsCapsule` preserves the detail sheet's monetary,
|
/// on its own line beneath, and "(approx.)" on a third when the journey is an
|
||||||
/// colour-coded verdict and comparison presentation.
|
/// estimate.
|
||||||
|
///
|
||||||
|
/// Stations and Favourites share `StationRow`, so both render this. The detail
|
||||||
|
/// sheet keeps `FuelSavingsCapsule`'s colour-coded monetary verdict on one line;
|
||||||
|
/// the Key sheet's legend renders this with `showsAmount: false` so it never
|
||||||
|
/// displays an invented figure.
|
||||||
struct ListingFuelSavingsCapsule: View {
|
struct ListingFuelSavingsCapsule: View {
|
||||||
let result: FuelSavingsResult
|
let result: FuelSavingsResult
|
||||||
|
/// Station rows carry the figure; the Key sheet's legend shows the wording only.
|
||||||
|
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 {
|
||||||
|
result.listingVerdict(showsAmount: showsAmount)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
Text(result.listingCapsuleHeadline())
|
// One Text per line, not a "\n" inside one string: each line keeps its
|
||||||
.lineLimit(nil)
|
// own localisation key and the amount can be monospaced on its own.
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
// Left-aligned: the word and its figure share a left edge, so the
|
||||||
.multilineTextAlignment(.leading)
|
// block reads as a column rather than a ragged centre.
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(verdict.headline)
|
||||||
|
if let amount = verdict.amount {
|
||||||
|
Text(amount)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
if let qualifier = verdict.qualifier {
|
||||||
|
Text(qualifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
// Never compress or truncate: the capsule wraps and the row grows taller.
|
||||||
|
.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(Capsule().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(result.listingCapsuleHeadline())
|
.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
|
||||||
|
/// semicircular ends crowd the text corners at that height, while an 8pt
|
||||||
|
/// radius does not. Keyed to the DRAWN line count, so a wrapped "Closest
|
||||||
|
/// station" gets the same rounded rect as a figure pushed onto its own line.
|
||||||
|
private var shape: AnyShape {
|
||||||
|
drawnLineCount > 1
|
||||||
|
? AnyShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
: AnyShape(Capsule())
|
||||||
}
|
}
|
||||||
|
|
||||||
private var symbol: String {
|
private var symbol: String {
|
||||||
@@ -180,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+103
-68
@@ -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 {
|
||||||
@@ -465,7 +495,9 @@ private struct KeySavingsCapsule: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ListingFuelSavingsCapsule(result: result)
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,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())
|
||||||
|
|||||||
@@ -39,6 +39,10 @@
|
|||||||
"The saving or extra cost is negligible." = "The saving or extra cost is negligible.";
|
"The saving or extra cost is negligible." = "The saving or extra cost is negligible.";
|
||||||
"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 */
|
||||||
|
"Costs" = "Costs";
|
||||||
|
"(approx.)" = "(approx.)";
|
||||||
|
"%@ more" = "%@ more";
|
||||||
"Station labels" = "Station labels";
|
"Station labels" = "Station labels";
|
||||||
"CLOSEST" = "CLOSEST";
|
"CLOSEST" = "CLOSEST";
|
||||||
"Meets your minimum saving after travel costs." = "Meets your minimum saving after travel costs.";
|
"Meets your minimum saving after travel costs." = "Meets your minimum saving after travel costs.";
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
|
||||||
func testListingCapsuleUsesSixShortFinancialVerdictsWithoutAmounts() {
|
func testListingCapsulePutsTheFigureOnItsOwnLine() {
|
||||||
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
|
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
|
||||||
FuelSavingsResult(
|
FuelSavingsResult(
|
||||||
fillLitres: 30,
|
fillLitres: 30,
|
||||||
@@ -31,12 +31,33 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingCapsuleHeadline(), "Saves")
|
// The figure takes its own line, so the capsule's shape is decided by the
|
||||||
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(), "Saves (approx.)")
|
// data rather than by how much width the price column hands it.
|
||||||
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingCapsuleHeadline(), "Costs more")
|
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingVerdict().lines,
|
||||||
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingCapsuleHeadline(), "Costs more (approx.)")
|
["Saves", "£12.50"])
|
||||||
XCTAssertEqual(result(.breakEven, .confirmed).listingCapsuleHeadline(), "Break even")
|
XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines,
|
||||||
XCTAssertEqual(result(.breakEven, .estimated).listingCapsuleHeadline(), "Break even (approx.)")
|
["Saves", "£12.50", "(approx.)"])
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingVerdict().lines,
|
||||||
|
["Costs", "£123.45 more"])
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingVerdict().lines,
|
||||||
|
["Costs", "£123.45 more", "(approx.)"])
|
||||||
|
|
||||||
|
// Break even carries no figure — ±£0.05 is noise — so it stays one line
|
||||||
|
// when confirmed and gains a second only for the estimate qualifier.
|
||||||
|
XCTAssertEqual(result(.breakEven, .confirmed).listingVerdict().lines, ["Break even"])
|
||||||
|
XCTAssertEqual(result(.breakEven, .estimated).listingVerdict().lines,
|
||||||
|
["Break even", "(approx.)"])
|
||||||
|
XCTAssertEqual(result(.baseline, .confirmed).listingVerdict().lines, ["Closest station"])
|
||||||
|
|
||||||
|
// The Key sheet's legend demonstrates the wording, never a figure.
|
||||||
|
XCTAssertEqual(
|
||||||
|
result(.notWorthTheDrive, .confirmed).listingVerdict(showsAmount: false).lines,
|
||||||
|
["Costs"]
|
||||||
|
)
|
||||||
|
|
||||||
|
// VoiceOver keeps a single line: a drawn break is a spoken pause.
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(),
|
||||||
|
"Saves £12.50 (approx.)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
|
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
|
||||||
|
|||||||
@@ -263,18 +263,73 @@ extension FuelSavingsResult {
|
|||||||
headline(compact: true)
|
headline(compact: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Short, amount-free verdict used only by station listing rows. The detail
|
/// The station-listing capsule's verdict, split into the lines the capsule
|
||||||
/// sheet continues to use `capsuleHeadline()` so its full monetary verdict is
|
/// draws.
|
||||||
/// unchanged. Confidence follows the existing journey provenance exactly.
|
///
|
||||||
func listingCapsuleHeadline() -> String {
|
/// The amount always takes its OWN line, so where the capsule breaks is
|
||||||
let approximate = journeyConfidence.isEstimated ? " (approx.)" : ""
|
/// decided by the data, never by the width it was handed: the figure can
|
||||||
|
/// never straddle two lines, and a taller verdict grows the capsule downward
|
||||||
|
/// instead of widening the row's fixed price column.
|
||||||
|
struct ListingVerdict: Equatable {
|
||||||
|
/// "Saves", "Costs", "Break even" or "Closest station".
|
||||||
|
let headline: String
|
||||||
|
/// "£3.10" / "£1.20 more" — nil when there is no figure to show.
|
||||||
|
let amount: String?
|
||||||
|
/// "(approx.)" when the journey behind the figure was estimated.
|
||||||
|
let qualifier: String?
|
||||||
|
|
||||||
|
var lines: [String] { [headline, amount, qualifier].compactMap { $0 } }
|
||||||
|
|
||||||
|
/// Single-line form, for VoiceOver and tests. A drawn newline is a pause
|
||||||
|
/// in the middle of a phrase when spoken.
|
||||||
|
var spokenText: String { lines.joined(separator: " ") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verdict for the station listing rows — Stations and Favourites share one
|
||||||
|
/// row, so they share this. `showsAmount: false` is the Key sheet's legend,
|
||||||
|
/// which demonstrates the wording without inventing a figure.
|
||||||
|
func listingVerdict(showsAmount: Bool = true) -> ListingVerdict {
|
||||||
|
let qualifier = journeyConfidence.isEstimated ? String(localized: "(approx.)") : nil
|
||||||
switch status {
|
switch status {
|
||||||
case .worthTheDrive: return "Saves\(approximate)"
|
case .worthTheDrive:
|
||||||
case .notWorthTheDrive: return "Costs more\(approximate)"
|
return ListingVerdict(
|
||||||
case .breakEven: return "Break even\(approximate)"
|
headline: String(localized: "Saves"),
|
||||||
case .baseline: return Self.baselineHeadline
|
amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .notWorthTheDrive:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Costs"),
|
||||||
|
amount: showsAmount ? Self.amountWithMore(abs(netSavingGBP)) : nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .breakEven:
|
||||||
|
// No figure: ±£0.05 is noise, so this stays a single line unless the
|
||||||
|
// estimate qualifier adds its own.
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Break even"),
|
||||||
|
amount: nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .baseline:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: Self.baselineHeadline,
|
||||||
|
amount: nil,
|
||||||
|
qualifier: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "£1.20 more" — kept as a format string so the copy editor owns the word.
|
||||||
|
private static func amountWithMore(_ value: Double) -> String {
|
||||||
|
String(format: String(localized: "%@ more"), FuelSavingsFormat.money(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line verdict for voice and tests; the capsule itself renders
|
||||||
|
/// `listingVerdict()`'s lines separately.
|
||||||
|
func listingCapsuleHeadline() -> String {
|
||||||
|
listingVerdict().spokenText
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One station's journey distance, plus how it was obtained.
|
/// One station's journey distance, plus how it was obtained.
|
||||||
|
|||||||
Reference in New Issue
Block a user