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:
FuelBoard Contributor
2026-10-18 17:16:12 +01:00
parent c331f3d663
commit 6cbb083208
11 changed files with 355 additions and 225 deletions
+10 -50
View File
@@ -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,41 +195,18 @@ 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) {
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
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"),
value: alertRadiusValueLabel,
isEnabled: enabled) {
+4 -40
View File
@@ -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)
}
}
+1 -1
View File
@@ -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))
+208 -118
View File
@@ -297,92 +297,72 @@ struct StationsView: View {
}
private var keySheet: some 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)
}
.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 }
}
NavigationStack {
List {
Section {
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[0]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[0], color: .green)
}
.navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showKey = false }
}
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[1]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[1], color: .orange)
}
// 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)
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[2]) {
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 {
@@ -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)
.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()
}
}
/// 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)
}
.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
)
}
}
+20 -5
View File
@@ -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";