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.
|
||||
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -533,7 +533,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -599,7 +599,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -663,7 +663,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -728,7 +728,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -826,7 +826,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -851,7 +851,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -875,7 +875,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
@@ -900,7 +900,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
|
||||
@@ -6,18 +6,12 @@ import SwiftUI
|
||||
/// versa). The Live Activity section also lives here — it mirrors this page's
|
||||
/// shape (toggle + fuel + distance) so the two "notify me while driving"
|
||||
/// surfaces sit together. Geofence plumbing (count, 18-limit) lives in
|
||||
/// Settings → Debug; the user-facing page keeps short descriptions that follow
|
||||
/// the selected alert type and radius, plus a 0-stations hint.
|
||||
/// Settings → Debug; the user-facing page keeps short descriptions of the
|
||||
/// cheapest-station rule and radius, plus a 0-stations hint.
|
||||
struct AlertsView: View {
|
||||
@Binding var enabled: Bool
|
||||
@Binding var radius: Double // stored in km (monitor + storage)
|
||||
@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 favouriteDropFuel: FuelType
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// unit — e.g. "Get alerts when you're within 2 miles of a matching
|
||||
/// station." "Follow search" mirrors the Stations-tab distance, which the
|
||||
@@ -212,32 +195,9 @@ struct AlertsView: View {
|
||||
Section {
|
||||
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
||||
Group {
|
||||
optionRow(String(localized: "Alert type", comment: "Proximity alert type picker title"),
|
||||
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"))
|
||||
Text(String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(alertModeDescription)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if alertMode == .bestValue {
|
||||
// The threshold is edited in Settings → Fuel Savings: it
|
||||
// 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) {
|
||||
|
||||
@@ -37,7 +37,6 @@ struct ContentView: View {
|
||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
|
||||
@State private var alertsMode: AlertMode = FuelStore.loadAlertsMode()
|
||||
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
||||
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
|
||||
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
|
||||
@@ -152,23 +151,13 @@ struct ContentView: View {
|
||||
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) {
|
||||
monitor.update(
|
||||
stations: stations,
|
||||
favourites: refreshedFavourites,
|
||||
fuel: overrideFuel ?? alertsFuel,
|
||||
radiusKM: effectiveAlertsRadiusKM,
|
||||
alertMode: effectiveAlertsMode,
|
||||
alertMode: .cheapest,
|
||||
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
||||
fuelSavingsSettings: fuelSavingsSettings,
|
||||
fuelSavingsAmount: sessionFuelSavingsAmount
|
||||
@@ -1154,7 +1143,6 @@ struct ContentView: View {
|
||||
// or the choice wouldn't take effect until the next launch.
|
||||
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
||||
ensureBestValueAlertModeIsAvailable()
|
||||
locationManager.startForegroundTracking()
|
||||
installLocationUpdateHook()
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
@@ -1174,7 +1162,6 @@ struct ContentView: View {
|
||||
#if DEBUG
|
||||
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
|
||||
#endif
|
||||
ensureBestValueAlertModeIsAvailable()
|
||||
updateAlertMonitor()
|
||||
}
|
||||
.onChange(of: sessionFuelSavingsAmount) { _, _ in
|
||||
@@ -1257,16 +1244,6 @@ struct ContentView: View {
|
||||
FuelStore.saveAlertsFuel(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
|
||||
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
|
||||
updateAlertMonitor()
|
||||
@@ -1609,9 +1586,6 @@ struct ContentView: View {
|
||||
enabled: $alertsEnabled,
|
||||
radius: $alertsRadius,
|
||||
fuel: $alertsFuel,
|
||||
alertMode: $alertsMode,
|
||||
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
||||
bestValueConfigured: fuelSavingsSettings.isConfigured,
|
||||
favouriteDropEnabled: $favouriteDropAlertsEnabled,
|
||||
favouriteDropFuel: $favouriteDropAlertsFuel,
|
||||
followsSearch: $alertsFollowsSearch,
|
||||
@@ -2053,9 +2027,7 @@ struct StationRow: View {
|
||||
// scaled down or truncated.
|
||||
if let price = station.prices[fuel] {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(ragColor)
|
||||
.frame(width: 8, height: 8)
|
||||
PriceRatingDot(color: ragColor)
|
||||
FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
|
||||
size: priceSize, weight: .bold)
|
||||
.lineLimit(1)
|
||||
@@ -2088,8 +2060,7 @@ struct StationRow: View {
|
||||
Button {
|
||||
onToggleFavourite()
|
||||
} label: {
|
||||
Image(systemName: isFavourite ? "star.fill" : "star")
|
||||
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
||||
FavouriteStarGlyph(isFavourite: isFavourite)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
@@ -2110,14 +2081,7 @@ struct StationRow: View {
|
||||
/// app: coloured text keeps its contrast in both appearances, at every
|
||||
/// Dynamic Type size, without introducing a colour outside the palette.
|
||||
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
|
||||
Text(badge.title)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(badge.tint)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(badge.tint.opacity(0.15)))
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
StationLabelBadge(label: badge.title, color: badge.tint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ struct HowBestValueRow: View {
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Text("How Best Value works")
|
||||
Text(KeySheetContent.helpTitle)
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
|
||||
+181
-91
@@ -300,70 +300,50 @@ struct StationsView: View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(spacing: 10) {
|
||||
keyCard(
|
||||
icon: "checkmark.circle.fill", color: .green,
|
||||
title: "Good price", blurb: "Within 1.5p of the cheapest",
|
||||
threshold: "0–1.5p")
|
||||
keyCard(
|
||||
icon: "equal.circle.fill", color: .orange,
|
||||
title: "Okay", blurb: "More than 1.5p, up to 4p above the cheapest",
|
||||
threshold: ">1.5–4p")
|
||||
keyCard(
|
||||
icon: "exclamationmark.circle.fill", color: .red,
|
||||
title: "Pricey", blurb: "More than 4p above the cheapest",
|
||||
threshold: ">4p")
|
||||
Rectangle()
|
||||
.fill(Color(.separator))
|
||||
.frame(height: 0.5)
|
||||
.padding(.vertical, 2)
|
||||
HStack(spacing: 12) {
|
||||
Text("TOP")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(.blue.opacity(0.16)))
|
||||
.foregroundStyle(.blue)
|
||||
Text("Top result for the current sort")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer(minLength: 0)
|
||||
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[0]) {
|
||||
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[0], color: .green)
|
||||
}
|
||||
.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)
|
||||
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[1]) {
|
||||
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[1], color: .orange)
|
||||
}
|
||||
.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)
|
||||
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[2]) {
|
||||
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[2], color: .red)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||
} header: {
|
||||
Text("Price ratings")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(KeySheetContent.savings, id: \.label) { item in
|
||||
KeySheetDescriptionRow(item: item) {
|
||||
KeySavingsCapsule(label: item.label)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Savings")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(KeySheetContent.stationLabels, id: \.label) { item in
|
||||
KeySheetDescriptionRow(item: item) {
|
||||
KeyStationLabelIndicator(label: item.label, fuelColor: selectedFuel.tintColor)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Station labels")
|
||||
}
|
||||
|
||||
Section {
|
||||
KeySheetDescriptionRow(item: KeySheetContent.favourite) {
|
||||
FavouriteStarGlyph(isFavourite: true)
|
||||
}
|
||||
} header: {
|
||||
Text("Favourites")
|
||||
}
|
||||
|
||||
// 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 }
|
||||
@@ -381,7 +361,7 @@ struct StationsView: View {
|
||||
// Nested sheet: the Key sheet stays behind the disclaimer.
|
||||
.importantInformationSheet(isPresented: $showImportantInformation)
|
||||
}
|
||||
.presentationDetents([.fraction(0.6)])
|
||||
.presentationDetents([.fraction(0.6), .large])
|
||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||
}
|
||||
|
||||
@@ -410,45 +390,155 @@ struct StationsView: View {
|
||||
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 {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 34)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(LocalizedStringKey(title)).font(.headline)
|
||||
Text(LocalizedStringKey(blurb))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
/// The station list's real RAG dot, paired with the rating name. The range lives
|
||||
/// in the aligned description column rather than as a separate trailing value.
|
||||
private struct KeyPriceRatingIndicator: View {
|
||||
let item: KeySheetContent.Item
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
PriceRatingDot(color: color)
|
||||
Text(LocalizedStringKey(item.label))
|
||||
.font(.caption.weight(.semibold))
|
||||
.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
|
||||
/// the grouped background, matching the tip-section card style.
|
||||
private extension View {
|
||||
func keyCardFill(color: Color) -> some View {
|
||||
background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemGroupedBackground))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).fill(color.opacity(0.08)))
|
||||
/// Every Key section shares this one two-column row. The scaled first-column
|
||||
/// width keeps every description on the same leading axis while preserving the
|
||||
/// inline layout at accessibility sizes; descriptions own the flexible column
|
||||
/// and grow vertically without truncating.
|
||||
private struct KeySheetDescriptionRow<Indicator: View>: View {
|
||||
private let indicatorColumnWidth: CGFloat = 128
|
||||
|
||||
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)";
|
||||
"All %lld stations shown" = "All %lld stations shown";
|
||||
"Key" = "Key";
|
||||
"Price ratings" = "Price ratings";
|
||||
"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";
|
||||
"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";
|
||||
"More than 4p above the cheapest" = "More than 4p above the cheapest";
|
||||
"Top result for the current sort" = "Top result for the current sort";
|
||||
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
||||
"More than 4p/L above the cheapest." = "More than 4p/L above the cheapest.";
|
||||
"Savings" = "Savings";
|
||||
"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) */
|
||||
"Add to favourites" = "Add to 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.";
|
||||
"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.";
|
||||
|
||||
/* Important information — the shared disclaimer sheet (one card per heading) */
|
||||
"Important information" = "Important information";
|
||||
"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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Presentation only — no new rules: the BEST VALUE qualification reuses the
|
||||
|
||||
@@ -701,11 +701,13 @@ struct FuelStore {
|
||||
}
|
||||
|
||||
static func loadAlertsMode() -> AlertMode {
|
||||
if let raw = loadString(service: alertsModeKey), let mode = AlertMode(rawValue: raw) {
|
||||
return mode
|
||||
}
|
||||
guard let raw = loadString(service: alertsModeKey),
|
||||
let mode = AlertMode(rawValue: raw),
|
||||
mode == .cheapest else {
|
||||
return .cheapest
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
static func saveAlertsMode(_ mode: AlertMode) {
|
||||
saveString(mode.rawValue, service: alertsModeKey)
|
||||
|
||||
Reference in New Issue
Block a user