Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9e71f7ac | ||
|
|
fcb11a8f52 | ||
|
|
ab93f6286a | ||
|
|
6edab35400 | ||
|
|
244f9be640 | ||
|
|
c46a7748e6 | ||
|
|
6cbb083208 | ||
|
|
c331f3d663 |
+18
@@ -106,6 +106,24 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
scene (CPListTemplate) for navigation deep-links.
|
scene (CPListTemplate) for navigation deep-links.
|
||||||
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
|
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
|
||||||
user-configurable (currently fixed 300 m trigger / 1 per station per hour).
|
user-configurable (currently fixed 300 m trigger / 1 per station per hour).
|
||||||
|
- [ ] **Restore Best Value proximity alerts after fixing evaluation parity** —
|
||||||
|
the option is intentionally removed from the Alerts tab and legacy selections
|
||||||
|
fall back to `Cheapest station` until this is implemented. Root cause: the live
|
||||||
|
geofence path calculates journeys from the entered station, making that station
|
||||||
|
its own zero-saving baseline and therefore unable to clear any positive minimum
|
||||||
|
saving, while Test alert calculates from the user's location and can pass.
|
||||||
|
Future implementation must obtain a fresh user fix on region entry; fetch,
|
||||||
|
radius-filter, choose the closest eligible baseline and calculate every journey
|
||||||
|
from that user location; then notify only when the entered station is the best
|
||||||
|
qualifying candidate. Real and Test alerts must call one shared pure evaluator.
|
||||||
|
Apply one disclosed qualification rule everywhere (real alert, Test alert and
|
||||||
|
BEST VALUE badge): remove the live path's hidden extra £0.50 estimate margin,
|
||||||
|
compare the same penny-rounded amount shown to the user while retaining raw
|
||||||
|
precision for ranking, and pass the current threshold explicitly into station
|
||||||
|
rows instead of reading persistence per row. Regression coverage: entered
|
||||||
|
station can qualify, a better nearby candidate blocks it, real/Test parity,
|
||||||
|
exact and display-rounding boundaries, estimated/confirmed parity, immediate
|
||||||
|
threshold changes, and below-threshold rejection.
|
||||||
- [ ] **Price-drop alerts: threshold control + Check now** — keep the current
|
- [ ] **Price-drop alerts: threshold control + Check now** — keep the current
|
||||||
cache-first / 12 h refresh policy, but add a per-alert sensitivity control
|
cache-first / 12 h refresh policy, but add a per-alert sensitivity control
|
||||||
for favourite price-drop alerts instead of a fetch-frequency slider. Scope:
|
for favourite price-drop alerts instead of a fetch-frequency slider. Scope:
|
||||||
|
|||||||
@@ -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 = 28;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -599,7 +599,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 28;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -663,7 +663,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 28;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -728,7 +728,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 28;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -826,7 +826,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 28;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -851,7 +851,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 28;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -875,7 +875,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 28;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
@@ -900,7 +900,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 28;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
|
|||||||
+10
-50
@@ -6,18 +6,12 @@ import SwiftUI
|
|||||||
/// versa). The Live Activity section also lives here — it mirrors this page's
|
/// versa). The Live Activity section also lives here — it mirrors this page's
|
||||||
/// shape (toggle + fuel + distance) so the two "notify me while driving"
|
/// shape (toggle + fuel + distance) so the two "notify me while driving"
|
||||||
/// surfaces sit together. Geofence plumbing (count, 18-limit) lives in
|
/// surfaces sit together. Geofence plumbing (count, 18-limit) lives in
|
||||||
/// Settings → Debug; the user-facing page keeps short descriptions that follow
|
/// Settings → Debug; the user-facing page keeps short descriptions of the
|
||||||
/// the selected alert type and radius, plus a 0-stations hint.
|
/// cheapest-station rule and radius, plus a 0-stations hint.
|
||||||
struct AlertsView: View {
|
struct AlertsView: View {
|
||||||
@Binding var enabled: Bool
|
@Binding var enabled: Bool
|
||||||
@Binding var radius: Double // stored in km (monitor + storage)
|
@Binding var radius: Double // stored in km (monitor + storage)
|
||||||
@Binding var fuel: FuelType // the fuel alerts monitor for
|
@Binding var fuel: FuelType // the fuel alerts monitor for
|
||||||
@Binding var alertMode: AlertMode
|
|
||||||
/// Read-only here: the threshold is edited in Settings → Fuel Savings, where
|
|
||||||
/// the fill size and MPG that decide the saving live. Read back so the
|
|
||||||
/// alert's own rule is visible without leaving the tab.
|
|
||||||
let bestValueThresholdGBP: Double
|
|
||||||
let bestValueConfigured: Bool
|
|
||||||
@Binding var favouriteDropEnabled: Bool
|
@Binding var favouriteDropEnabled: Bool
|
||||||
@Binding var favouriteDropFuel: FuelType
|
@Binding var favouriteDropFuel: FuelType
|
||||||
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
||||||
@@ -61,17 +55,6 @@ struct AlertsView: View {
|
|||||||
Int((FuelStore.alertFollowCapKM / FuelSavingsCalculator.kilometresPerMile).rounded())
|
Int((FuelStore.alertFollowCapKM / FuelSavingsCalculator.kilometresPerMile).rounded())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the selected alert type actually does. Swapped with the picker so
|
|
||||||
/// the copy can never describe the wrong mode.
|
|
||||||
private var alertModeDescription: String {
|
|
||||||
switch alertMode {
|
|
||||||
case .cheapest:
|
|
||||||
return String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description")
|
|
||||||
case .bestValue:
|
|
||||||
return String(localized: "Get notified when you're near a station that could save you money after travel costs.", comment: "Best Value proximity alert description")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The radius the selected option really monitors, stated in the user's
|
/// The radius the selected option really monitors, stated in the user's
|
||||||
/// unit — e.g. "Get alerts when you're within 2 miles of a matching
|
/// unit — e.g. "Get alerts when you're within 2 miles of a matching
|
||||||
/// station." "Follow search" mirrors the Stations-tab distance, which the
|
/// station." "Follow search" mirrors the Stations-tab distance, which the
|
||||||
@@ -212,41 +195,18 @@ struct AlertsView: View {
|
|||||||
Section {
|
Section {
|
||||||
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
||||||
Group {
|
Group {
|
||||||
optionRow(String(localized: "Alert type", comment: "Proximity alert type picker title"),
|
Text(String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description"))
|
||||||
value: alertMode.displayName,
|
|
||||||
isEnabled: enabled) {
|
|
||||||
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
|
|
||||||
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in
|
|
||||||
Text(mode.displayName).tag(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !bestValueConfigured {
|
|
||||||
Text(String(localized: "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.", comment: "Best Value alert unavailable hint"))
|
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
||||||
Text(alertModeDescription)
|
value: fuel.displayName,
|
||||||
.font(.footnote)
|
isEnabled: enabled) {
|
||||||
.foregroundStyle(.secondary)
|
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
|
||||||
if alertMode == .bestValue {
|
ForEach(FuelType.allCases) { fuel in
|
||||||
// The threshold is edited in Settings → Fuel Savings: it
|
Text(fuel.displayName).tag(fuel)
|
||||||
// labels station rows BEST VALUE as well as gating this
|
}
|
||||||
// alert, so it lives with the fill size and MPG that
|
|
||||||
// produce the saving. Read back here, not offered twice.
|
|
||||||
Text(String(format: String(localized: "Minimum saving %@ — change in Settings, Fuel Savings.", comment: "Best Value alert threshold read-back"), FuelSavingsFormat.money(bestValueThresholdGBP)))
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
|
||||||
value: fuel.displayName,
|
|
||||||
isEnabled: enabled) {
|
|
||||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
|
|
||||||
ForEach(FuelType.allCases) { fuel in
|
|
||||||
Text(fuel.displayName).tag(fuel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
optionRow(String(localized: "Radius", comment: "Alert radius picker title"),
|
optionRow(String(localized: "Radius", comment: "Alert radius picker title"),
|
||||||
value: alertRadiusValueLabel,
|
value: alertRadiusValueLabel,
|
||||||
isEnabled: enabled) {
|
isEnabled: enabled) {
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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 closestStation = CapsuleTone(
|
||||||
|
lightForeground: Color(hex: 0x525967), lightBackground: Color(hex: 0xE8EAF0),
|
||||||
|
darkForeground: Color(hex: 0xC0C3CC), darkBackground: Color(hex: 0x424349)
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Blue, like `cheapest`: the top favourite IS the cheapest favourite, so it
|
||||||
|
/// must not read as a different class of thing.
|
||||||
|
static let top = CapsuleTone(
|
||||||
|
lightForeground: Color(hex: 0x225F9B), lightBackground: Color(hex: 0xE0EFFB),
|
||||||
|
darkForeground: Color(hex: 0x8BC5FA), darkBackground: Color(hex: 0x293F53)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 — "Closest station" is the case that
|
||||||
|
/// forced this: one declared line that wraps to two in the listing's price
|
||||||
|
/// column.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-83
@@ -37,7 +37,6 @@ struct ContentView: View {
|
|||||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||||
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
|
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
|
||||||
@State private var alertsMode: AlertMode = FuelStore.loadAlertsMode()
|
|
||||||
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
||||||
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
|
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
|
||||||
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
|
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
|
||||||
@@ -152,23 +151,13 @@ struct ContentView: View {
|
|||||||
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var effectiveAlertsMode: AlertMode {
|
|
||||||
(alertsMode == .bestValue && !fuelSavingsSettings.isConfigured) ? .cheapest : alertsMode
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ensureBestValueAlertModeIsAvailable() {
|
|
||||||
guard alertsMode == .bestValue, !fuelSavingsSettings.isConfigured else { return }
|
|
||||||
alertsMode = .cheapest
|
|
||||||
FuelStore.saveAlertsMode(.cheapest)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) {
|
private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) {
|
||||||
monitor.update(
|
monitor.update(
|
||||||
stations: stations,
|
stations: stations,
|
||||||
favourites: refreshedFavourites,
|
favourites: refreshedFavourites,
|
||||||
fuel: overrideFuel ?? alertsFuel,
|
fuel: overrideFuel ?? alertsFuel,
|
||||||
radiusKM: effectiveAlertsRadiusKM,
|
radiusKM: effectiveAlertsRadiusKM,
|
||||||
alertMode: effectiveAlertsMode,
|
alertMode: .cheapest,
|
||||||
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
||||||
fuelSavingsSettings: fuelSavingsSettings,
|
fuelSavingsSettings: fuelSavingsSettings,
|
||||||
fuelSavingsAmount: sessionFuelSavingsAmount
|
fuelSavingsAmount: sessionFuelSavingsAmount
|
||||||
@@ -1154,7 +1143,6 @@ struct ContentView: View {
|
|||||||
// or the choice wouldn't take effect until the next launch.
|
// or the choice wouldn't take effect until the next launch.
|
||||||
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||||
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
||||||
ensureBestValueAlertModeIsAvailable()
|
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
installLocationUpdateHook()
|
installLocationUpdateHook()
|
||||||
ensureBackgroundMotionTrackingIfNeeded()
|
ensureBackgroundMotionTrackingIfNeeded()
|
||||||
@@ -1174,7 +1162,6 @@ struct ContentView: View {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
|
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
|
||||||
#endif
|
#endif
|
||||||
ensureBestValueAlertModeIsAvailable()
|
|
||||||
updateAlertMonitor()
|
updateAlertMonitor()
|
||||||
}
|
}
|
||||||
.onChange(of: sessionFuelSavingsAmount) { _, _ in
|
.onChange(of: sessionFuelSavingsAmount) { _, _ in
|
||||||
@@ -1257,16 +1244,6 @@ struct ContentView: View {
|
|||||||
FuelStore.saveAlertsFuel(newValue)
|
FuelStore.saveAlertsFuel(newValue)
|
||||||
updateAlertMonitor(fuel: newValue)
|
updateAlertMonitor(fuel: newValue)
|
||||||
}
|
}
|
||||||
.onChange(of: alertsMode) { _, newValue in
|
|
||||||
if newValue == .bestValue, !fuelSavingsSettings.isConfigured {
|
|
||||||
alertsMode = .cheapest
|
|
||||||
FuelStore.saveAlertsMode(.cheapest)
|
|
||||||
updateAlertMonitor()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
FuelStore.saveAlertsMode(newValue)
|
|
||||||
updateAlertMonitor()
|
|
||||||
}
|
|
||||||
.onChange(of: alertsBestValueThresholdGBP) { _, newValue in
|
.onChange(of: alertsBestValueThresholdGBP) { _, newValue in
|
||||||
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
|
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
|
||||||
updateAlertMonitor()
|
updateAlertMonitor()
|
||||||
@@ -1609,9 +1586,6 @@ struct ContentView: View {
|
|||||||
enabled: $alertsEnabled,
|
enabled: $alertsEnabled,
|
||||||
radius: $alertsRadius,
|
radius: $alertsRadius,
|
||||||
fuel: $alertsFuel,
|
fuel: $alertsFuel,
|
||||||
alertMode: $alertsMode,
|
|
||||||
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
|
||||||
bestValueConfigured: fuelSavingsSettings.isConfigured,
|
|
||||||
favouriteDropEnabled: $favouriteDropAlertsEnabled,
|
favouriteDropEnabled: $favouriteDropAlertsEnabled,
|
||||||
favouriteDropFuel: $favouriteDropAlertsFuel,
|
favouriteDropFuel: $favouriteDropAlertsFuel,
|
||||||
followsSearch: $alertsFollowsSearch,
|
followsSearch: $alertsFollowsSearch,
|
||||||
@@ -1967,14 +1941,18 @@ struct StationRow: View {
|
|||||||
|
|
||||||
// Name, full address, distance
|
// Name, full address, distance
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
// The name WRAPS instead of truncating: the row is tall
|
// The name wraps, capped at THREE lines. The row is tall
|
||||||
// enough now, and a name cut to "Z A Service Stat…" cannot
|
// enough for a two-line name, but an unbounded wrap let one
|
||||||
// be told apart from the station next door. Baseline
|
// very long forecourt name set the height of the whole row —
|
||||||
// alignment keeps the notice symbols on the name's FIRST
|
// the excessive row heights this refinement removes. Past
|
||||||
// line instead of floating beside a two-line name.
|
// three lines the name ellipsises; the full name is always
|
||||||
|
// in Station Details. Baseline alignment keeps the notice
|
||||||
|
// symbols on the name's FIRST line instead of floating
|
||||||
|
// beside a two-line name.
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
Text(station.name)
|
Text(station.name)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
.lineLimit(3)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
ForEach(StationNotice.notices(for: station)) { notice in
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
@@ -2045,29 +2023,33 @@ struct StationRow: View {
|
|||||||
if let badge = recommendationBadge {
|
if let badge = recommendationBadge {
|
||||||
badgeCapsule(badge)
|
badgeCapsule(badge)
|
||||||
}
|
}
|
||||||
// Price line: the status dot and the price, leading-aligned on
|
// The price and its verdict capsule are ONE vertically grouped
|
||||||
// the column's one axis. The star no longer ends this line — it
|
// element: 5pt binds them as "this price, this outcome", while
|
||||||
// sits under the station logo — so this whole column width now
|
// the badge keeps the column's full 12pt above so the three
|
||||||
// belongs to the price, which is what lets the wide pounds &
|
// lines do not read as equals.
|
||||||
// pence form (£1.58⁷/L) render at full size rather than being
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
// scaled down or truncated.
|
// Price line: the status dot and the price, leading-aligned
|
||||||
if let price = station.prices[fuel] {
|
// on the column's one axis. The star no longer ends this
|
||||||
HStack(spacing: 5) {
|
// line — it sits under the station logo — so this whole
|
||||||
Circle()
|
// column width now belongs to the price, which is what lets
|
||||||
.fill(ragColor)
|
// the wide pounds & pence form (£1.58⁷/L) render at full
|
||||||
.frame(width: 8, height: 8)
|
// size rather than being scaled down or truncated.
|
||||||
FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
|
if let price = station.prices[fuel] {
|
||||||
size: priceSize, weight: .bold)
|
HStack(spacing: 5) {
|
||||||
.lineLimit(1)
|
PriceRatingDot(color: ragColor)
|
||||||
.minimumScaleFactor(0.8)
|
FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
|
||||||
|
size: priceSize, weight: .bold)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
// The visible "+1.8p vs cheapest" line is gone with the
|
||||||
|
// redesign, so the comparison rides on the price's label.
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(priceSpokenLabel(price))
|
||||||
|
}
|
||||||
|
if let result = fuelSavings?.result {
|
||||||
|
savingsCapsule(result)
|
||||||
}
|
}
|
||||||
// The visible "+1.8p vs cheapest" line is gone with the
|
|
||||||
// redesign, so the comparison rides on the price's label.
|
|
||||||
.accessibilityElement(children: .ignore)
|
|
||||||
.accessibilityLabel(priceSpokenLabel(price))
|
|
||||||
}
|
|
||||||
if let result = fuelSavings?.result {
|
|
||||||
savingsCapsule(result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -2088,52 +2070,40 @@ struct StationRow: View {
|
|||||||
Button {
|
Button {
|
||||||
onToggleFavourite()
|
onToggleFavourite()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: isFavourite ? "star.fill" : "star")
|
FavouriteStarGlyph(isFavourite: isFavourite)
|
||||||
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Listing-only verdict. It deliberately omits money and the repeated
|
/// The listing's verdict capsule — ONE component for every status, so the
|
||||||
/// comparison caption; the full monetary result remains in More info.
|
/// baseline's "Closest station" pill carries the same soft blue fill, white
|
||||||
@ViewBuilder
|
/// text and shape rules as Saves, Costs and Break even. The detail sheet
|
||||||
|
/// keeps `FuelSavingsCapsule` to itself, unchanged.
|
||||||
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
|
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
|
||||||
if result.status == .baseline {
|
ListingFuelSavingsCapsule(result: result)
|
||||||
FuelSavingsCapsule(result: result)
|
|
||||||
} else {
|
|
||||||
ListingFuelSavingsCapsule(result: result)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One recommendation badge, above the price it annotates. Tinted rather
|
/// One recommendation badge, above the price it annotates. Badges stay
|
||||||
/// than the mockup's solid fill, like every other badge and capsule in the
|
/// TINTED with coloured text — unlike the verdict 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 {
|
||||||
Text(badge.title)
|
StationLabelBadge(kind: badge.labelKind)
|
||||||
.font(.caption2.bold())
|
|
||||||
.foregroundStyle(badge.tint)
|
|
||||||
.padding(.horizontal, 7)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(Capsule().fill(badge.tint.opacity(0.15)))
|
|
||||||
.lineLimit(1)
|
|
||||||
.fixedSize()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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:
|
|
||||||
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?
|
||||||
|
|
||||||
@@ -206,8 +214,8 @@ struct FavouritesView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
|
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if !favourites.isEmpty {
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
if !favourites.isEmpty {
|
||||||
Button {
|
Button {
|
||||||
showTrends = true
|
showTrends = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -216,8 +224,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,
|
||||||
|
|||||||
@@ -105,38 +105,51 @@ 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 .closestStation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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"
|
||||||
@@ -147,28 +160,81 @@ struct FuelSavingsCapsule: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Amount-free, neutral verdict used by station listing rows only. Keeping this
|
/// The station listing's verdict capsule: the words on the first line, the figure
|
||||||
/// separate from `FuelSavingsCapsule` preserves the detail sheet's monetary,
|
/// on its own line beneath, and "(approx.)" on a third when the journey is an
|
||||||
/// colour-coded verdict and comparison presentation.
|
/// estimate.
|
||||||
|
///
|
||||||
|
/// Stations and Favourites share `StationRow`, so both render this. The detail
|
||||||
|
/// sheet keeps `FuelSavingsCapsule`'s colour-coded monetary verdict on one line;
|
||||||
|
/// the Key sheet's legend renders this with `showsAmount: false` so it never
|
||||||
|
/// displays an invented figure.
|
||||||
struct ListingFuelSavingsCapsule: View {
|
struct ListingFuelSavingsCapsule: View {
|
||||||
let result: FuelSavingsResult
|
let result: FuelSavingsResult
|
||||||
|
/// Station rows carry the figure; the Key sheet's legend shows the wording only.
|
||||||
|
var showsAmount: Bool = true
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
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,
|
||||||
Text(result.listingCapsuleHeadline())
|
declaredLineCount: verdict.lines.count,
|
||||||
.lineLimit(nil)
|
horizontalPadding: 8,
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
verticalPadding: 4
|
||||||
|
) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
// One Text per line, not a "\n" inside one string: each line keeps its
|
||||||
|
// 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
|
||||||
|
// block reads as a column rather than a ragged centre.
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(verdict.headline)
|
||||||
|
if let amount = verdict.amount {
|
||||||
|
Text(amount)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
if let qualifier = verdict.qualifier {
|
||||||
|
Text(qualifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
|
// Never compress or truncate: the capsule wraps and the row grows taller.
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
// Tinted foreground on a tinted fill. These capsules carry a figure the
|
||||||
|
// eye is asked to read at a glance, so they are filled rather than
|
||||||
|
// 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(Capsule().fill(Color.secondary.opacity(0.14)))
|
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
.accessibilityLabel(result.listingCapsuleHeadline())
|
.accessibilityLabel(verdict.spokenText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves and Costs only — the two verdicts that carry a figure. 84pt is the
|
||||||
|
/// width the widest common amount needs ("£123.45 more"), so shorter figures
|
||||||
|
/// pad rather than shrink the pill.
|
||||||
|
private var minimumWidth: CGFloat {
|
||||||
|
switch result.status {
|
||||||
|
case .worthTheDrive, .notWorthTheDrive: return 84
|
||||||
|
case .breakEven, .baseline: return 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var symbol: String {
|
private var symbol: String {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ struct HowBestValueRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text("How Best Value works")
|
Text(KeySheetContent.helpTitle)
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
Image(systemName: "chevron.forward")
|
Image(systemName: "chevron.forward")
|
||||||
.font(.footnote.weight(.semibold))
|
.font(.footnote.weight(.semibold))
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// "Important information" — FuelBoard's prices, availability, savings, journey
|
||||||
|
/// and liability disclaimer as ONE reusable sheet.
|
||||||
|
///
|
||||||
|
/// The disclaimer is shown from more than one place, so the copy and its layout
|
||||||
|
/// live here once: a host adds an `ImportantInformationRow` (or the
|
||||||
|
/// `.importantInformationSheet(isPresented:)` modifier) and the wording can
|
||||||
|
/// never drift between entry points.
|
||||||
|
///
|
||||||
|
/// One `Section` per topic, so every heading gets its own card: an accent
|
||||||
|
/// symbol beside a semibold title, with the secondary-coloured description
|
||||||
|
/// beneath. First host: the Stations tab's Key sheet (a row at the bottom). Copy
|
||||||
|
/// lives in `Localizable.strings`, keyed by the English source string, so the web
|
||||||
|
/// copy editor covers it.
|
||||||
|
struct ImportantInformationSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// Width of the symbol column that every heading's icon sits in.
|
||||||
|
///
|
||||||
|
/// Symbols have wildly different ink widths — `car.side` is ~34 pt wide at
|
||||||
|
/// this size while `info.circle` is ~20 pt — so without a shared column each
|
||||||
|
/// heading's TEXT would start at a different x (measured: 180 px vs 233 px
|
||||||
|
/// at 3x), which is exactly the inconsistent alignment the brief rules out.
|
||||||
|
/// `@ScaledMetric` keeps the column in step with the icon font, so it still
|
||||||
|
/// can't overrun at large Dynamic Type sizes.
|
||||||
|
@ScaledMetric(relativeTo: .title3) private var iconSlot: CGFloat = 34
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app.",
|
||||||
|
"While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Fuel Prices", systemImage: "sterlingsign.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type.",
|
||||||
|
"We recommend checking availability before making a special journey."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Fuel Availability", systemImage: "fuelpump.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings.",
|
||||||
|
"Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Savings & Best Value", systemImage: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used.",
|
||||||
|
"Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Journey Estimates", systemImage: "car.side")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Before You Travel", systemImage: "checkmark.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations.",
|
||||||
|
"FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Disclaimer", systemImage: "info.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The brief asks for ~24 pt between topics; the list's own default is
|
||||||
|
// wider and unsettable, this pins it.
|
||||||
|
.listSectionSpacing(.custom(24))
|
||||||
|
.navigationTitle("Important information")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A section heading: accent symbol + semibold title, laid out horizontally
|
||||||
|
/// and vertically centred, with the symbol at a consistent size in every
|
||||||
|
/// section.
|
||||||
|
///
|
||||||
|
/// `.title3` is 20 pt at the default Dynamic Type size — inside the 18–20 pt
|
||||||
|
/// brief — and still scales with the user's text size, so the symbols stay in
|
||||||
|
/// proportion to the headings rather than drifting apart on large sizes.
|
||||||
|
///
|
||||||
|
/// The symbol is decorative (the heading text already carries the meaning),
|
||||||
|
/// so it is hidden from VoiceOver to stop it being announced as a stray glyph.
|
||||||
|
private func heading(_ title: String, systemImage: String) -> some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
// Same-width column for every symbol, so all six headings'
|
||||||
|
// text starts at one x regardless of how wide the glyph is.
|
||||||
|
.frame(width: iconSlot)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text(LocalizedStringKey(title))
|
||||||
|
.font(.headline)
|
||||||
|
// `Color.primary`, not `.primary`: the bare ShapeStyle resolves
|
||||||
|
// against the List header's own secondary style and comes out
|
||||||
|
// grey — measured (152,152,159), i.e. identical to the body text,
|
||||||
|
// which throws away the heading/description hierarchy.
|
||||||
|
.foregroundStyle(Color.primary)
|
||||||
|
// Let a long heading wrap instead of truncating on a narrow
|
||||||
|
// device or at a large text size.
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card's paragraphs, stacked as prose rather than as separate `List` rows
|
||||||
|
/// so the card reads as one statement and the list draws no hairline between
|
||||||
|
/// a topic's own sentences.
|
||||||
|
///
|
||||||
|
/// `LocalizedStringKey` is what makes a `String` parameter localisable —
|
||||||
|
/// `Text(someString)` would render verbatim and the copy editor could never
|
||||||
|
/// reach it.
|
||||||
|
@ViewBuilder
|
||||||
|
private func paragraphs(_ lines: String...) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||||
|
Text(LocalizedStringKey(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row that opens `ImportantInformationSheet`. Reusable: drop it in any
|
||||||
|
/// `List` section.
|
||||||
|
///
|
||||||
|
/// `.buttonStyle(.plain)` keeps it from becoming a row-wide tap target that
|
||||||
|
/// could swallow a neighbouring control (see the two-buttons-in-one-row rule).
|
||||||
|
struct ImportantInformationRow: View {
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
Text("Important information")
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Image(systemName: "chevron.forward")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Presents the shared "Important information" disclaimer. One call site per
|
||||||
|
/// surface — the sheet itself is never duplicated.
|
||||||
|
func importantInformationSheet(isPresented: Binding<Bool>) -> some View {
|
||||||
|
sheet(isPresented: isPresented) { ImportantInformationSheet() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// QA hook support: `-showImportantInfo` opens the Key sheet and then the
|
||||||
|
/// disclaimer on top of it, so the copy can be screenshotted without a tap
|
||||||
|
/// (simctl has no tap command) — the same chained pattern as
|
||||||
|
/// `-showBestValueInfo`. The disclaimer's `.sheet` lives inside the Key sheet's
|
||||||
|
/// content, so it cannot present while the Key sheet is shut.
|
||||||
|
enum ImportantInformationQA {
|
||||||
|
static var isRequested: Bool {
|
||||||
|
ProcessInfo.processInfo.arguments.contains("-showImportantInfo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -82,6 +82,9 @@ struct SettingsView: View {
|
|||||||
@State private var fillUpDraft: Double?
|
@State private var fillUpDraft: Double?
|
||||||
@State private var versionTapCount = 0
|
@State private var versionTapCount = 0
|
||||||
@State private var lastVersionTap = Date.distantPast
|
@State private var lastVersionTap = Date.distantPast
|
||||||
|
/// The shared "Important information" disclaimer sheet, opened from the row
|
||||||
|
/// at the bottom of the tab.
|
||||||
|
@State private var showImportantInformation = false
|
||||||
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
||||||
/// type (keychain) + the installed-widget inventory from WidgetCenter.
|
/// type (keychain) + the installed-widget inventory from WidgetCenter.
|
||||||
@State private var smallWidgetDiag: String?
|
@State private var smallWidgetDiag: String?
|
||||||
@@ -607,11 +610,16 @@ struct SettingsView: View {
|
|||||||
Text("About")
|
Text("About")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ImportantInformationRow { showImportantInformation = true }
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
// Numeric entry must be escapable: `.decimalPad` has no Return key.
|
// Numeric entry must be escapable: `.decimalPad` has no Return key.
|
||||||
.keyboardDoneButton { focusedSavingsField = nil }
|
.keyboardDoneButton { focusedSavingsField = nil }
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
|
.importantInformationSheet(isPresented: $showImportantInformation)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task { await tipStore.load() }
|
Task { await tipStore.load() }
|
||||||
}
|
}
|
||||||
|
|||||||
+288
-115
@@ -1,6 +1,101 @@
|
|||||||
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
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
KeyStationLabelIndicator(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)
|
||||||
|
}
|
||||||
|
.presentationDetents([.fraction(0.6), .large])
|
||||||
|
.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]
|
||||||
@@ -39,6 +134,11 @@ struct StationsView: View {
|
|||||||
/// the Key sheet and from the More info sheet.
|
/// the Key sheet and from the More info sheet.
|
||||||
@State private var showBestValueInfo = false
|
@State private var showBestValueInfo = false
|
||||||
|
|
||||||
|
/// The shared "Important information" disclaimer sheet. It lives at the
|
||||||
|
/// bottom of the Key sheet here; the same sheet is reused wherever else the
|
||||||
|
/// disclaimer is needed.
|
||||||
|
@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?
|
||||||
|
|
||||||
@@ -223,6 +323,17 @@ struct StationsView: View {
|
|||||||
showBestValueInfo = true
|
showBestValueInfo = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// QA hook: `-showImportantInfo` opens the Key sheet and then the
|
||||||
|
// shared disclaimer on top of it, so the copy can be
|
||||||
|
// screenshotted without a tap. Same chained requirement as
|
||||||
|
// `-showBestValueInfo` above.
|
||||||
|
if ImportantInformationQA.isRequested {
|
||||||
|
showKey = true
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||||
|
showImportantInformation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
|
||||||
@@ -280,87 +391,14 @@ struct StationsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
VStack(spacing: 10) {
|
)
|
||||||
keyCard(
|
|
||||||
icon: "checkmark.circle.fill", color: .green,
|
|
||||||
title: "Good price", blurb: "Within 1.5p of the cheapest",
|
|
||||||
threshold: "0–1.5p")
|
|
||||||
keyCard(
|
|
||||||
icon: "equal.circle.fill", color: .orange,
|
|
||||||
title: "Okay", blurb: "More than 1.5p, up to 4p above the cheapest",
|
|
||||||
threshold: ">1.5–4p")
|
|
||||||
keyCard(
|
|
||||||
icon: "exclamationmark.circle.fill", color: .red,
|
|
||||||
title: "Pricey", blurb: "More than 4p above the cheapest",
|
|
||||||
threshold: ">4p")
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color(.separator))
|
|
||||||
.frame(height: 0.5)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Text("TOP")
|
|
||||||
.font(.caption2.bold())
|
|
||||||
.padding(.horizontal, 7)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(Capsule().fill(.blue.opacity(0.16)))
|
|
||||||
.foregroundStyle(.blue)
|
|
||||||
Text("Top result for the current sort")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.keyCardFill(color: .blue)
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: "star.fill")
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(.yellow)
|
|
||||||
.frame(width: 34)
|
|
||||||
Text("Star a station to add it to Favourites")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.keyCardFill(color: .yellow)
|
|
||||||
Text("Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The explanation is its own sheet now, opened from this
|
|
||||||
// row. Gated exactly like the More info row and the Best
|
|
||||||
// Value card itself: `isConfigured` is the same condition
|
|
||||||
// `savingsForStation` uses, so the row appears and
|
|
||||||
// disappears with the feature.
|
|
||||||
if fuelSavingsSettings.isConfigured {
|
|
||||||
Section {
|
|
||||||
HowBestValueRow { showBestValueInfo = 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() }
|
|
||||||
}
|
|
||||||
.presentationDetents([.fraction(0.6)])
|
|
||||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stationRow(_ station: FuelStation) -> some View {
|
private func stationRow(_ station: FuelStation) -> some View {
|
||||||
@@ -388,45 +426,180 @@ struct StationsView: View {
|
|||||||
return modes
|
return modes
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One price-rating card for the Key sheet: accent icon, title + blurb,
|
}
|
||||||
/// and the rating threshold in the accent colour (mirrors the tip cards).
|
|
||||||
private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View {
|
/// The station list's real RAG dot, paired with the rating name. The range lives
|
||||||
HStack(spacing: 12) {
|
/// in the aligned description column rather than as a separate trailing value.
|
||||||
Image(systemName: icon)
|
private struct KeyPriceRatingIndicator: View {
|
||||||
.font(.title3)
|
let item: KeySheetContent.Item
|
||||||
.foregroundStyle(color)
|
let color: Color
|
||||||
.frame(width: 34)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
var body: some View {
|
||||||
Text(LocalizedStringKey(title)).font(.headline)
|
HStack(spacing: 5) {
|
||||||
Text(LocalizedStringKey(blurb))
|
PriceRatingDot(color: color)
|
||||||
.font(.caption)
|
Text(LocalizedStringKey(item.label))
|
||||||
.foregroundStyle(.secondary)
|
.font(.caption.weight(.semibold))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Circle().fill(color).frame(width: 10, height: 10)
|
|
||||||
Text(threshold)
|
|
||||||
.font(.headline.weight(.bold))
|
|
||||||
.foregroundStyle(color)
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.keyCardFill(color: color)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Card background shared by the Key sheet cards — subtle accent tint over
|
/// Every Key section shares this one two-column row. The scaled first-column
|
||||||
/// the grouped background, matching the tip-section card style.
|
/// width keeps every description on the same leading axis while preserving the
|
||||||
private extension View {
|
/// inline layout at accessibility sizes; descriptions own the flexible column
|
||||||
func keyCardFill(color: Color) -> some View {
|
/// and grow vertically without truncating.
|
||||||
background(
|
private struct KeySheetDescriptionRow<Indicator: View>: View {
|
||||||
RoundedRectangle(cornerRadius: 12)
|
private let indicatorColumnWidth: CGFloat = 128
|
||||||
.fill(Color(.secondarySystemGroupedBackground))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 12).fill(color.opacity(0.08)))
|
let item: KeySheetContent.Item
|
||||||
|
@ViewBuilder let indicator: Indicator
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
indicator
|
||||||
|
.frame(width: indicatorColumnWidth, alignment: .leading)
|
||||||
|
description
|
||||||
|
}
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("\(item.label). \(item.description)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var description: some View {
|
||||||
|
Text(LocalizedStringKey(item.description))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uses the same listing capsule component as real station rows, with neutral
|
||||||
|
/// fixture numbers: only the verdict is visible in this compact variant.
|
||||||
|
private struct KeySavingsCapsule: View {
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
/// Keyed on the shared label so the legend cannot demonstrate a word the
|
||||||
|
/// listing capsule never draws.
|
||||||
|
private var status: FuelSavingsStatus {
|
||||||
|
switch label {
|
||||||
|
case String(localized: "Saves"): return .worthTheDrive
|
||||||
|
case String(localized: "Costs"): return .notWorthTheDrive
|
||||||
|
default: return .breakEven
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var result: FuelSavingsResult {
|
||||||
|
let netSaving = status == .worthTheDrive ? 1.0 : (status == .notWorthTheDrive ? -1.0 : 0)
|
||||||
|
return FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: netSaving,
|
||||||
|
extraDrivingCostGBP: 0,
|
||||||
|
extraDistanceMiles: 0,
|
||||||
|
netSavingGBP: netSaving,
|
||||||
|
status: status,
|
||||||
|
journeyConfidence: .confirmed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// The legend demonstrates the wording, not a figure: `result` above is a
|
||||||
|
// placeholder verdict, and its £1.00 must never reach the screen.
|
||||||
|
ListingFuelSavingsCapsule(result: result, showsAmount: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
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 {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared favourite glyph used by station rows and the Key.
|
||||||
|
struct FavouriteStarGlyph: View {
|
||||||
|
let isFavourite: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(systemName: isFavourite ? "star.fill" : "star")
|
||||||
|
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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 {
|
||||||
|
let kind: StationLabelKind
|
||||||
|
|
||||||
|
private var tone: CapsuleTone {
|
||||||
|
switch kind {
|
||||||
|
case .bestValue: return .bestValue
|
||||||
|
case .cheapest: return .cheapest
|
||||||
|
case .closestStation: return .closestStation
|
||||||
|
case .top: return .top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: kind.symbol)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text(LocalizedStringKey(kind.label))
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
}
|
||||||
|
.foregroundStyle(tone.foreground)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Capsule().fill(tone.background))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Key sheet's indicator for a station label. The closest station is the one
|
||||||
|
/// exception: it renders the listing's real verdict capsule, so the legend
|
||||||
|
/// demonstrates the same fill, text and shape the rows show. Everything else is
|
||||||
|
/// the shared badge.
|
||||||
|
private struct KeyStationLabelIndicator: View {
|
||||||
|
let kind: StationLabelKind
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if kind == .closestStation {
|
||||||
|
ListingFuelSavingsCapsule(result: baselineResult)
|
||||||
|
} else {
|
||||||
|
StationLabelBadge(kind: kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var baselineResult: FuelSavingsResult {
|
||||||
|
FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 0,
|
||||||
|
extraDrivingCostGBP: 0,
|
||||||
|
extraDistanceMiles: 0,
|
||||||
|
netSavingGBP: 0,
|
||||||
|
status: .baseline,
|
||||||
|
journeyConfidence: .confirmed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
"Alerts" = "Alerts";
|
"Alerts" = "Alerts";
|
||||||
"Settings" = "Settings";
|
"Settings" = "Settings";
|
||||||
"FuelBoard" = "FuelBoard";
|
"FuelBoard" = "FuelBoard";
|
||||||
"TOP" = "TOP";
|
"Top" = "Top";
|
||||||
"BEST VALUE" = "BEST VALUE";
|
"Best value" = "Best value";
|
||||||
"CHEAPEST" = "CHEAPEST";
|
"Cheapest" = "Cheapest";
|
||||||
|
"Closest station" = "Closest station";
|
||||||
|
|
||||||
/* 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";
|
||||||
@@ -25,14 +26,31 @@
|
|||||||
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
|
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
|
||||||
"All %lld stations shown" = "All %lld stations shown";
|
"All %lld stations shown" = "All %lld stations shown";
|
||||||
"Key" = "Key";
|
"Key" = "Key";
|
||||||
|
"Price ratings" = "Price ratings";
|
||||||
"Good price" = "Good price";
|
"Good price" = "Good price";
|
||||||
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
|
"Within 1.5p/L of the cheapest." = "Within 1.5p/L of the cheapest.";
|
||||||
"Okay" = "Okay";
|
"Okay" = "Okay";
|
||||||
"More than 1.5p, up to 4p above the cheapest" = "More than 1.5p, up to 4p above the cheapest";
|
"More than 1.5p/L, up to 4p/L above the cheapest." = "More than 1.5p/L, up to 4p/L above the cheapest.";
|
||||||
"Pricey" = "Pricey";
|
"Pricey" = "Pricey";
|
||||||
"More than 4p above the cheapest" = "More than 4p above the cheapest";
|
"More than 4p/L above the cheapest." = "More than 4p/L above the cheapest.";
|
||||||
"Top result for the current sort" = "Top result for the current sort";
|
"Savings" = "Savings";
|
||||||
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
"Saves" = "Saves";
|
||||||
|
"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";
|
||||||
|
"The saving or extra cost is negligible." = "The saving or extra cost is negligible.";
|
||||||
|
"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.";
|
||||||
|
/* Station listing verdict capsule: the words above the figure */
|
||||||
|
"(approx.)" = "(approx.)";
|
||||||
|
"%@ more" = "%@ more";
|
||||||
|
"Station labels" = "Station labels";
|
||||||
|
"Closest station" = "Closest station";
|
||||||
|
"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 nearest station by journey distance." = "The nearest 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.";
|
||||||
|
"How does Best Value work?" = "How does Best Value work?";
|
||||||
/* 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";
|
||||||
@@ -249,3 +267,23 @@
|
|||||||
"Uses the same distance as your station search." = "Uses the same distance as your station search.";
|
"Uses the same distance as your station search." = "Uses the same distance as your station search.";
|
||||||
"See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel." = "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.";
|
"See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel." = "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.";
|
||||||
"Background alerts depend on when your iPhone allows them; each station alerts at most once an hour." = "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.";
|
"Background alerts depend on when your iPhone allows them; each station alerts at most once an hour." = "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.";
|
||||||
|
|
||||||
|
/* Important information — the shared disclaimer sheet (one card per heading) */
|
||||||
|
"Important information" = "Important information";
|
||||||
|
"Fuel Prices" = "Fuel Prices";
|
||||||
|
"FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app." = "FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app.";
|
||||||
|
"While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump." = "While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump.";
|
||||||
|
"Fuel Availability" = "Fuel Availability";
|
||||||
|
"A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type." = "A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type.";
|
||||||
|
"We recommend checking availability before making a special journey." = "We recommend checking availability before making a special journey.";
|
||||||
|
"Savings & Best Value" = "Savings & Best Value";
|
||||||
|
"Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings." = "Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings.";
|
||||||
|
"Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost." = "Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost.";
|
||||||
|
"Journey Estimates" = "Journey Estimates";
|
||||||
|
"Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used." = "Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used.";
|
||||||
|
"Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits." = "Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits.";
|
||||||
|
"Before You Travel" = "Before You Travel";
|
||||||
|
"Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up." = "Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up.";
|
||||||
|
"Disclaimer" = "Disclaimer";
|
||||||
|
"FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations." = "FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations.";
|
||||||
|
"FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided." = "FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided.";
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
|
||||||
func testListingCapsuleUsesSixShortFinancialVerdictsWithoutAmounts() {
|
func testListingCapsulePutsTheFigureOnItsOwnLine() {
|
||||||
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
|
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
|
||||||
FuelSavingsResult(
|
FuelSavingsResult(
|
||||||
fillLitres: 30,
|
fillLitres: 30,
|
||||||
@@ -31,12 +31,33 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingCapsuleHeadline(), "Saves")
|
// The figure takes its own line, so the capsule's shape is decided by the
|
||||||
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(), "Saves (approx.)")
|
// data rather than by how much width the price column hands it.
|
||||||
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingCapsuleHeadline(), "Costs more")
|
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingVerdict().lines,
|
||||||
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingCapsuleHeadline(), "Costs more (approx.)")
|
["Saves", "£12.50"])
|
||||||
XCTAssertEqual(result(.breakEven, .confirmed).listingCapsuleHeadline(), "Break even")
|
XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines,
|
||||||
XCTAssertEqual(result(.breakEven, .estimated).listingCapsuleHeadline(), "Break even (approx.)")
|
["Saves", "£12.50", "(approx.)"])
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingVerdict().lines,
|
||||||
|
["Costs", "£123.45 more"])
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingVerdict().lines,
|
||||||
|
["Costs", "£123.45 more", "(approx.)"])
|
||||||
|
|
||||||
|
// Break even carries no figure — ±£0.05 is noise — so it stays one line
|
||||||
|
// when confirmed and gains a second only for the estimate qualifier.
|
||||||
|
XCTAssertEqual(result(.breakEven, .confirmed).listingVerdict().lines, ["Break even"])
|
||||||
|
XCTAssertEqual(result(.breakEven, .estimated).listingVerdict().lines,
|
||||||
|
["Break even", "(approx.)"])
|
||||||
|
XCTAssertEqual(result(.baseline, .confirmed).listingVerdict().lines, ["Closest station"])
|
||||||
|
|
||||||
|
// The Key sheet's legend demonstrates the wording, never a figure.
|
||||||
|
XCTAssertEqual(
|
||||||
|
result(.notWorthTheDrive, .confirmed).listingVerdict(showsAmount: false).lines,
|
||||||
|
["Costs"]
|
||||||
|
)
|
||||||
|
|
||||||
|
// VoiceOver keeps a single line: a drawn break is a spoken pause.
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(),
|
||||||
|
"Saves £12.50 (approx.)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
|
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
|
||||||
|
|||||||
@@ -257,3 +257,73 @@ final class StationRecommendationBadgeTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class KeySheetContentTests: XCTestCase {
|
||||||
|
func testPriceRatingsExplainTheImplementedRAGBoundaries() {
|
||||||
|
XCTAssertEqual(KeySheetContent.priceRatings, [
|
||||||
|
.init(label: "Good price", description: "Within 1.5p/L of the cheapest."),
|
||||||
|
.init(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
|
||||||
|
.init(label: "Pricey", description: "More than 4p/L above the cheapest.")
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavingsExplainVerdictsWithoutImplyingMinimumSavingQualification() {
|
||||||
|
XCTAssertEqual(KeySheetContent.savings, [
|
||||||
|
.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."),
|
||||||
|
// "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" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 station", "Top"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.closestStation.symbol, "location.north.fill")
|
||||||
|
XCTAssertEqual(StationLabelKind.top.symbol, "trophy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStationLabelsDescribeTheCurrentQualificationAndRangeRules() {
|
||||||
|
XCTAssertEqual(KeySheetContent.stationLabels, [
|
||||||
|
.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: "Closest station", description: "The nearest station by journey distance."),
|
||||||
|
.init(label: "Top", description: "The cheapest station in your Favourites for the selected fuel.")
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFavouriteAndHelpCopyRemainPinned() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
KeySheetContent.favourite,
|
||||||
|
.init(label: "Favourites", description: "Star a station to save it to your Favourites.")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(KeySheetContent.helpTitle, "How does Best Value work?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,3 +31,12 @@ final class FuelStoreBestValueThresholdTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class FuelStoreAlertModeTests: XCTestCase {
|
||||||
|
func testLegacyBestValueSelectionLoadsAsCheapest() {
|
||||||
|
FuelStore.saveAlertsMode(.bestValue)
|
||||||
|
defer { FuelStore.saveAlertsMode(.cheapest) }
|
||||||
|
|
||||||
|
XCTAssertEqual(FuelStore.loadAlertsMode(), .cheapest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -516,8 +516,10 @@ 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")
|
// Sentence case, to match every other surface: the watch
|
||||||
.font(.system(size: 9, weight: .bold))
|
// row is the same label as the phone's, not a different one.
|
||||||
|
Text("Top")
|
||||||
|
.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())
|
||||||
|
|||||||
+140
-9
@@ -263,18 +263,73 @@ extension FuelSavingsResult {
|
|||||||
headline(compact: true)
|
headline(compact: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Short, amount-free verdict used only by station listing rows. The detail
|
/// The station-listing capsule's verdict, split into the lines the capsule
|
||||||
/// sheet continues to use `capsuleHeadline()` so its full monetary verdict is
|
/// draws.
|
||||||
/// unchanged. Confidence follows the existing journey provenance exactly.
|
///
|
||||||
func listingCapsuleHeadline() -> String {
|
/// The amount always takes its OWN line, so where the capsule breaks is
|
||||||
let approximate = journeyConfidence.isEstimated ? " (approx.)" : ""
|
/// decided by the data, never by the width it was handed: the figure can
|
||||||
|
/// never straddle two lines, and a taller verdict grows the capsule downward
|
||||||
|
/// instead of widening the row's fixed price column.
|
||||||
|
struct ListingVerdict: Equatable {
|
||||||
|
/// "Saves", "Costs", "Break even" or "Closest station".
|
||||||
|
let headline: String
|
||||||
|
/// "£3.10" / "£1.20 more" — nil when there is no figure to show.
|
||||||
|
let amount: String?
|
||||||
|
/// "(approx.)" when the journey behind the figure was estimated.
|
||||||
|
let qualifier: String?
|
||||||
|
|
||||||
|
var lines: [String] { [headline, amount, qualifier].compactMap { $0 } }
|
||||||
|
|
||||||
|
/// Single-line form, for VoiceOver and tests. A drawn newline is a pause
|
||||||
|
/// in the middle of a phrase when spoken.
|
||||||
|
var spokenText: String { lines.joined(separator: " ") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verdict for the station listing rows — Stations and Favourites share one
|
||||||
|
/// row, so they share this. `showsAmount: false` is the Key sheet's legend,
|
||||||
|
/// which demonstrates the wording without inventing a figure.
|
||||||
|
func listingVerdict(showsAmount: Bool = true) -> ListingVerdict {
|
||||||
|
let qualifier = journeyConfidence.isEstimated ? String(localized: "(approx.)") : nil
|
||||||
switch status {
|
switch status {
|
||||||
case .worthTheDrive: return "Saves\(approximate)"
|
case .worthTheDrive:
|
||||||
case .notWorthTheDrive: return "Costs more\(approximate)"
|
return ListingVerdict(
|
||||||
case .breakEven: return "Break even\(approximate)"
|
headline: String(localized: "Saves"),
|
||||||
case .baseline: return Self.baselineHeadline
|
amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .notWorthTheDrive:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Costs"),
|
||||||
|
amount: showsAmount ? Self.amountWithMore(abs(netSavingGBP)) : nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .breakEven:
|
||||||
|
// No figure: ±£0.05 is noise, so this stays a single line unless the
|
||||||
|
// estimate qualifier adds its own.
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Break even"),
|
||||||
|
amount: nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .baseline:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: Self.baselineHeadline,
|
||||||
|
amount: nil,
|
||||||
|
qualifier: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "£1.20 more" — kept as a format string so the copy editor owns the word.
|
||||||
|
private static func amountWithMore(_ value: Double) -> String {
|
||||||
|
String(format: String(localized: "%@ more"), FuelSavingsFormat.money(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line verdict for voice and tests; the capsule itself renders
|
||||||
|
/// `listingVerdict()`'s lines separately.
|
||||||
|
func listingCapsuleHeadline() -> String {
|
||||||
|
listingVerdict().spokenText
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One station's journey distance, plus how it was obtained.
|
/// One station's journey distance, plus how it was obtained.
|
||||||
@@ -309,6 +364,82 @@ struct FuelSavingsRankedStation: Equatable {
|
|||||||
let pricePencePerLitre: Double
|
let pricePencePerLitre: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// each visual indicator without coupling the wording to SwiftUI layout.
|
||||||
|
///
|
||||||
|
/// Every label is SENTENCE CASE ("Best value", not "BEST VALUE"): the labels
|
||||||
|
/// are read as phrases, and a capsule set in all caps reads as shouting beside
|
||||||
|
/// 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.")
|
||||||
|
]
|
||||||
|
|
||||||
|
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 station", description: "The nearest station by journey distance."),
|
||||||
|
Item(label: "Top", description: "The cheapest station in your Favourites for the selected fuel.")
|
||||||
|
]
|
||||||
|
|
||||||
|
static let favourite = Item(
|
||||||
|
label: "Favourites",
|
||||||
|
description: "Star a station to save it to your Favourites."
|
||||||
|
)
|
||||||
|
|
||||||
|
static let helpTitle = "How does Best Value work?"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A station label's meaning, independent of its copy.
|
||||||
|
///
|
||||||
|
/// The Key sheet used to dispatch its indicator on the label's literal text
|
||||||
|
/// (`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 closestStation
|
||||||
|
case top
|
||||||
|
|
||||||
|
/// Matches the corresponding `KeySheetContent.stationLabels` entry.
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .bestValue: return String(localized: "Best value")
|
||||||
|
case .cheapest: return String(localized: "Cheapest")
|
||||||
|
case .closestStation: return FuelSavingsResult.baselineHeadline
|
||||||
|
case .top: return String(localized: "Top")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .bestValue: return "medal"
|
||||||
|
case .cheapest: return "tag"
|
||||||
|
case .closestStation: return "location.north.fill"
|
||||||
|
case .top: return "trophy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Which recommendation badge a station listing row shows, if any.
|
/// Which recommendation badge a station listing row shows, if any.
|
||||||
///
|
///
|
||||||
/// Presentation only — no new rules: the BEST VALUE qualification reuses the
|
/// Presentation only — no new rules: the BEST VALUE qualification reuses the
|
||||||
|
|||||||
@@ -701,10 +701,12 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadAlertsMode() -> AlertMode {
|
static func loadAlertsMode() -> AlertMode {
|
||||||
if let raw = loadString(service: alertsModeKey), let mode = AlertMode(rawValue: raw) {
|
guard let raw = loadString(service: alertsModeKey),
|
||||||
return mode
|
let mode = AlertMode(rawValue: raw),
|
||||||
|
mode == .cheapest else {
|
||||||
|
return .cheapest
|
||||||
}
|
}
|
||||||
return .cheapest
|
return mode
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveAlertsMode(_ mode: AlertMode) {
|
static func saveAlertsMode(_ mode: AlertMode) {
|
||||||
|
|||||||
Reference in New Issue
Block a user