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:
+10
-50
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
+208
-118
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user