Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29e299d558 | ||
|
|
fcd1fb8572 | ||
|
|
8153a36168 | ||
|
|
c69bdb3c5f | ||
|
|
5d9e71f7ac | ||
|
|
fcb11a8f52 | ||
|
|
ab93f6286a | ||
|
|
6edab35400 | ||
|
|
244f9be640 |
@@ -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 = 31;
|
||||||
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 = 31;
|
||||||
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 = 31;
|
||||||
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 = 31;
|
||||||
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 = 31;
|
||||||
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 = 31;
|
||||||
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 = 31;
|
||||||
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 = 31;
|
||||||
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";
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// One semantic capsule tone: a foreground and a background for each appearance.
|
||||||
|
///
|
||||||
|
/// The palette is deliberately a TINTED pair rather than a saturated solid. Every
|
||||||
|
/// tone below clears 4.5:1 for its own foreground on its own background in BOTH
|
||||||
|
/// appearances (measured, 4.96:1 to 6.72:1), which is what lets one component
|
||||||
|
/// carry the same meaning on a white row and a dark card without a second
|
||||||
|
/// colour per status.
|
||||||
|
///
|
||||||
|
/// The pairs are resolved through `UIColor`'s dynamic provider rather than
|
||||||
|
/// SwiftUI's `@Environment(\.colorScheme)`: a capsule inside a `List` is often
|
||||||
|
/// re-rendered without its body being rebuilt, so a scheme read at body time can
|
||||||
|
/// lag one switch behind. This way the colour itself follows the appearance.
|
||||||
|
struct CapsuleTone: Equatable {
|
||||||
|
let lightForeground: Color
|
||||||
|
let lightBackground: Color
|
||||||
|
let darkForeground: Color
|
||||||
|
let darkBackground: Color
|
||||||
|
|
||||||
|
var foreground: Color { dynamic(light: lightForeground, dark: darkForeground) }
|
||||||
|
var background: Color { dynamic(light: lightBackground, dark: darkBackground) }
|
||||||
|
|
||||||
|
private func dynamic(light: Color, dark: Color) -> Color {
|
||||||
|
let lightUI = UIColor(light)
|
||||||
|
let darkUI = UIColor(dark)
|
||||||
|
return Color(UIColor { traits in
|
||||||
|
traits.userInterfaceStyle == .dark ? darkUI : lightUI
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CapsuleTone {
|
||||||
|
/// Green: a financial saving.
|
||||||
|
static let saves = CapsuleTone(
|
||||||
|
lightForeground: Color(hex: 0x176B3D), lightBackground: Color(hex: 0xDDF3E5),
|
||||||
|
darkForeground: Color(hex: 0x66D69A), darkBackground: Color(hex: 0x204333)
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Grey: a neutral outcome, neither saving nor costing.
|
||||||
|
static let breakEven = CapsuleTone(
|
||||||
|
lightForeground: Color(hex: 0x555B64), lightBackground: Color(hex: 0xE9EAED),
|
||||||
|
darkForeground: Color(hex: 0xD0D0D5), darkBackground: Color(hex: 0x404044)
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Red: an additional cost.
|
||||||
|
static let costs = CapsuleTone(
|
||||||
|
lightForeground: Color(hex: 0xA63F3D), lightBackground: Color(hex: 0xF9E1DF),
|
||||||
|
darkForeground: Color(hex: 0xEF9690), darkBackground: Color(hex: 0x4B3032)
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Purple: best overall value after travel.
|
||||||
|
static let bestValue = CapsuleTone(
|
||||||
|
lightForeground: Color(hex: 0x7043A1), lightBackground: Color(hex: 0xEEE4F8),
|
||||||
|
darkForeground: Color(hex: 0xD1B4FA), darkBackground: Color(hex: 0x44354F)
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Blue: the lowest fuel price in range.
|
||||||
|
static let cheapest = CapsuleTone(
|
||||||
|
lightForeground: Color(hex: 0x225F9B), lightBackground: Color(hex: 0xE0EFFB),
|
||||||
|
darkForeground: Color(hex: 0x8BC5FA), darkBackground: Color(hex: 0x293F53)
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Neutral grey: proximity. Same family as `breakEven` — both mean "no
|
||||||
|
/// judgement" — but kept as its own pair so proximity and a break-even
|
||||||
|
/// verdict stay independently tunable.
|
||||||
|
static let closest = CapsuleTone(
|
||||||
|
lightForeground: Color(hex: 0x525967), lightBackground: Color(hex: 0xE8EAF0),
|
||||||
|
darkForeground: Color(hex: 0xC0C3CC), darkBackground: Color(hex: 0x424349)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one shape rule every capsule in the app follows.
|
||||||
|
///
|
||||||
|
/// A capsule while the content is a single line; a rounded rectangle once it
|
||||||
|
/// wraps. A pill's semicircular ends crowd the text corners at two lines or
|
||||||
|
/// more, while a 10pt radius does not. The shape follows what the content
|
||||||
|
/// actually DREW, not how many lines it declared, because a label can wrap in a
|
||||||
|
/// narrow column without the data changing — the baseline label was the case that
|
||||||
|
/// forced this: "Closest station" was one declared line that wrapped to two in the
|
||||||
|
/// listing's price column, which is also why that label is now the single word
|
||||||
|
/// "Closest".
|
||||||
|
///
|
||||||
|
/// Measuring happens on the content BEFORE padding, so the drawn line count is
|
||||||
|
/// never inflated by the capsule's own insets.
|
||||||
|
struct AdaptiveCapsule<Content: View>: View {
|
||||||
|
/// The tone's background fill. The caller resolves it from its own semantics.
|
||||||
|
let fill: Color
|
||||||
|
/// One line's height for the content's font, already scaled with Dynamic
|
||||||
|
/// Type — pass a `@ScaledMetric` so the division below tracks the user's size.
|
||||||
|
let lineHeight: CGFloat
|
||||||
|
/// Lines the content declares, used for the first layout pass before a
|
||||||
|
/// measured height exists.
|
||||||
|
var declaredLineCount: Int = 1
|
||||||
|
var cornerRadius: CGFloat = 10
|
||||||
|
var horizontalPadding: CGFloat
|
||||||
|
var verticalPadding: CGFloat
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
|
@State private var drawnHeight: CGFloat = 0
|
||||||
|
|
||||||
|
private var lineCount: Int {
|
||||||
|
guard drawnHeight > 0, lineHeight > 0 else { return max(1, declaredLineCount) }
|
||||||
|
return max(1, Int((drawnHeight / lineHeight).rounded()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shape: AnyShape {
|
||||||
|
lineCount > 1
|
||||||
|
? AnyShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
|
: AnyShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content
|
||||||
|
.background(
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear.preference(key: CapsuleContentHeightKey.self,
|
||||||
|
value: proxy.size.height)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal, horizontalPadding)
|
||||||
|
.padding(.vertical, verticalPadding)
|
||||||
|
.background(shape.fill(fill))
|
||||||
|
.onPreferenceChange(CapsuleContentHeightKey.self) { drawnHeight = $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carries the drawn height of a capsule's content up to `lineCount`.
|
||||||
|
private struct CapsuleContentHeightKey: PreferenceKey {
|
||||||
|
static let defaultValue: CGFloat = 0
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
|
value = max(value, nextValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// A fixed sRGB colour from a 24-bit literal. The capsule palette is
|
||||||
|
/// appearance-specific and supplied per tone, so these are deliberately NOT
|
||||||
|
/// dynamic — `CapsuleTone` resolves the pair itself.
|
||||||
|
init(hex: UInt32) {
|
||||||
|
self.init(
|
||||||
|
.sRGB,
|
||||||
|
red: Double((hex >> 16) & 0xFF) / 255,
|
||||||
|
green: Double((hex >> 8) & 0xFF) / 255,
|
||||||
|
blue: Double(hex & 0xFF) / 255,
|
||||||
|
opacity: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-68
@@ -321,29 +321,29 @@ struct ContentView: View {
|
|||||||
return pool.compactMap { $0.prices[selectedFuel] }.min()
|
return pool.compactMap { $0.prices[selectedFuel] }.min()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The "best" station — the cheapest in the pool — gets the TOP badge.
|
/// The cheapest station in the pool — the one that wears the Cheapest badge.
|
||||||
/// First occurrence in display order wins on price ties.
|
/// First occurrence in display order wins on price ties.
|
||||||
private var topStationID: String? {
|
private var cheapestStationID: String? {
|
||||||
guard let baselinePrice else { return nil }
|
guard let baselinePrice else { return nil }
|
||||||
return topStationID(in: displayedStations, baselinePrice: baselinePrice)
|
return cheapestStationID(in: displayedStations, baselinePrice: baselinePrice)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The TOP-badge station within an ALREADY-SORTED list. Split out so a caller
|
/// The Cheapest-badge station within an ALREADY-SORTED list. Split out so a
|
||||||
/// that has the sorted list (the Stations tab) does not sort the whole
|
/// caller that has the sorted list (the Stations tab) does not sort the whole
|
||||||
/// country a second time just to find one id.
|
/// country a second time just to find one id.
|
||||||
private func topStationID(in list: [FuelStation], baselinePrice: Double) -> String? {
|
private func cheapestStationID(in list: [FuelStation], baselinePrice: Double) -> String? {
|
||||||
list.first { $0.prices[selectedFuel] == baselinePrice }?.id
|
list.first { $0.prices[selectedFuel] == baselinePrice }?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The app's own "cheapest in range" — the TOP-badge station — mirrored
|
/// The app's own "cheapest in range" — the Cheapest-badge station — mirrored
|
||||||
/// into the Settings → Debug section so its "Cheapest in range" row
|
/// into the Settings → Debug section so its "Cheapest in range" row
|
||||||
/// matches the app list (and the widget when the widget's fuel + distance
|
/// matches the app list (and the widget when the widget's fuel + distance
|
||||||
/// match the app's). The debug section previously showed the ALERT
|
/// match the app's). The debug section previously showed the ALERT
|
||||||
/// prediction (alertsFuel + alert radius), which is a different pool by
|
/// prediction (alertsFuel + alert radius), which is a different pool by
|
||||||
/// design — that mismatch is what this fixes.
|
/// design — that mismatch is what this fixes.
|
||||||
private var appCheapestStatus: DebugAppCheapest? {
|
private var appCheapestStatus: DebugAppCheapest? {
|
||||||
guard let topID = topStationID,
|
guard let cheapestID = cheapestStationID,
|
||||||
let station = stations.first(where: { $0.id == topID }),
|
let station = stations.first(where: { $0.id == cheapestID }),
|
||||||
let location else { return nil }
|
let location else { return nil }
|
||||||
return DebugAppCheapest(
|
return DebugAppCheapest(
|
||||||
station: station,
|
station: station,
|
||||||
@@ -428,7 +428,7 @@ struct ContentView: View {
|
|||||||
///
|
///
|
||||||
/// The baseline is chosen with the same journey guards as
|
/// The baseline is chosen with the same journey guards as
|
||||||
/// `FuelSavingsEstimator.baselineStation` when configured; with the feature
|
/// `FuelSavingsEstimator.baselineStation` when configured; with the feature
|
||||||
/// off, the ordinary Closest station still names that row.
|
/// off, the ordinary closest station still names that row.
|
||||||
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
|
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
|
||||||
guard let location else { return (nil, [:]) }
|
guard let location else { return (nil, [:]) }
|
||||||
guard fuelSavingsSettings.isConfigured else {
|
guard fuelSavingsSettings.isConfigured else {
|
||||||
@@ -830,6 +830,13 @@ struct ContentView: View {
|
|||||||
fuelSavingsAmount: sessionFuelSavingsAmount,
|
fuelSavingsAmount: sessionFuelSavingsAmount,
|
||||||
savingsBaselineStationName: scaffold.baseline?.name,
|
savingsBaselineStationName: scaffold.baseline?.name,
|
||||||
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
|
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
|
||||||
|
// The same "cheapest in range" test this view gives its
|
||||||
|
// rows, so the sheet's Cheapest capsule matches the badge on
|
||||||
|
// the list it was opened from.
|
||||||
|
isCheapestInRange: station.id == cheapestStationID,
|
||||||
|
// The same "closest" test the rows get, from the ONE
|
||||||
|
// scaffold baseline the savings lookup also compares against.
|
||||||
|
isClosestInRange: station.id == scaffold.baseline?.id,
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
|
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
|
||||||
)
|
)
|
||||||
@@ -1047,9 +1054,10 @@ struct ContentView: View {
|
|||||||
sortMode = .worthTheDrive
|
sortMode = .worthTheDrive
|
||||||
}
|
}
|
||||||
// QA temp: `FB_DETAIL_DEBUG=1` (DEBUG builds only) forces
|
// QA temp: `FB_DETAIL_DEBUG=1` (DEBUG builds only) forces
|
||||||
// "Tap station = More info". The stored default is open-Apple-Maps,
|
// "Tap station = More info". The default is already the detail
|
||||||
// so on a simulator a tap bounces out to Maps with no way back and
|
// sheet, so this only matters on a simulator whose store has
|
||||||
// the Worth the Drive detail section can't be checked at all.
|
// "Directions" saved — it overrides the stored choice either way,
|
||||||
|
// which keeps the Worth the Drive detail section reachable.
|
||||||
if ProcessInfo.processInfo.environment["FB_DETAIL_DEBUG"] == "1" {
|
if ProcessInfo.processInfo.environment["FB_DETAIL_DEBUG"] == "1" {
|
||||||
stationTapAction = .showDetails
|
stationTapAction = .showDetails
|
||||||
}
|
}
|
||||||
@@ -1524,15 +1532,15 @@ struct ContentView: View {
|
|||||||
/// within the compiler's type-check budget.
|
/// within the compiler's type-check budget.
|
||||||
private var stationsTab: some View {
|
private var stationsTab: some View {
|
||||||
// ONE scaffold (baseline + journeys) and ONE sort serve the list, its
|
// ONE scaffold (baseline + journeys) and ONE sort serve the list, its
|
||||||
// count, the TOP badge and every savings label. Asking for
|
// count, the Cheapest badge and every savings label. Asking for
|
||||||
// `nearestSavingsBaseline`, `displayedStations`, `sortedStations.count`,
|
// `nearestSavingsBaseline`, `displayedStations`, `sortedStations.count`,
|
||||||
// `topStationID` and `savingsLookup` separately walked the full ~8k list
|
// `cheapestStationID` and `savingsLookup` separately walked the full ~8k
|
||||||
// six times per render — seconds of main-thread work in Closest mode,
|
// list six times per render — seconds of main-thread work in Closest mode,
|
||||||
// where the pool is every station in the UK.
|
// where the pool is every station in the UK.
|
||||||
let scaffold = savingsScaffold()
|
let scaffold = savingsScaffold()
|
||||||
let baselineStation = scaffold.baseline
|
let baselineStation = scaffold.baseline
|
||||||
let displayed = displayedStations
|
let displayed = displayedStations
|
||||||
let topID = baselinePrice.flatMap { topStationID(in: displayed, baselinePrice: $0) }
|
let cheapestID = baselinePrice.flatMap { cheapestStationID(in: displayed, baselinePrice: $0) }
|
||||||
return StationsView(
|
return StationsView(
|
||||||
stations: displayed,
|
stations: displayed,
|
||||||
totalCount: displayed.count,
|
totalCount: displayed.count,
|
||||||
@@ -1543,7 +1551,8 @@ struct ContentView: View {
|
|||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
baselinePrice: baselinePrice,
|
baselinePrice: baselinePrice,
|
||||||
topStationID: topID,
|
cheapestStationID: cheapestID,
|
||||||
|
closestStationID: baselineStation?.id,
|
||||||
location: location,
|
location: location,
|
||||||
favouriteIDs: favouriteIDs,
|
favouriteIDs: favouriteIDs,
|
||||||
stationTapAction: stationTapAction,
|
stationTapAction: stationTapAction,
|
||||||
@@ -1797,8 +1806,11 @@ struct StationRow: View {
|
|||||||
let priceDisplayStyle: PriceDisplayStyle
|
let priceDisplayStyle: PriceDisplayStyle
|
||||||
let baselinePrice: Double?
|
let baselinePrice: Double?
|
||||||
/// This row is the cheapest price in range — the caller's existing
|
/// This row is the cheapest price in range — the caller's existing
|
||||||
/// `topStationID` test. Feeds the CHEAPEST badge.
|
/// `cheapestStationID` test. Feeds the Cheapest badge.
|
||||||
let isTopResult: Bool
|
let isCheapestResult: Bool
|
||||||
|
/// This row is the station savings are measured against — the caller's
|
||||||
|
/// `closestStationID` test. Feeds the Closest badge.
|
||||||
|
let isClosestResult: Bool
|
||||||
let isFavourite: Bool
|
let isFavourite: Bool
|
||||||
var tapAction: StationTapAction = .openMap
|
var tapAction: StationTapAction = .openMap
|
||||||
var fuelSavings: FuelSavingsAvailability?
|
var fuelSavings: FuelSavingsAvailability?
|
||||||
@@ -1858,12 +1870,13 @@ struct StationRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The row's single recommendation badge, if any. BEST VALUE (worth the
|
/// The row's single recommendation badge, if any. BEST VALUE (worth the
|
||||||
/// drive by the alert threshold) outranks CHEAPEST; NEAREST never gets one,
|
/// drive by the alert threshold) outranks CHEAPEST, which outranks CLOSEST —
|
||||||
/// being context rather than a recommendation.
|
/// see `StationRecommendationBadge.badge` for why proximity ranks last.
|
||||||
private var recommendationBadge: StationRecommendationBadge? {
|
private var recommendationBadge: StationRecommendationBadge? {
|
||||||
StationRecommendationBadge.badge(
|
StationRecommendationBadge.badge(
|
||||||
netSavingGBP: fuelSavings?.result?.netSavingGBP,
|
netSavingGBP: fuelSavings?.result?.netSavingGBP,
|
||||||
isCheapest: isTopResult,
|
isCheapest: isCheapestResult,
|
||||||
|
isClosest: isClosestResult,
|
||||||
bestValueThresholdGBP: bestValueThresholdGBP
|
bestValueThresholdGBP: bestValueThresholdGBP
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1941,14 +1954,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 +2036,37 @@ 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))
|
||||||
|
}
|
||||||
|
// The closest station wears the Closest badge above its price
|
||||||
|
// and gets no verdict capsule: the capsule's only word for that
|
||||||
|
// state was "Closest", so printing it here as well would say
|
||||||
|
// the same thing twice, three lines apart.
|
||||||
|
if let result = fuelSavings?.result, !isClosestResult {
|
||||||
|
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,39 +2092,36 @@ 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" pill carries the same fill, text and shape rules as
|
||||||
@ViewBuilder
|
/// 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 capsule beneath, which is
|
||||||
/// app: coloured text keeps its contrast in both appearances, at every
|
/// filled: a badge annotates the price rather than reporting an outcome, so it
|
||||||
/// Dynamic Type size, without introducing a colour outside the palette.
|
/// stays quieter than the pill below it, and tinted text keeps its contrast in
|
||||||
|
/// both appearances at every Dynamic Type size.
|
||||||
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
|
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
|
||||||
StationLabelBadge(label: badge.title, color: badge.tint)
|
StationLabelBadge(kind: badge.labelKind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
|
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
|
||||||
/// they stay out of the shared model the tests cover.
|
/// they stay out of the shared model the tests cover.
|
||||||
private extension StationRecommendationBadge {
|
private extension StationRecommendationBadge {
|
||||||
var title: String {
|
/// The shared label kind, so a row's badge and the Key's legend for the same
|
||||||
|
/// meaning can never drift apart.
|
||||||
|
var labelKind: StationLabelKind {
|
||||||
switch self {
|
switch self {
|
||||||
case .bestValue:
|
case .bestValue: return .bestValue
|
||||||
return String(localized: "BEST VALUE", comment: "Station badge: saving clears the Best Value threshold")
|
case .cheapest: return .cheapest
|
||||||
case .cheapest:
|
case .closest: return .closest
|
||||||
return String(localized: "CHEAPEST", comment: "Station badge: cheapest price in range")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var tint: Color { self == .bestValue ? .green : .blue }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Location manager
|
// MARK: - Location manager
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
@@ -69,6 +77,26 @@ struct FavouritesView: View {
|
|||||||
}
|
}
|
||||||
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
|
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
|
||||||
|
|
||||||
|
/// Nearest favourited station for the active fuel — this tab's Closest.
|
||||||
|
///
|
||||||
|
/// Measured on the same one-way distance the rows print, so the badge cannot
|
||||||
|
/// point at a station the list shows as further away than its neighbour. Only
|
||||||
|
/// favourites with a price for the active fuel are eligible, matching the
|
||||||
|
/// radius tab, where the closest station is by definition the savings
|
||||||
|
/// baseline; an unpriced favourite appears last and wears no label. Ties keep
|
||||||
|
/// the user's own order, which is what `ordered` already encodes.
|
||||||
|
private var nearest: FuelStation? {
|
||||||
|
guard let location else { return nil }
|
||||||
|
var best: (station: FuelStation, km: Double)?
|
||||||
|
for station in ordered where station.prices[activeFuel] != nil {
|
||||||
|
let km = FuelStore.stationDistanceDisplay(
|
||||||
|
station: station, userLat: location.lat, userLng: location.lng
|
||||||
|
).km
|
||||||
|
if best == nil || km < best!.km { best = (station, km) }
|
||||||
|
}
|
||||||
|
return best?.station
|
||||||
|
}
|
||||||
|
|
||||||
/// The baseline note is useful only when at least one visible favourite has
|
/// The baseline note is useful only when at least one visible favourite has
|
||||||
/// an actual savings comparison. An unavailable result (for example, while
|
/// an actual savings comparison. An unavailable result (for example, while
|
||||||
/// location is missing) must not leave an orphaned explanation below the list.
|
/// location is missing) must not leave an orphaned explanation below the list.
|
||||||
@@ -175,7 +203,8 @@ struct FavouritesView: View {
|
|||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
baselinePrice: cheapestPrice,
|
baselinePrice: cheapestPrice,
|
||||||
isTopResult: station.id == cheapest?.id,
|
isCheapestResult: station.id == cheapest?.id,
|
||||||
|
isClosestResult: station.id == nearest?.id,
|
||||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||||
tapAction: stationTapAction,
|
tapAction: stationTapAction,
|
||||||
fuelSavings: savingsForStation(station),
|
fuelSavings: savingsForStation(station),
|
||||||
@@ -206,8 +235,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 +245,24 @@ 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(
|
||||||
|
showBestValueInfo: $showBestValueInfo,
|
||||||
|
showImportantInformation: $showImportantInformation
|
||||||
|
)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showTrends) {
|
.sheet(isPresented: $showTrends) {
|
||||||
TrendsView(
|
TrendsView(
|
||||||
favourites: favourites,
|
favourites: favourites,
|
||||||
@@ -237,6 +282,11 @@ struct FavouritesView: View {
|
|||||||
fuelSavings: savingsForStation(station),
|
fuelSavings: savingsForStation(station),
|
||||||
fuelSavingsAmount: fuelSavingsAmount,
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||||
|
// This tab's cheapest favourite — the same flag its rows get,
|
||||||
|
// and the same label they wear.
|
||||||
|
isCheapestInRange: station.id == cheapest?.id,
|
||||||
|
// This tab's nearest favourite — the same flag its rows get.
|
||||||
|
isClosestInRange: station.id == nearest?.id,
|
||||||
// Every station listed here IS a favourite for the tab's
|
// Every station listed here IS a favourite for the tab's
|
||||||
// fuel, so the star starts filled; tapping it unstars.
|
// fuel, so the star starts filled; tapping it unstars.
|
||||||
isFavourite: true,
|
isFavourite: true,
|
||||||
|
|||||||
@@ -105,44 +105,61 @@ struct FuelSavingsCapsule: View {
|
|||||||
let result: FuelSavingsResult
|
let result: FuelSavingsResult
|
||||||
var compact: Bool = true
|
var compact: Bool = true
|
||||||
|
|
||||||
|
/// One line of `.headline` / `.caption`, scaled with Dynamic Type, so the
|
||||||
|
/// shared shape rule can tell one drawn line from two.
|
||||||
|
@ScaledMetric(relativeTo: .headline) private var headlineLineHeight: CGFloat = 22
|
||||||
|
@ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14
|
||||||
|
|
||||||
|
private var tone: CapsuleTone { Self.tone(for: result.status) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: compact ? 3 : 5) {
|
AdaptiveCapsule(
|
||||||
Image(systemName: Self.symbol(for: result.status))
|
fill: tone.background,
|
||||||
.accessibilityHidden(true)
|
lineHeight: compact ? captionLineHeight : headlineLineHeight,
|
||||||
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
|
horizontalPadding: compact ? 8 : 12,
|
||||||
.lineLimit(1)
|
verticalPadding: compact ? 4 : 7
|
||||||
// Measured on a 393pt screen: the capsule is handed ~101pt, of
|
) {
|
||||||
// which the icon, its spacing and the 8pt padding either side
|
HStack(spacing: compact ? 3 : 5) {
|
||||||
// take 32pt, leaving ~69pt for the text. "Closest station" needs
|
Image(systemName: Self.symbol(for: result.status))
|
||||||
// ~90pt at full size, so a 0.8 floor — 72pt — could not reach it
|
.accessibilityHidden(true)
|
||||||
// and the row showed "Closest stati…" instead. 0.7 leaves ~63pt,
|
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
|
||||||
// which clears it at ~10pt type: scaled, never truncated.
|
// The detail sheet's capsule sits beside a headline and has the
|
||||||
.minimumScaleFactor(0.7)
|
// width for its wording; the listing's is narrow and scales.
|
||||||
|
.lineLimit(compact ? 1 : nil)
|
||||||
|
.minimumScaleFactor(compact ? 0.7 : 1)
|
||||||
|
}
|
||||||
|
.font(compact ? .caption.weight(.semibold) : .headline)
|
||||||
|
.foregroundStyle(tone.foreground)
|
||||||
}
|
}
|
||||||
.font(compact ? .caption.weight(.semibold) : .headline)
|
|
||||||
.foregroundStyle(Self.color(for: result.status))
|
|
||||||
.padding(.horizontal, compact ? 8 : 12)
|
|
||||||
.padding(.vertical, compact ? 4 : 7)
|
|
||||||
.background(Capsule().fill(Self.color(for: result.status).opacity(0.15)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verdict colour: green save, red worse off, grey break even, blue
|
/// Verdict tone: green save, red worse off, grey break even, neutral-grey
|
||||||
/// baseline — the same green/amber/red family the price dot already uses.
|
/// baseline — the same meanings the price dot's RAG family carries.
|
||||||
static func color(for status: FuelSavingsStatus) -> Color {
|
static func tone(for status: FuelSavingsStatus) -> CapsuleTone {
|
||||||
switch status {
|
switch status {
|
||||||
case .worthTheDrive: return .green
|
case .worthTheDrive: return .saves
|
||||||
case .notWorthTheDrive: return .red
|
case .notWorthTheDrive: return .costs
|
||||||
case .breakEven: return .secondary
|
case .breakEven: return .breakEven
|
||||||
case .baseline: return .blue
|
case .baseline: return .closest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Foreground-only form, for a `Text` that is tinted rather than filled (the
|
||||||
|
/// detail sheet's Total row). Keeps one source for the verdict colour.
|
||||||
|
static func color(for status: FuelSavingsStatus) -> Color {
|
||||||
|
tone(for: status).foreground
|
||||||
|
}
|
||||||
|
|
||||||
static func symbol(for status: FuelSavingsStatus) -> String {
|
static func symbol(for status: FuelSavingsStatus) -> String {
|
||||||
switch status {
|
switch status {
|
||||||
case .worthTheDrive: return "checkmark.circle.fill"
|
case .worthTheDrive: return "checkmark.circle.fill"
|
||||||
case .breakEven: return "equal.circle.fill"
|
case .breakEven: return "equal.circle.fill"
|
||||||
case .notWorthTheDrive: return "xmark.circle.fill"
|
case .notWorthTheDrive: return "xmark.circle.fill"
|
||||||
case .baseline: return "location.circle.fill"
|
// The ONE closest glyph, shared with the Key legend and the station
|
||||||
|
// badges (`StationLabelKind.closest`). The other three verdicts are
|
||||||
|
// circular because they report an outcome; closest reports a position,
|
||||||
|
// and the plain location pointer is what the legend teaches for it.
|
||||||
|
case .baseline: return StationLabelKind.closest.symbol
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,49 +177,68 @@ 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
|
||||||
|
|
||||||
|
/// One caption line's height, scaled with Dynamic Type, for the shared shape
|
||||||
|
/// rule. The drawn height is divided by this, so the shape follows the lines
|
||||||
|
/// actually on screen rather than the number the verdict declares.
|
||||||
|
@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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tone: CapsuleTone { FuelSavingsCapsule.tone(for: result.status) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
AdaptiveCapsule(
|
||||||
Image(systemName: symbol)
|
fill: tone.background,
|
||||||
.accessibilityHidden(true)
|
lineHeight: captionLineHeight,
|
||||||
// One Text per line, not a "\n" inside one string: each line keeps its
|
declaredLineCount: verdict.lines.count,
|
||||||
// own localisation key and the amount can be monospaced on its own.
|
horizontalPadding: 8,
|
||||||
// Left-aligned: the word and its figure share a left edge, so the
|
verticalPadding: 4
|
||||||
// block reads as a column rather than a ragged centre.
|
) {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||||
Text(verdict.headline)
|
Image(systemName: symbol)
|
||||||
if let amount = verdict.amount {
|
.accessibilityHidden(true)
|
||||||
Text(amount)
|
// One Text per line, not a "\n" inside one string: each line keeps its
|
||||||
.monospacedDigit()
|
// own localisation key and the amount can be monospaced on its own.
|
||||||
}
|
// Left-aligned: the word and its figure share a left edge, so the
|
||||||
if let qualifier = verdict.qualifier {
|
// block reads as a column rather than a ragged centre.
|
||||||
Text(qualifier)
|
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)
|
||||||
}
|
}
|
||||||
.multilineTextAlignment(.leading)
|
// Tinted foreground on a tinted fill. These capsules carry a figure the
|
||||||
// Never compress or truncate: the capsule wraps and the row grows taller.
|
// eye is asked to read at a glance, so they are filled rather than
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
// tinted-text-only; every badge above them stays the quieter tinted form.
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(tone.foreground)
|
||||||
}
|
}
|
||||||
.font(.caption.weight(.semibold))
|
// Saves and Costs share one minimum width, so their trailing edges line
|
||||||
.foregroundStyle(.primary)
|
// up down the list whatever the figure's length. Break even and Closest
|
||||||
.padding(.horizontal, 8)
|
// station carry no figure and keep their natural width.
|
||||||
.padding(.vertical, 4)
|
.frame(minWidth: minimumWidth, alignment: .leading)
|
||||||
.background(shape.fill(Color.secondary.opacity(0.14)))
|
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
.accessibilityLabel(verdict.spokenText)
|
.accessibilityLabel(verdict.spokenText)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line keeps the capsule. Two or more switch to a rounded rect: a pill's
|
/// Saves and Costs only — the two verdicts that carry a figure. 84pt is the
|
||||||
/// semicircular ends crowd the text corners at that height, while an 8pt
|
/// width the widest common amount needs ("£123.45 more"), so shorter figures
|
||||||
/// radius does not. Keyed to the LINE COUNT, so Break even flips shape only
|
/// pad rather than shrink the pill.
|
||||||
/// when "(approx.)" pushes it onto a second line.
|
private var minimumWidth: CGFloat {
|
||||||
private var shape: AnyShape {
|
switch result.status {
|
||||||
verdict.lines.count > 1
|
case .worthTheDrive, .notWorthTheDrive: return 84
|
||||||
? AnyShape(RoundedRectangle(cornerRadius: 8))
|
case .breakEven, .baseline: return 0
|
||||||
: AnyShape(Capsule())
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var symbol: String {
|
private var symbol: String {
|
||||||
@@ -210,7 +246,9 @@ struct ListingFuelSavingsCapsule: View {
|
|||||||
case .worthTheDrive: return "checkmark.circle"
|
case .worthTheDrive: return "checkmark.circle"
|
||||||
case .notWorthTheDrive: return "arrow.up.circle"
|
case .notWorthTheDrive: return "arrow.up.circle"
|
||||||
case .breakEven: return "equal.circle"
|
case .breakEven: return "equal.circle"
|
||||||
case .baseline: return "location.circle"
|
// Same closest glyph as the detail verdict and the Key — this capsule is
|
||||||
|
// the listing's copy of the same state, not a third icon for it.
|
||||||
|
case .baseline: return StationLabelKind.closest.symbol
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// The "How Best Value works" explanation as its own sheet.
|
/// 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
|
/// 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
|
/// now so the Key sheet and the More info sheet can both open it, and the copy is
|
||||||
@@ -69,7 +69,11 @@ struct HowBestValueSheet: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("How Best Value works")
|
.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)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
|||||||
@@ -141,9 +141,14 @@ struct BestValueDetailCard: View {
|
|||||||
/// same large form the sheet used to repeat on its own line above the
|
/// same large form the sheet used to repeat on its own line above the
|
||||||
/// working. The comparison caption sits UNDER the capsule, trailing-aligned
|
/// working. The comparison caption sits UNDER the capsule, trailing-aligned
|
||||||
/// with it, the same way the listing rows lay the pair out.
|
/// with it, the same way the listing rows lay the pair out.
|
||||||
|
///
|
||||||
|
/// "Value breakdown" rather than "Best Value": the card is the working behind
|
||||||
|
/// whatever verdict the capsule shows, and that verdict is not always a Best
|
||||||
|
/// Value one — a Costs station shows its extra cost here. Naming the card after
|
||||||
|
/// the feature promised the reader a Best Value result it might not get.
|
||||||
private func header(_ result: FuelSavingsResult) -> some View {
|
private func header(_ result: FuelSavingsResult) -> some View {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||||
Text("Best Value")
|
Text("Value breakdown")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
@@ -204,7 +209,7 @@ struct BestValueDetailCard: View {
|
|||||||
|
|
||||||
/// The working: what the extra travel adds, what the pump saves, and the
|
/// The working: what the extra travel adds, what the pump saves, and the
|
||||||
/// total. A baseline station has nothing to save against itself, so it stops
|
/// total. A baseline station has nothing to save against itself, so it stops
|
||||||
/// after the panels — the capsule above already says "Closest station".
|
/// after the panels — the capsule above already says "Closest".
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func workings(_ result: FuelSavingsResult) -> some View {
|
private func workings(_ result: FuelSavingsResult) -> some View {
|
||||||
if result.status != .baseline {
|
if result.status != .baseline {
|
||||||
@@ -278,6 +283,6 @@ struct BestValueDetailCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "How Best Value works" lives in the shared `HowBestValueRow`
|
/// "How does FuelBoard calculate value?" lives in the shared `HowBestValueRow`
|
||||||
/// (HowBestValueSheet.swift) — the same row the Key sheet uses, so the sheet and
|
/// (HowBestValueSheet.swift) — the same row the Key sheet uses, so the sheet and
|
||||||
/// the Key cannot drift.
|
/// the Key cannot drift.
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ struct StationDetailView: View {
|
|||||||
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||||
var savingsBaselineStationName: String? = nil
|
var savingsBaselineStationName: String? = nil
|
||||||
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
|
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
|
||||||
|
/// Ranking flags, supplied by the list that opened this sheet — the SAME flag
|
||||||
|
/// it hands its own rows, so this sheet cannot award a capsule the row behind it
|
||||||
|
/// did not. Both tabs mean the same thing by "cheapest": the lowest price among
|
||||||
|
/// the stations they are listing, whether that is the radius or the Favourites.
|
||||||
|
var isCheapestInRange: Bool = false
|
||||||
|
/// The same "closest" test the host gives its own rows. Its meaning follows
|
||||||
|
/// the tab, exactly as Cheapest does: the nearest station in the radius, or
|
||||||
|
/// the nearest favourite.
|
||||||
|
var isClosestInRange: 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
|
/// 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 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
|
/// the star here and the star on the row are the same fuel-scoped
|
||||||
@@ -38,8 +51,8 @@ struct StationDetailView: View {
|
|||||||
/// the caption fades out on its own.
|
/// the caption fades out on its own.
|
||||||
@State private var showingCopied = false
|
@State private var showingCopied = false
|
||||||
@State private var copyResetTask: Task<Void, Never>?
|
@State private var copyResetTask: Task<Void, Never>?
|
||||||
/// The "How Best Value works" sheet, opened from the row at the bottom of the
|
/// The "How does FuelBoard calculate value?" sheet, opened from the row at the
|
||||||
/// Best Value card (the Key sheet opens the same shared view).
|
/// bottom of the value-breakdown card (the Key sheet opens the same shared view).
|
||||||
@State private var showBestValueInfo = false
|
@State private var showBestValueInfo = false
|
||||||
|
|
||||||
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
||||||
@@ -66,6 +79,18 @@ struct StationDetailView: View {
|
|||||||
FuelPriceFreshness.assessment(for: station)
|
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,
|
||||||
|
isClosestInRange: isClosestInRange,
|
||||||
|
bestValueThresholdGBP: bestValueThresholdGBP
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// One-way and out-and-back distance from the current location, from the ONE
|
/// 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
|
/// shared display helper — so this sheet's figures carry the same confidence
|
||||||
/// marker as the listing row, the widget and the Live Activity.
|
/// marker as the listing row, the widget and the Live Activity.
|
||||||
@@ -229,10 +254,17 @@ struct StationDetailView: View {
|
|||||||
// clutter. The Address/Distance and Distance/actions lines
|
// clutter. The Address/Distance and Distance/actions lines
|
||||||
// stay.
|
// stay.
|
||||||
.listRowSeparator(.hidden, edges: .bottom)
|
.listRowSeparator(.hidden, edges: .bottom)
|
||||||
// The map, the facts row, any notices and the two station
|
// Ranking capsules directly under the map, above the price and
|
||||||
// actions all share ONE card (the list's own row hairlines
|
// distance. Nothing renders when the station qualifies for no
|
||||||
// separate them), so no hand-rolled Divider here — adding one
|
// label, so an ordinary station's layout is untouched.
|
||||||
// would double the line.
|
//
|
||||||
|
// 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,
|
// The station name AND the address live in the sheet header,
|
||||||
// which is also where a tap copies the postcode — see the
|
// which is also where a tap copies the postcode — see the
|
||||||
@@ -290,9 +322,12 @@ struct StationDetailView: View {
|
|||||||
// is not configured — so the heading cannot be left dangling
|
// is not configured — so the heading cannot be left dangling
|
||||||
// over an empty section.
|
// over an empty section.
|
||||||
if let fuelSavings {
|
if let fuelSavings {
|
||||||
// The card's own header row says "Best Value", so a section
|
// The card carries its own "Value breakdown" heading, so a
|
||||||
// header would repeat it; the green tint is what marks this
|
// section header would repeat it. Neutral card background: the
|
||||||
// block as the savings card.
|
// 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 {
|
Section {
|
||||||
BestValueDetailCard(
|
BestValueDetailCard(
|
||||||
availability: fuelSavings,
|
availability: fuelSavings,
|
||||||
@@ -301,7 +336,6 @@ struct StationDetailView: View {
|
|||||||
distanceUnit: distanceUnit
|
distanceUnit: distanceUnit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.green.opacity(0.08))
|
|
||||||
.id("bestValueCard")
|
.id("bestValueCard")
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
|||||||
+175
-114
@@ -1,6 +1,115 @@
|
|||||||
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 {
|
||||||
|
@Binding var showBestValueInfo: Bool
|
||||||
|
@Binding var showImportantInformation: Bool
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
/// Where the user last dragged the sheet. Bound rather than fixed at 0.6 so
|
||||||
|
/// dragging it up to read the Station labels section sticks, instead of
|
||||||
|
/// snapping back every time the sheet is reopened.
|
||||||
|
///
|
||||||
|
/// The QA launch argument sets the INITIAL detent rather than assigning one
|
||||||
|
/// after the sheet appears: assigning a new selection while the sheet is still
|
||||||
|
/// presenting is ignored, which left the legend stuck at 0.6 and the Station
|
||||||
|
/// labels section off screen.
|
||||||
|
@State private var detent: PresentationDetent =
|
||||||
|
ProcessInfo.processInfo.arguments.contains("-qaKeySheetLarge") ? .large : .fraction(0.6)
|
||||||
|
|
||||||
|
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(StationLabelKind.allCases, id: \.self) { kind in
|
||||||
|
KeySheetDescriptionRow(item: item(for: kind)) {
|
||||||
|
StationLabelBadge(kind: kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
// QA hook: `-qaKeySheetLarge` opens the legend at full height — see the
|
||||||
|
// `detent` state. The 0.6 detent puts the Station labels section below the
|
||||||
|
// fold on every device, including the tallest iPhone, so the three label
|
||||||
|
// badges cannot otherwise be seen on screen.
|
||||||
|
.presentationDetents([.fraction(0.6), .large], selection: $detent)
|
||||||
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Key row's copy for a label kind. Fails loudly in debug rather than
|
||||||
|
/// silently rendering a blank row if a kind is ever added without its entry.
|
||||||
|
private func item(for kind: StationLabelKind) -> KeySheetContent.Item {
|
||||||
|
if let match = KeySheetContent.stationLabels.first(where: { $0.label == kind.label }) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
assertionFailure("No KeySheetContent.stationLabels entry for \(kind)")
|
||||||
|
return KeySheetContent.Item(label: kind.label, description: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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]
|
||||||
@@ -12,7 +121,11 @@ struct StationsView: View {
|
|||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
let priceDisplayStyle: PriceDisplayStyle
|
let priceDisplayStyle: PriceDisplayStyle
|
||||||
let baselinePrice: Double?
|
let baselinePrice: Double?
|
||||||
let topStationID: String?
|
let cheapestStationID: String?
|
||||||
|
/// The station the savings are measured against — the closest station with an
|
||||||
|
/// eligible price. Passed in from the owner of the scaffold baseline so this
|
||||||
|
/// list's Closest badge and the reference station cannot disagree.
|
||||||
|
let closestStationID: String?
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let favouriteIDs: Set<String>
|
let favouriteIDs: Set<String>
|
||||||
let stationTapAction: StationTapAction
|
let stationTapAction: StationTapAction
|
||||||
@@ -24,7 +137,7 @@ struct StationsView: View {
|
|||||||
var onRefresh: () async -> Void = {}
|
var onRefresh: () async -> Void = {}
|
||||||
|
|
||||||
/// Pagination: one page = 10 rows, reset whenever the underlying list
|
/// Pagination: one page = 10 rows, reset whenever the underlying list
|
||||||
/// changes (new fetch, fuel/sort/radius switch). RAG/TOP/deltas still come
|
/// changes (new fetch, fuel/sort/radius switch). RAG/badge/deltas still come
|
||||||
/// from the whole radius pool via `cheapestPrice` — paging never changes
|
/// from the whole radius pool via `cheapestPrice` — paging never changes
|
||||||
/// which station is "best".
|
/// which station is "best".
|
||||||
@State private var pageSize = 10
|
@State private var pageSize = 10
|
||||||
@@ -291,78 +404,23 @@ struct StationsView: View {
|
|||||||
fuelSavings: savingsForStation(station),
|
fuelSavings: savingsForStation(station),
|
||||||
fuelSavingsAmount: fuelSavingsAmount,
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||||
|
// The same flag the row gets from `cheapestStationID`.
|
||||||
|
isCheapestInRange: station.id == cheapestStationID,
|
||||||
|
// The same flag the row gets from `closestStationID`.
|
||||||
|
isClosestInRange: station.id == closestStationID,
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The legend sheet, shared with the Favourites tab. Nothing varies by fuel:
|
||||||
|
/// every station label has a fixed semantic tone, so the legend reads the same
|
||||||
|
/// whichever grade the reader is looking at.
|
||||||
private var keySheet: some View {
|
private var keySheet: some View {
|
||||||
NavigationStack {
|
KeySheetView(
|
||||||
List {
|
showBestValueInfo: $showBestValueInfo,
|
||||||
Section {
|
showImportantInformation: $showImportantInformation
|
||||||
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: 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 {
|
||||||
@@ -373,7 +431,8 @@ struct StationsView: View {
|
|||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
baselinePrice: baselinePrice,
|
baselinePrice: baselinePrice,
|
||||||
isTopResult: station.id == topStationID,
|
isCheapestResult: station.id == cheapestStationID,
|
||||||
|
isClosestResult: station.id == closestStationID,
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
tapAction: stationTapAction,
|
tapAction: stationTapAction,
|
||||||
fuelSavings: savingsForStation(station),
|
fuelSavings: savingsForStation(station),
|
||||||
@@ -443,10 +502,12 @@ private struct KeySheetDescriptionRow<Indicator: View>: View {
|
|||||||
private struct KeySavingsCapsule: View {
|
private struct KeySavingsCapsule: View {
|
||||||
let label: String
|
let label: String
|
||||||
|
|
||||||
|
/// Keyed on the shared label so the legend cannot demonstrate a word the
|
||||||
|
/// listing capsule never draws.
|
||||||
private var status: FuelSavingsStatus {
|
private var status: FuelSavingsStatus {
|
||||||
switch label {
|
switch label {
|
||||||
case "Saves": return .worthTheDrive
|
case String(localized: "Saves"): return .worthTheDrive
|
||||||
case "Costs more": return .notWorthTheDrive
|
case String(localized: "Costs"): return .notWorthTheDrive
|
||||||
default: return .breakEven
|
default: return .breakEven
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,13 +533,23 @@ private struct KeySavingsCapsule: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shared RAG dot used beside live station prices and in the Key.
|
/// Shared RAG dot used beside live station prices and in the Key.
|
||||||
|
///
|
||||||
|
/// 12pt is the app's one diameter: it reads at a glance beside a 22pt price
|
||||||
|
/// without competing with it, and it is large enough that the three colours are
|
||||||
|
/// distinguishable for colour-vision-deficient users at arm's length. The widget
|
||||||
|
/// keeps a smaller dot of its own — its rows are far tighter than a phone row —
|
||||||
|
/// so this size is deliberately the APP's.
|
||||||
struct PriceRatingDot: View {
|
struct PriceRatingDot: View {
|
||||||
let color: Color
|
let color: Color
|
||||||
|
var diameter: CGFloat = PriceRatingDot.standardDiameter
|
||||||
|
|
||||||
|
/// The app's standard dot diameter, in points.
|
||||||
|
static let standardDiameter: CGFloat = 12
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(color)
|
.fill(color)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: diameter, height: diameter)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,56 +566,46 @@ struct FavouriteStarGlyph: View {
|
|||||||
|
|
||||||
/// Shared station-label capsule. `StationRow` uses the same view for its live
|
/// Shared station-label capsule. `StationRow` uses the same view for its live
|
||||||
/// BEST VALUE/CHEAPEST badge, so the Key cannot drift from the listing style.
|
/// BEST VALUE/CHEAPEST badge, so the Key cannot drift from the listing style.
|
||||||
|
///
|
||||||
|
/// One tone per label kind, from the shared palette: purple for best value, blue
|
||||||
|
/// for cheapest and top (a top favourite IS the cheapest favourite, so they share
|
||||||
|
/// a class), neutral for proximity. The symbol is the label's own meaning, sized
|
||||||
|
/// to sit on the caption's cap height rather than to dominate it.
|
||||||
struct StationLabelBadge: View {
|
struct StationLabelBadge: View {
|
||||||
let label: String
|
let kind: StationLabelKind
|
||||||
let color: Color
|
|
||||||
|
|
||||||
var body: some View {
|
private var tone: CapsuleTone {
|
||||||
Text(LocalizedStringKey(label))
|
switch kind {
|
||||||
.font(.caption2.bold())
|
case .bestValue: return .bestValue
|
||||||
.foregroundStyle(color)
|
case .cheapest: return .cheapest
|
||||||
.padding(.horizontal, 7)
|
case .closest: return .closest
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(Capsule().fill(color.opacity(0.15)))
|
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct KeyStationLabelIndicator: View {
|
|
||||||
let label: String
|
|
||||||
let fuelColor: Color
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
switch label {
|
|
||||||
case "BEST VALUE":
|
|
||||||
StationLabelBadge(label: label, color: .green)
|
|
||||||
case "CHEAPEST":
|
|
||||||
StationLabelBadge(label: label, color: .blue)
|
|
||||||
case "CLOSEST":
|
|
||||||
FuelSavingsCapsule(result: baselineResult)
|
|
||||||
default:
|
|
||||||
Text("TOP")
|
|
||||||
.font(.caption2.bold())
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(fuelColor.opacity(0.18), in: Capsule())
|
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var baselineResult: FuelSavingsResult {
|
var body: some View {
|
||||||
FuelSavingsResult(
|
HStack(spacing: 3) {
|
||||||
fillLitres: 30,
|
Image(systemName: kind.symbol)
|
||||||
grossSavingGBP: 0,
|
.font(.caption2.weight(.semibold))
|
||||||
extraDrivingCostGBP: 0,
|
.accessibilityHidden(true)
|
||||||
extraDistanceMiles: 0,
|
Text(LocalizedStringKey(kind.label))
|
||||||
netSavingGBP: 0,
|
.font(.caption.weight(.semibold))
|
||||||
status: .baseline,
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
journeyConfidence: .confirmed
|
}
|
||||||
)
|
.foregroundStyle(tone.foreground)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Capsule().fill(tone.background))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every station label in the Key is the SAME `StationLabelBadge` the listing rows
|
||||||
|
/// and the More info sheet draw.
|
||||||
|
///
|
||||||
|
/// The closest station used to be the exception — the legend rendered the real
|
||||||
|
/// verdict capsule for it, a wider pill with different fill and shape, so it read as
|
||||||
|
/// a different class of thing from Best value and Cheapest sitting above it. Now
|
||||||
|
/// that its label is the single word "Closest", it belongs in the same column, in
|
||||||
|
/// the same capsule, at the same size.
|
||||||
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
|
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
|
||||||
|
|
||||||
extension FuelType {
|
extension FuelType {
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"Alerts" = "Alerts";
|
"Alerts" = "Alerts";
|
||||||
"Settings" = "Settings";
|
"Settings" = "Settings";
|
||||||
"FuelBoard" = "FuelBoard";
|
"FuelBoard" = "FuelBoard";
|
||||||
"TOP" = "TOP";
|
"Best value" = "Best value";
|
||||||
"BEST VALUE" = "BEST VALUE";
|
"Cheapest" = "Cheapest";
|
||||||
"CHEAPEST" = "CHEAPEST";
|
"Closest" = "Closest";
|
||||||
|
|
||||||
/* Stations tab */
|
/* Stations tab */
|
||||||
"Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations";
|
"Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations";
|
||||||
@@ -37,26 +37,24 @@
|
|||||||
"You’ll save money compared with your closest station, including travel costs." = "You’ll save money compared with your closest station, including travel costs.";
|
"You’ll save money compared with your closest station, including travel costs." = "You’ll save money compared with your closest station, including travel costs.";
|
||||||
"Break even" = "Break even";
|
"Break even" = "Break even";
|
||||||
"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" = "Costs";
|
||||||
"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";
|
|
||||||
"(approx.)" = "(approx.)";
|
"(approx.)" = "(approx.)";
|
||||||
"%@ more" = "%@ more";
|
"%@ more" = "%@ more";
|
||||||
"Station labels" = "Station labels";
|
"Station labels" = "Station labels";
|
||||||
"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.";
|
||||||
"The lowest fuel price per litre within your selected range." = "The lowest fuel price per litre within your selected range.";
|
"The lowest fuel price per litre within your selected range." = "The lowest fuel price per litre within your selected range.";
|
||||||
"The nearest station by journey distance." = "The nearest station by journey distance.";
|
"The closest station by journey distance." = "The closest station by journey distance.";
|
||||||
"The cheapest station in your Favourites for the selected fuel." = "The cheapest station in your Favourites for the selected fuel.";
|
|
||||||
"Star a station to save it to your Favourites." = "Star a station to save it to your Favourites.";
|
"Star a station to save it to your Favourites." = "Star a station to save it to your Favourites.";
|
||||||
"How does Best Value work?" = "How does Best Value work?";
|
"How does FuelBoard calculate value?" = "How does FuelBoard calculate value?";
|
||||||
|
/* Title of the explanation sheet the row above opens (a short form of it). */
|
||||||
|
"How value is calculated" = "How value is calculated";
|
||||||
|
"Value breakdown" = "Value breakdown";
|
||||||
/* Star button in the More info sheet heading (accessibility labels) */
|
/* Star button in the More info sheet heading (accessibility labels) */
|
||||||
"Add to favourites" = "Add to favourites";
|
"Add to favourites" = "Add to favourites";
|
||||||
"Remove from favourites" = "Remove from favourites";
|
"Remove from favourites" = "Remove from favourites";
|
||||||
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
|
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
|
||||||
"How Best Value works" = "How Best Value works";
|
|
||||||
"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." = "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.";
|
"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." = "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.";
|
||||||
"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." = "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.";
|
"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." = "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.";
|
||||||
"Your closest station by journey distance is the baseline — every other saving is measured against it." = "Your closest station by journey distance is the baseline — every other saving is measured against it.";
|
"Your closest station by journey distance is the baseline — every other saving is measured against it." = "Your closest station by journey distance is the baseline — every other saving is measured against it.";
|
||||||
|
|||||||
@@ -533,6 +533,6 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
)?.result
|
)?.result
|
||||||
XCTAssertEqual(result?.status, .baseline)
|
XCTAssertEqual(result?.status, .baseline)
|
||||||
XCTAssertEqual(result?.netSavingGBP, 0)
|
XCTAssertEqual(result?.netSavingGBP, 0)
|
||||||
XCTAssertEqual(result?.headline(), "Closest station")
|
XCTAssertEqual(result?.headline(), "Closest")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -47,7 +47,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
XCTAssertEqual(result(.breakEven, .confirmed).listingVerdict().lines, ["Break even"])
|
XCTAssertEqual(result(.breakEven, .confirmed).listingVerdict().lines, ["Break even"])
|
||||||
XCTAssertEqual(result(.breakEven, .estimated).listingVerdict().lines,
|
XCTAssertEqual(result(.breakEven, .estimated).listingVerdict().lines,
|
||||||
["Break even", "(approx.)"])
|
["Break even", "(approx.)"])
|
||||||
XCTAssertEqual(result(.baseline, .confirmed).listingVerdict().lines, ["Closest station"])
|
XCTAssertEqual(result(.baseline, .confirmed).listingVerdict().lines, ["Closest"])
|
||||||
|
|
||||||
// The Key sheet's legend demonstrates the wording, never a figure.
|
// The Key sheet's legend demonstrates the wording, never a figure.
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
@@ -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() {
|
||||||
@@ -450,8 +450,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(availability?.result?.status, .baseline)
|
XCTAssertEqual(availability?.result?.status, .baseline)
|
||||||
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station")
|
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest")
|
||||||
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station")
|
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest")
|
||||||
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
|
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
|
||||||
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
|
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
|
||||||
}
|
}
|
||||||
@@ -503,7 +503,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
|
|
||||||
/// The exact strings the brief asks for.
|
/// The exact strings the brief asks for.
|
||||||
func testHeadlineWordingMatchesTheBrief() {
|
func testHeadlineWordingMatchesTheBrief() {
|
||||||
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station")
|
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest")
|
||||||
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
|
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
|
||||||
|
|
||||||
let saving = FuelSavingsResult(fillLitres: 34.9,
|
let saving = FuelSavingsResult(fillLitres: 34.9,
|
||||||
@@ -594,7 +594,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The baseline capsule has to say what it IS: it previously repeated the
|
/// The baseline capsule has to say what it IS: it previously repeated the
|
||||||
/// status word above it ("Closest station" twice) and never explained that
|
/// status word above it ("Closest" twice) and never explained that
|
||||||
/// the savings are measured against it.
|
/// the savings are measured against it.
|
||||||
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
|
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
|
||||||
let baseline = FuelSavingsResult(
|
let baseline = FuelSavingsResult(
|
||||||
@@ -605,7 +605,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
netSavingGBP: 0,
|
netSavingGBP: 0,
|
||||||
status: .baseline
|
status: .baseline
|
||||||
)
|
)
|
||||||
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station")
|
XCTAssertEqual(baseline.capsuleHeadline(), "Closest")
|
||||||
|
|
||||||
let saving = FuelSavingsResult(
|
let saving = FuelSavingsResult(
|
||||||
fillLitres: 30,
|
fillLitres: 30,
|
||||||
|
|||||||
@@ -209,20 +209,82 @@ final class StationRecommendationBadgeTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A near-by station with no saving to speak of is context, not a
|
/// A station with no saving, not the cheapest and not the closest states
|
||||||
/// recommendation — the mockup gives NEAREST no capsule, and nothing else
|
/// nothing a badge could carry.
|
||||||
/// here may invent one.
|
func testNoSavingNotCheapestAndNotClosestMeansNoBadge() {
|
||||||
func testNoSavingAndNotCheapestMeansNoBadge() {
|
|
||||||
XCTAssertNil(
|
XCTAssertNil(
|
||||||
StationRecommendationBadge.badge(netSavingGBP: 0.0, isCheapest: false,
|
StationRecommendationBadge.badge(netSavingGBP: 0.0, isCheapest: false,
|
||||||
|
isClosest: false,
|
||||||
bestValueThresholdGBP: threshold)
|
bestValueThresholdGBP: threshold)
|
||||||
)
|
)
|
||||||
XCTAssertNil(
|
XCTAssertNil(
|
||||||
StationRecommendationBadge.badge(netSavingGBP: -2.50, isCheapest: false,
|
StationRecommendationBadge.badge(netSavingGBP: -2.50, isCheapest: false,
|
||||||
|
isClosest: false,
|
||||||
bestValueThresholdGBP: threshold)
|
bestValueThresholdGBP: threshold)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testTheClosestStationWearsClosestWhenNothingElseApplies() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: 0.0, isCheapest: false,
|
||||||
|
isClosest: true,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
.closest
|
||||||
|
)
|
||||||
|
// Closest is a position, so it does not need a savings figure to hold.
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: false,
|
||||||
|
isClosest: true,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
.closest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nearest forecourt is usually also the cheapest, so without the
|
||||||
|
/// ordering the Closest label would win by accident and hide the price news.
|
||||||
|
func testCheapestOutranksClosestWhenBothApply() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: 0.42, isCheapest: true,
|
||||||
|
isClosest: true,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
.cheapest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBestValueOutranksBothCheapestAndClosest() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: 3.15, isCheapest: true,
|
||||||
|
isClosest: true,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
.bestValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole ladder in one place, so a future case cannot slide in between
|
||||||
|
/// two rungs unnoticed: Best Value, then Cheapest, then Closest, then none.
|
||||||
|
func testLabelPrecedenceIsTotalAcrossEveryCombination() {
|
||||||
|
let cases: [(saving: Double, cheapest: Bool, closest: Bool,
|
||||||
|
badge: StationRecommendationBadge?)] = [
|
||||||
|
(3.15, true, true, .bestValue),
|
||||||
|
(3.15, false, true, .bestValue),
|
||||||
|
(3.15, false, false, .bestValue),
|
||||||
|
(0.42, true, true, .cheapest),
|
||||||
|
(0.42, true, false, .cheapest),
|
||||||
|
(0.42, false, true, .closest),
|
||||||
|
(0.42, false, false, nil)
|
||||||
|
]
|
||||||
|
for c in cases {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRecommendationBadge.badge(
|
||||||
|
netSavingGBP: c.saving, isCheapest: c.cheapest, isClosest: c.closest,
|
||||||
|
bestValueThresholdGBP: threshold
|
||||||
|
),
|
||||||
|
c.badge,
|
||||||
|
"saving \(c.saving), cheapest \(c.cheapest), closest \(c.closest)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testUnavailableSavingsLeaveOnlyTheCheapestBadge() {
|
func testUnavailableSavingsLeaveOnlyTheCheapestBadge() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: true,
|
StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: true,
|
||||||
@@ -271,25 +333,203 @@ final class KeySheetContentTests: XCTestCase {
|
|||||||
XCTAssertEqual(KeySheetContent.savings, [
|
XCTAssertEqual(KeySheetContent.savings, [
|
||||||
.init(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
|
.init(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
|
||||||
.init(label: "Break even", description: "The saving or extra cost is negligible."),
|
.init(label: "Break even", description: "The saving or extra cost is negligible."),
|
||||||
.init(label: "Costs more", description: "Filling up here costs more than at your closest station, including travel.")
|
// "Costs" — the listing capsule's own headline, not "Costs more".
|
||||||
|
.init(label: "Costs", description: "Filling up here costs more than at your closest station, including travel.")
|
||||||
])
|
])
|
||||||
XCTAssertFalse(KeySheetContent.savings.contains { $0.label == "Small saving" })
|
XCTAssertFalse(KeySheetContent.savings.contains { $0.label == "Small saving" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every Key label is sentence case: the capsules are read as phrases, and an
|
||||||
|
/// all-caps set reads as shouting beside the sentence-case copy around it.
|
||||||
|
func testEveryKeyLabelIsSentenceCase() {
|
||||||
|
let labels = (KeySheetContent.priceRatings
|
||||||
|
+ KeySheetContent.savings
|
||||||
|
+ KeySheetContent.stationLabels).map(\.label)
|
||||||
|
for label in labels {
|
||||||
|
XCTAssertNotEqual(label, label.uppercased(),
|
||||||
|
"\(label) must be sentence case, not all caps")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(KeySheetContent.stationLabels.map(\.label),
|
||||||
|
["Best value", "Cheapest", "Closest"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Key dispatches its indicators on `StationLabelKind`, so every kind must
|
||||||
|
/// have exactly one entry in the legend — and each entry's label must be the
|
||||||
|
/// kind's own, or the row would describe one label and draw another.
|
||||||
|
func testEveryStationLabelKindHasOneMatchingLegendEntry() {
|
||||||
|
XCTAssertEqual(StationLabelKind.allCases.count, KeySheetContent.stationLabels.count)
|
||||||
|
for kind in StationLabelKind.allCases {
|
||||||
|
let matches = KeySheetContent.stationLabels.filter { $0.label == kind.label }
|
||||||
|
XCTAssertEqual(matches.count, 1, "\(kind) must own exactly one legend entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recommended symbols, pinned so a rename cannot silently blank a badge.
|
||||||
|
func testStationLabelKindsUseTheirRecommendedSymbols() {
|
||||||
|
XCTAssertEqual(StationLabelKind.bestValue.symbol, "medal")
|
||||||
|
XCTAssertEqual(StationLabelKind.cheapest.symbol, "tag")
|
||||||
|
XCTAssertEqual(StationLabelKind.closest.symbol, "location")
|
||||||
|
// No trophy: "Top" is gone. One state, one label.
|
||||||
|
XCTAssertFalse(StationLabelKind.allCases.map(\.symbol).contains("trophy"))
|
||||||
|
}
|
||||||
|
|
||||||
func testStationLabelsDescribeTheCurrentQualificationAndRangeRules() {
|
func testStationLabelsDescribeTheCurrentQualificationAndRangeRules() {
|
||||||
XCTAssertEqual(KeySheetContent.stationLabels, [
|
XCTAssertEqual(KeySheetContent.stationLabels, [
|
||||||
.init(label: "BEST VALUE", description: "Meets your minimum saving after travel costs."),
|
.init(label: "Best value", description: "Meets your minimum saving after travel costs."),
|
||||||
.init(label: "CHEAPEST", description: "The lowest fuel price per litre within your selected range."),
|
.init(label: "Cheapest", description: "The lowest fuel price per litre within your selected range."),
|
||||||
.init(label: "CLOSEST", description: "The nearest station by journey distance."),
|
.init(label: "Closest", description: "The closest station by journey distance.")
|
||||||
.init(label: "TOP", description: "The cheapest station in your Favourites for the selected fuel.")
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Top" is retired. It and "Cheapest" were two words for the same flag, and the
|
||||||
|
/// app drifted between them by device: the phone's rows said Cheapest while the
|
||||||
|
/// watch said Top for the identical state. Nothing may reintroduce the synonym.
|
||||||
|
func testTopLabelIsRetired() {
|
||||||
|
XCTAssertFalse(StationLabelKind.allCases.contains { $0.label == "Top" })
|
||||||
|
XCTAssertFalse(KeySheetContent.stationLabels.contains { $0.label == "Top" })
|
||||||
|
// The concept is not lost — it is called Cheapest, which is also the word
|
||||||
|
// every piece of favourites copy uses.
|
||||||
|
XCTAssertTrue(StationLabelKind.allCases.contains { $0.label == "Cheapest" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The closest label is one word everywhere: the badge, the verdict capsule the
|
||||||
|
/// baseline station wears, and the Key all read it from `baselineHeadline`.
|
||||||
|
func testClosestLabelIsTheSameWordEverywhere() {
|
||||||
|
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest")
|
||||||
|
XCTAssertEqual(StationLabelKind.closest.label, "Closest")
|
||||||
|
let baseline = FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 0,
|
||||||
|
extraDrivingCostGBP: 0,
|
||||||
|
extraDistanceMiles: 0,
|
||||||
|
netSavingGBP: 0,
|
||||||
|
status: .baseline,
|
||||||
|
journeyConfidence: .confirmed
|
||||||
|
)
|
||||||
|
XCTAssertEqual(baseline.headline(), "Closest")
|
||||||
|
XCTAssertEqual(baseline.headline(), StationLabelKind.closest.label)
|
||||||
|
}
|
||||||
|
|
||||||
func testFavouriteAndHelpCopyRemainPinned() {
|
func testFavouriteAndHelpCopyRemainPinned() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
KeySheetContent.favourite,
|
KeySheetContent.favourite,
|
||||||
.init(label: "Favourites", description: "Star a station to save it to your Favourites.")
|
.init(label: "Favourites", description: "Star a station to save it to your Favourites.")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(KeySheetContent.helpTitle, "How does Best Value work?")
|
// The link names the calculation, not the feature: the sheet explains the
|
||||||
|
// fill-up, the return trip and the total, of which the Best Value gate is
|
||||||
|
// only the last step.
|
||||||
|
XCTAssertEqual(KeySheetContent.helpTitle, "How does FuelBoard calculate value?")
|
||||||
|
// The sheet title is a short form of the question, NOT the question itself:
|
||||||
|
// a nav title clamps to the bar, and the full question truncated to
|
||||||
|
// "How does FuelBoard…" at both display modes.
|
||||||
|
XCTAssertEqual(KeySheetContent.helpSheetTitle, "How value is calculated")
|
||||||
|
XCTAssertLessThan(KeySheetContent.helpSheetTitle.count, KeySheetContent.helpTitle.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The More info sheet's ranking capsules. Every rule must come from the shared
|
||||||
|
/// badge logic — the sheet may show more labels than a row (which shows one), but
|
||||||
|
/// it must not invent a qualification of its own.
|
||||||
|
final class StationRankingTests: XCTestCase {
|
||||||
|
private let threshold = 2.50
|
||||||
|
|
||||||
|
func testNoQualificationProducesNoCapsules() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRanking.kinds(netSavingGBP: 0.40, isCheapestInRange: false,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBestValueUsesTheSameGateAsTheListingBadge() {
|
||||||
|
// Exactly at the threshold qualifies, matching StationRecommendationBadge.
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRanking.kinds(netSavingGBP: threshold, isCheapestInRange: false,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
[.bestValue]
|
||||||
|
)
|
||||||
|
// A hair under does not.
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRanking.kinds(netSavingGBP: threshold - 0.01, isCheapestInRange: false,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheapest is the only ranking label besides Best value. Whichever tab opened
|
||||||
|
/// the sheet — Stations or Favourites — it means the lowest price among the
|
||||||
|
/// stations on that screen, so there is no second cheapest-flavoured capsule.
|
||||||
|
func testCheapestIsTheOnlyCheapestFlavouredLabel() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: true,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
[.cheapest]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: false,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapsulesAreOrderedBestValueThenCheapest() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRanking.kinds(netSavingGBP: 9.99, isCheapestInRange: true,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
[.bestValue, .cheapest]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sheet has room for every qualification, so a station holding all three
|
||||||
|
/// shows all three — in the Key's order, which is also the row's precedence,
|
||||||
|
/// so the sheet reads left-to-right the same way a row resolves top-to-bottom.
|
||||||
|
func testCapsulesAreOrderedBestValueCheapestClosest() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRanking.kinds(netSavingGBP: 9.99, isCheapestInRange: true,
|
||||||
|
isClosestInRange: true, bestValueThresholdGBP: threshold),
|
||||||
|
[.bestValue, .cheapest, .closest]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The closest station with no saving and no price advantage still earns its
|
||||||
|
/// label on the sheet — the one case a listing row cannot show, because there
|
||||||
|
/// Best Value and Cheapest are both unavailable and Closest wins the slot.
|
||||||
|
func testClosestAloneEarnsOneCapsule() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: false,
|
||||||
|
isClosestInRange: true, bestValueThresholdGBP: threshold),
|
||||||
|
[.closest]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRanking.kinds(netSavingGBP: 0.0, isCheapestInRange: false,
|
||||||
|
isClosestInRange: true, bestValueThresholdGBP: threshold),
|
||||||
|
[.closest]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closest must not sneak in when the host did not flag it — the sheet never
|
||||||
|
/// invents a rule of its own.
|
||||||
|
func testNoClosestFlagMeansNoClosestCapsule() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: true,
|
||||||
|
isClosestInRange: false, bestValueThresholdGBP: threshold),
|
||||||
|
[.cheapest]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row shows ONE badge; the sheet may show several. Both must agree on the
|
||||||
|
/// Best Value decision, or the sheet would claim a label the list behind it
|
||||||
|
/// withheld.
|
||||||
|
func testSheetAgreesWithTheRowOnBestValue() {
|
||||||
|
for netSaving in [-3.0, 0.0, 2.49, 2.50, 12.0, .nan] as [Double] {
|
||||||
|
let rowSaysBestValue = StationRecommendationBadge.badge(
|
||||||
|
netSavingGBP: netSaving, isCheapest: false, bestValueThresholdGBP: threshold
|
||||||
|
) == .bestValue
|
||||||
|
let sheetSaysBestValue = StationRanking.kinds(
|
||||||
|
netSavingGBP: netSaving, isCheapestInRange: false,
|
||||||
|
bestValueThresholdGBP: threshold
|
||||||
|
).contains(.bestValue)
|
||||||
|
XCTAssertEqual(rowSaysBestValue, sheetSaysBestValue, "disagreed on net saving \(netSaving)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -516,8 +516,12 @@ struct ContentView: View {
|
|||||||
.fontWeight(favourite.isCheapest ? .semibold : .regular)
|
.fontWeight(favourite.isCheapest ? .semibold : .regular)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if favourite.isCheapest {
|
if favourite.isCheapest {
|
||||||
Text("TOP")
|
// The same WORD as every other surface, not just the same
|
||||||
.font(.system(size: 9, weight: .bold))
|
// capitalisation. The phone's rows call this state
|
||||||
|
// "Cheapest"; a watch that said "Top" made one state look
|
||||||
|
// like two different achievements.
|
||||||
|
Text("Cheapest")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(fuel.color.opacity(0.18), in: Capsule())
|
.background(fuel.color.opacity(0.18), in: Capsule())
|
||||||
|
|||||||
+147
-40
@@ -45,7 +45,7 @@ enum FuelSavingsStatus: String, Codable, Equatable {
|
|||||||
case breakEven
|
case breakEven
|
||||||
case notWorthTheDrive
|
case notWorthTheDrive
|
||||||
/// The comparison baseline itself — the station every other saving is
|
/// The comparison baseline itself — the station every other saving is
|
||||||
/// measured against. Shown as "Closest station", never "Break even".
|
/// measured against. Shown as "Closest", never "Break even".
|
||||||
case baseline
|
case baseline
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
@@ -187,8 +187,13 @@ enum FuelSavingsFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension FuelSavingsResult {
|
extension FuelSavingsResult {
|
||||||
/// Baseline row label — required wording.
|
/// The baseline's capsule label.
|
||||||
static let baselineHeadline = "Closest station"
|
///
|
||||||
|
/// "Closest", not "Closest station": it is a station LABEL sitting alongside
|
||||||
|
/// "Best value" and "Cheapest", and the noun is implied by the row it is on.
|
||||||
|
/// This is also the ONE source for the word, so the closest station's badge and
|
||||||
|
/// its verdict capsule cannot word it differently.
|
||||||
|
static let baselineHeadline = "Closest"
|
||||||
/// Neutral result label — required wording.
|
/// Neutral result label — required wording.
|
||||||
static let breakEvenHeadline = "Break even"
|
static let breakEvenHeadline = "Break even"
|
||||||
|
|
||||||
@@ -209,7 +214,7 @@ extension FuelSavingsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
|
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
|
||||||
/// travel", "Break even", "Closest station".
|
/// travel", "Break even", "Closest".
|
||||||
///
|
///
|
||||||
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
|
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
|
||||||
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
|
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
|
||||||
@@ -271,7 +276,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".
|
||||||
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 +298,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
|
||||||
)
|
)
|
||||||
@@ -364,41 +369,98 @@ struct FuelSavingsRankedStation: Equatable {
|
|||||||
let pricePencePerLitre: Double
|
let pricePencePerLitre: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user-facing copy shown by the Stations tab's Key sheet. Keeping the
|
/// The user-facing copy shown by the Stations tab's Key sheet. Keeping the
|
||||||
/// descriptions in one Foundation-only model lets tests pin the exact meaning of
|
/// descriptions in one Foundation-only model lets tests pin the exact meaning of
|
||||||
/// each visual indicator without coupling the wording to SwiftUI layout.
|
/// each visual indicator without coupling the wording to SwiftUI layout.
|
||||||
enum KeySheetContent {
|
///
|
||||||
struct Item: Equatable {
|
/// Every label is SENTENCE CASE ("Best value", not "BEST VALUE"): the labels
|
||||||
let label: String
|
/// are read as phrases, and a capsule set in all caps reads as shouting beside
|
||||||
let description: String
|
/// the sentence-case copy in the rows around it. The tests pin these exact
|
||||||
|
/// strings, and `StationLabelKind` maps each one to its indicator, so the view
|
||||||
|
/// never matches on the literal text.
|
||||||
|
enum KeySheetContent {
|
||||||
|
struct Item: Equatable {
|
||||||
|
let label: String
|
||||||
|
let description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static let priceRatings = [
|
||||||
|
Item(label: "Good price", description: "Within 1.5p/L of the cheapest."),
|
||||||
|
Item(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
|
||||||
|
Item(label: "Pricey", description: "More than 4p/L above the cheapest.")
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The same three verdicts the listing capsule draws, so the legend and the
|
||||||
|
/// rows cannot disagree on a word. "Costs" is the capsule's own headline.
|
||||||
|
static let savings = [
|
||||||
|
Item(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
|
||||||
|
Item(label: "Break even", description: "The saving or extra cost is negligible."),
|
||||||
|
Item(label: "Costs", description: "Filling up here costs more than at your closest station, including travel.")
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The station labels, in the order the Key lists them.
|
||||||
|
///
|
||||||
|
/// There is no "Top" entry. Top and Cheapest were two words for one state —
|
||||||
|
/// both driven by the same "cheapest among the stations you are looking at"
|
||||||
|
/// flag, differing only in whether the candidate set was the radius or the
|
||||||
|
/// Favourites — and which word a surface used drifted by device. Cheapest is
|
||||||
|
/// the survivor: it says what it measures, and it is already the word every
|
||||||
|
/// piece of favourites copy uses ("your cheapest E10 favourite", "Cheapest
|
||||||
|
/// favourite" alert). The concept survives; the synonym does not.
|
||||||
|
static let stationLabels = [
|
||||||
|
Item(label: "Best value", description: "Meets your minimum saving after travel costs."),
|
||||||
|
Item(label: "Cheapest", description: "The lowest fuel price per litre within your selected range."),
|
||||||
|
Item(label: "Closest", description: "The closest station by journey distance.")
|
||||||
|
]
|
||||||
|
|
||||||
|
static let favourite = Item(
|
||||||
|
label: "Favourites",
|
||||||
|
description: "Star a station to save it to your Favourites."
|
||||||
|
)
|
||||||
|
|
||||||
|
/// The link that opens the explanation sheet. Names the question the sheet
|
||||||
|
/// answers rather than the feature ("How does Best Value work?"), because the
|
||||||
|
/// sheet explains the whole calculation — fill-up, return trip, extra travel,
|
||||||
|
/// total — not only the Best Value gate at the end of it.
|
||||||
|
static let helpTitle = "How does FuelBoard calculate value?"
|
||||||
|
/// The explanation sheet's own title: a SHORT form of the same question, not
|
||||||
|
/// the link's words. A navigation title is clamped to the bar, and the full
|
||||||
|
/// question truncated to "How does FuelBoard…" in BOTH inline and large
|
||||||
|
/// display modes — so the row carries the full question and the sheet it opens
|
||||||
|
/// names the same topic in words that render in full.
|
||||||
|
static let helpSheetTitle = "How value is calculated"
|
||||||
}
|
}
|
||||||
|
|
||||||
static let priceRatings = [
|
/// A station label's meaning, independent of its copy.
|
||||||
Item(label: "Good price", description: "Within 1.5p/L of the cheapest."),
|
///
|
||||||
Item(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
|
/// The Key sheet used to dispatch its indicator on the label's literal text
|
||||||
Item(label: "Pricey", description: "More than 4p/L above the cheapest.")
|
/// (`case "BEST VALUE":`), so renaming a label silently dropped that row to the
|
||||||
]
|
/// fallback indicator. The enum makes the mapping explicit and lets the
|
||||||
|
/// compiler catch a missing case.
|
||||||
|
enum StationLabelKind: String, CaseIterable {
|
||||||
|
case bestValue
|
||||||
|
case cheapest
|
||||||
|
case closest
|
||||||
|
|
||||||
static let savings = [
|
/// Matches the corresponding `KeySheetContent.stationLabels` entry.
|
||||||
Item(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
|
var label: String {
|
||||||
Item(label: "Break even", description: "The saving or extra cost is negligible."),
|
switch self {
|
||||||
Item(label: "Costs more", description: "Filling up here costs more than at your closest station, including travel.")
|
case .bestValue: return String(localized: "Best value")
|
||||||
]
|
case .cheapest: return String(localized: "Cheapest")
|
||||||
|
// "Closest", from the one shared constant, so the badge, the verdict
|
||||||
|
// capsule and the Key cannot word the same label three ways.
|
||||||
|
case .closest: return FuelSavingsResult.baselineHeadline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static let stationLabels = [
|
var symbol: String {
|
||||||
Item(label: "BEST VALUE", description: "Meets your minimum saving after travel costs."),
|
switch self {
|
||||||
Item(label: "CHEAPEST", description: "The lowest fuel price per litre within your selected range."),
|
case .bestValue: return "medal"
|
||||||
Item(label: "CLOSEST", description: "The nearest station by journey distance."),
|
case .cheapest: return "tag"
|
||||||
Item(label: "TOP", description: "The cheapest station in your Favourites for the selected fuel.")
|
case .closest: return "location"
|
||||||
]
|
}
|
||||||
|
}
|
||||||
static let favourite = Item(
|
}
|
||||||
label: "Favourites",
|
|
||||||
description: "Star a station to save it to your Favourites."
|
|
||||||
)
|
|
||||||
|
|
||||||
static let helpTitle = "How does Best Value work?"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which recommendation badge a station listing row shows, if any.
|
/// Which recommendation badge a station listing row shows, if any.
|
||||||
///
|
///
|
||||||
@@ -406,20 +468,65 @@ enum KeySheetContent {
|
|||||||
/// app's existing Best Value gate (the user's alert threshold, the same figure
|
/// app's existing Best Value gate (the user's alert threshold, the same figure
|
||||||
/// `FuelSavingsCalculator.bestValueAlertCandidate` is given) and CHEAPEST
|
/// `FuelSavingsCalculator.bestValueAlertCandidate` is given) and CHEAPEST
|
||||||
/// reuses the caller's existing "cheapest in range" flag (the station behind
|
/// reuses the caller's existing "cheapest in range" flag (the station behind
|
||||||
/// `topStationID`). One badge per row: a station that is both the cheapest and
|
/// `cheapestStationID`). One badge per row: a station that is both the cheapest
|
||||||
/// worth the drive shows BEST VALUE, the stronger of the two.
|
/// and worth the drive shows BEST VALUE, the stronger of the two.
|
||||||
enum StationRecommendationBadge: Equatable {
|
enum StationRecommendationBadge: Equatable {
|
||||||
case bestValue
|
case bestValue
|
||||||
case cheapest
|
case cheapest
|
||||||
|
case closest
|
||||||
|
|
||||||
|
/// One label per row, in this order: Best Value, then Cheapest, then Closest.
|
||||||
|
///
|
||||||
|
/// A station can hold all three at once — the nearest forecourt is often also
|
||||||
|
/// the cheapest — and a row has room for one, so the order is decided here
|
||||||
|
/// rather than left to each caller. Being nearby is the weakest claim: a short
|
||||||
|
/// drive says nothing about the price, and unlike the other two it reports no
|
||||||
|
/// saving at all. Closest therefore surfaces only when the row earns neither
|
||||||
|
/// of the others.
|
||||||
static func badge(netSavingGBP: Double?,
|
static func badge(netSavingGBP: Double?,
|
||||||
isCheapest: Bool,
|
isCheapest: Bool,
|
||||||
|
isClosest: Bool = false,
|
||||||
bestValueThresholdGBP: Double) -> StationRecommendationBadge? {
|
bestValueThresholdGBP: Double) -> StationRecommendationBadge? {
|
||||||
if let netSavingGBP, netSavingGBP.isFinite, bestValueThresholdGBP.isFinite,
|
if let netSavingGBP, netSavingGBP.isFinite, bestValueThresholdGBP.isFinite,
|
||||||
netSavingGBP >= bestValueThresholdGBP {
|
netSavingGBP >= bestValueThresholdGBP {
|
||||||
return .bestValue
|
return .bestValue
|
||||||
}
|
}
|
||||||
return isCheapest ? .cheapest : nil
|
if isCheapest { return .cheapest }
|
||||||
|
return isClosest ? .closest : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every ranking label a station qualifies for, in display order.
|
||||||
|
///
|
||||||
|
/// A listing row shows ONE badge (Best Value outranks Cheapest outranks Closest,
|
||||||
|
/// because a row has room for one). The More info sheet has room for all of them,
|
||||||
|
/// so it shows each qualification side by side — but it must never invent a rule
|
||||||
|
/// of its own, so this defers to `StationRecommendationBadge.badge` for the Best
|
||||||
|
/// Value gate and takes Cheapest and Closest as the SAME flags the host list
|
||||||
|
/// gives its rows.
|
||||||
|
///
|
||||||
|
/// There is no Top label any more: both tabs mean the same thing by "cheapest" —
|
||||||
|
/// the lowest price among the stations on screen, whether that set is the radius or
|
||||||
|
/// the Favourites — so a host passes one flag and the sheet renders one capsule.
|
||||||
|
enum StationRanking {
|
||||||
|
static func kinds(netSavingGBP: Double?,
|
||||||
|
isCheapestInRange: Bool,
|
||||||
|
isClosestInRange: Bool = false,
|
||||||
|
bestValueThresholdGBP: Double) -> [StationLabelKind] {
|
||||||
|
var kinds: [StationLabelKind] = []
|
||||||
|
// The same gate the listing row uses, asked without a cheapest flag so the
|
||||||
|
// ranking is decided only by the saving.
|
||||||
|
if StationRecommendationBadge.badge(
|
||||||
|
netSavingGBP: netSavingGBP,
|
||||||
|
isCheapest: false,
|
||||||
|
isClosest: false,
|
||||||
|
bestValueThresholdGBP: bestValueThresholdGBP
|
||||||
|
) == .bestValue {
|
||||||
|
kinds.append(.bestValue)
|
||||||
|
}
|
||||||
|
if isCheapestInRange { kinds.append(.cheapest) }
|
||||||
|
if isClosestInRange { kinds.append(.closest) }
|
||||||
|
return kinds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,8 +245,8 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
|
|||||||
// MARK: - Station tap action
|
// MARK: - Station tap action
|
||||||
|
|
||||||
/// What happens when a station row is tapped in the Stations/Favourites tabs:
|
/// What happens when a station row is tapped in the Stations/Favourites tabs:
|
||||||
/// open Apple Maps directions immediately (historic behaviour, default), or
|
/// open Apple Maps directions immediately, or show a detail sheet (name,
|
||||||
/// show a detail sheet (name, address, distance, Directions button).
|
/// address, distance, Directions button) — the default.
|
||||||
enum StationTapAction: String, Codable, CaseIterable, Identifiable {
|
enum StationTapAction: String, Codable, CaseIterable, Identifiable {
|
||||||
case openMap // tap → Apple Maps directions straight away
|
case openMap // tap → Apple Maps directions straight away
|
||||||
case showDetails // tap → detail sheet, Directions button inside
|
case showDetails // tap → detail sheet, Directions button inside
|
||||||
@@ -801,8 +801,9 @@ struct FuelStore {
|
|||||||
saveString(style.rawValue, service: priceDisplayStyleKey)
|
saveString(style.rawValue, service: priceDisplayStyleKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Station tap action — open map vs detail sheet. Stored raw value;
|
// MARK: Station tap action — detail sheet vs open map. Stored raw value;
|
||||||
// default open map preserves the historic tap behaviour for existing installs.
|
// the default is the detail sheet, so a tap always lands somewhere the app
|
||||||
|
// can come back from. Directions still one tap away, inside the sheet.
|
||||||
|
|
||||||
static let stationTapActionKey = "fuelboard.stationTapAction"
|
static let stationTapActionKey = "fuelboard.stationTapAction"
|
||||||
|
|
||||||
@@ -810,7 +811,7 @@ struct FuelStore {
|
|||||||
if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) {
|
if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) {
|
||||||
return action
|
return action
|
||||||
}
|
}
|
||||||
return .openMap
|
return .showDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveStationTapAction(_ action: StationTapAction) {
|
static func saveStationTapAction(_ action: StationTapAction) {
|
||||||
|
|||||||
Reference in New Issue
Block a user