From 6cbb0832086aa0824a94768aaa6c61924528f073 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Sun, 18 Oct 2026 17:16:12 +0100 Subject: [PATCH] Retire the Best Value proximity alert option; move Key sheet copy into a tested model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- BACKLOG.md | 18 + FuelBoard.xcodeproj/project.pbxproj | 16 +- FuelBoard/AlertsView.swift | 60 +--- FuelBoard/ContentView.swift | 44 +-- FuelBoard/HowBestValueSheet.swift | 2 +- FuelBoard/StationsView.swift | 326 +++++++++++------- FuelBoard/en.lproj/Localizable.strings | 25 +- .../FuelSavingsTests.swift | 36 ++ .../FuelBoardSharedTests/FuelStoreTests.swift | 9 + Shared/FuelSavings.swift | 36 ++ Shared/FuelStore.swift | 8 +- 11 files changed, 355 insertions(+), 225 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 9e1c5cd..8d0b5a6 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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: diff --git a/FuelBoard.xcodeproj/project.pbxproj b/FuelBoard.xcodeproj/project.pbxproj index 2f9f8f8..c1584f8 100644 --- a/FuelBoard.xcodeproj/project.pbxproj +++ b/FuelBoard.xcodeproj/project.pbxproj @@ -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"; diff --git a/FuelBoard/AlertsView.swift b/FuelBoard/AlertsView.swift index 6b057f1..b583ed7 100644 --- a/FuelBoard/AlertsView.swift +++ b/FuelBoard/AlertsView.swift @@ -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) { diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 2a759fe..2122fda 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -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) } } diff --git a/FuelBoard/HowBestValueSheet.swift b/FuelBoard/HowBestValueSheet.swift index 762e5e6..c38d1e1 100644 --- a/FuelBoard/HowBestValueSheet.swift +++ b/FuelBoard/HowBestValueSheet.swift @@ -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)) diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index 09351c4..c5b522f 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -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: 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 ) } } diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index 8cde60e..028d834 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -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"; diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift index 37e806b..86dafe8 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift @@ -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?") + } +} diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelStoreTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelStoreTests.swift index fdcffc3..39e4f6e 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelStoreTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelStoreTests.swift @@ -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) + } +} diff --git a/Shared/FuelSavings.swift b/Shared/FuelSavings.swift index 082c14e..d4b833f 100644 --- a/Shared/FuelSavings.swift +++ b/Shared/FuelSavings.swift @@ -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 diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 95e9645..29e72b6 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -701,10 +701,12 @@ 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 .cheapest + return mode } static func saveAlertsMode(_ mode: AlertMode) {