Compare commits

...
2 Commits
Author SHA1 Message Date
FuelBoard Contributor 6cbb083208 Retire the Best Value proximity alert option; move Key sheet copy into a tested model
- Alerts: drop the alert-type picker and the Best Value alert path, so
  proximity alerts always follow the cheapest station. A stored `bestValue`
  selection now falls back to cheapest when loaded.
- Key sheet: the price-rating, savings, station-label and favourite wording
  comes from `KeySheetContent` in Shared/FuelSavings.swift — a Foundation-only
  model with tests that pin the exact meaning of each indicator — and the
  sheet is built from that model rather than inline strings.
- BACKLOG: record the plan to restore Best Value proximity alerts behind one
  shared evaluator (user-location baseline, a single disclosed qualification
  rule, and real/Test alert parity).
- Bump CURRENT_PROJECT_VERSION to 29.
2026-10-18 17:16:12 +01:00
FuelBoard Contributor c331f3d663 Add a reusable Important information disclaimer sheet
One sheet, reachable from the bottom of the Key sheet and from the bottom
of Settings, with each topic as its own card and heading:

- Fuel Prices, Fuel Availability, Savings & Best Value, Journey Estimates,
  Before You Travel and Disclaimer, in that order.
- Every heading carries an SF Symbol in a fixed-width, Dynamic-Type-aware
  slot, so heading text lines up across sections regardless of glyph width.
  Icons take the accent colour, headings are semibold and primary, and each
  description sits beneath its heading in secondary text.
- All copy lives in en.lproj/Localizable.strings keyed by the English
  literal, ready for the web copy editor.
- The sheet, its trigger row and the .importantInformationSheet(isPresented:)
  modifier live in one file, so any further entry point is a one-line change
  and the wording cannot drift between them.
- DEBUG-only `-showImportantInfo` launch hook, chained behind the Key sheet,
  for screenshotting without a tap.
2026-10-18 17:16:07 +01:00
13 changed files with 591 additions and 219 deletions
+18
View File
@@ -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:
+8 -8
View File
@@ -533,7 +533,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 28; CURRENT_PROJECT_VERSION = 29;
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 = 29;
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 = 29;
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 = 29;
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 = 29;
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 = 29;
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 = 29;
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 = 29;
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
View File
@@ -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) {
+4 -40
View File
@@ -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,
@@ -2053,9 +2027,7 @@ struct StationRow: View {
// scaled down or truncated. // scaled down or truncated.
if let price = station.prices[fuel] { if let price = station.prices[fuel] {
HStack(spacing: 5) { HStack(spacing: 5) {
Circle() PriceRatingDot(color: ragColor)
.fill(ragColor)
.frame(width: 8, height: 8)
FuelStore.priceTextAttributed(price, style: priceDisplayStyle, FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
size: priceSize, weight: .bold) size: priceSize, weight: .bold)
.lineLimit(1) .lineLimit(1)
@@ -2088,8 +2060,7 @@ 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)
} }
@@ -2110,14 +2081,7 @@ struct StationRow: View {
/// app: coloured text keeps its contrast in both appearances, at every /// app: coloured text keeps its contrast in both appearances, at every
/// Dynamic Type size, without introducing a colour outside the palette. /// Dynamic Type size, without introducing a colour outside the palette.
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View { private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
Text(badge.title) StationLabelBadge(label: badge.title, color: badge.tint)
.font(.caption2.bold())
.foregroundStyle(badge.tint)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(badge.tint.opacity(0.15)))
.lineLimit(1)
.fixedSize()
} }
} }
+1 -1
View File
@@ -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))
+193
View File
@@ -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
+8
View File
@@ -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() }
} }
+224 -112
View File
@@ -39,6 +39,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 +228,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
@@ -281,86 +297,72 @@ struct StationsView: View {
} }
private var keySheet: some View { private var keySheet: some View {
NavigationStack { NavigationStack {
List { List {
Section { Section {
VStack(spacing: 10) { KeySheetDescriptionRow(item: KeySheetContent.priceRatings[0]) {
keyCard( KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[0], color: .green)
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") KeySheetDescriptionRow(item: KeySheetContent.priceRatings[1]) {
.navigationBarTitleDisplayMode(.inline) KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[1], color: .orange)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showKey = false }
}
} }
// Nested sheet: the Key sheet stays behind the explanation. KeySheetDescriptionRow(item: KeySheetContent.priceRatings[2]) {
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() } KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[2], color: .red)
}
} header: {
Text("Price ratings")
} }
.presentationDetents([.fraction(0.6)])
.presentationBackground(Color(UIColor.systemGroupedBackground)) Section {
ForEach(KeySheetContent.savings, id: \.label) { item in
KeySheetDescriptionRow(item: item) {
KeySavingsCapsule(label: item.label)
}
}
} header: {
Text("Savings")
}
Section {
ForEach(KeySheetContent.stationLabels, id: \.label) { item in
KeySheetDescriptionRow(item: item) {
KeyStationLabelIndicator(label: item.label, fuelColor: selectedFuel.tintColor)
}
}
} header: {
Text("Station labels")
}
Section {
KeySheetDescriptionRow(item: KeySheetContent.favourite) {
FavouriteStarGlyph(isFavourite: true)
}
} header: {
Text("Favourites")
}
Section {
HowBestValueRow { showBestValueInfo = true }
}
Section {
ImportantInformationRow { showImportantInformation = true }
}
}
.navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showKey = false }
}
}
// Nested sheet: the Key sheet stays behind the explanation.
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
// Nested sheet: the Key sheet stays behind the disclaimer.
.importantInformationSheet(isPresented: $showImportantInformation)
}
.presentationDetents([.fraction(0.6), .large])
.presentationBackground(Color(UIColor.systemGroupedBackground))
} }
private func stationRow(_ station: FuelStation) -> some View { private func stationRow(_ station: FuelStation) -> some View {
@@ -388,45 +390,155 @@ 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
private var status: FuelSavingsStatus {
switch label {
case "Saves": return .worthTheDrive
case "Costs more": 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 {
ListingFuelSavingsCapsule(result: result)
}
}
/// Shared RAG dot used beside live station prices and in the Key.
struct PriceRatingDot: View {
let color: Color
var body: some View {
Circle()
.fill(color)
.frame(width: 8, height: 8)
.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.
struct StationLabelBadge: View {
let label: String
let color: Color
var body: some View {
Text(LocalizedStringKey(label))
.font(.caption2.bold())
.foregroundStyle(color)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(color.opacity(0.15)))
.fixedSize(horizontal: true, vertical: false)
}
}
private struct KeyStationLabelIndicator: View {
let label: String
let fuelColor: Color
var body: some View {
switch label {
case "BEST VALUE":
StationLabelBadge(label: label, color: .green)
case "CHEAPEST":
StationLabelBadge(label: label, color: .blue)
case "CLOSEST":
FuelSavingsCapsule(result: baselineResult)
default:
Text("TOP")
.font(.caption2.bold())
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(fuelColor.opacity(0.18), in: Capsule())
.fixedSize(horizontal: true, vertical: false)
}
}
private var baselineResult: FuelSavingsResult {
FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 0,
extraDrivingCostGBP: 0,
extraDistanceMiles: 0,
netSavingGBP: 0,
status: .baseline,
journeyConfidence: .confirmed
) )
} }
} }
+39 -5
View File
@@ -25,14 +25,28 @@
"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 more" = "Costs more";
"Filling up here costs more than at your closest station, including travel." = "Filling up here costs more than at your closest station, including travel.";
"Station labels" = "Station labels";
"CLOSEST" = "CLOSEST";
"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 +263,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.";
@@ -257,3 +257,39 @@ 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."),
.init(label: "Costs more", description: "Filling up here costs more than at your closest station, including travel.")
])
XCTAssertFalse(KeySheetContent.savings.contains { $0.label == "Small saving" })
}
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", 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)
}
}
+36
View File
@@ -309,6 +309,42 @@ 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.
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.")
]
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 more", 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", 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?"
}
/// 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
+5 -3
View File
@@ -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) {