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.
This commit is contained in:
+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 = 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
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
+208
-118
@@ -297,92 +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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
ImportantInformationRow { showImportantInformation = 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)
|
||||||
// Nested sheet: the Key sheet stays behind the disclaimer.
|
}
|
||||||
.importantInformationSheet(isPresented: $showImportantInformation)
|
} 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 {
|
||||||
@@ -410,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +263,7 @@
|
|||||||
"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 — the shared disclaimer sheet (one card per heading) */
|
||||||
"Important information" = "Important information";
|
"Important information" = "Important information";
|
||||||
"Fuel Prices" = "Fuel Prices";
|
"Fuel Prices" = "Fuel Prices";
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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