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:
+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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user