Compare commits
18
Commits
@@ -533,7 +533,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
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 = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
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 = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
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 = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
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 = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
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 = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
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 = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
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 = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
|
||||
+37
-10
@@ -566,10 +566,34 @@ struct ContentView: View {
|
||||
func source(_ basis: FuelSavingsDistanceBasis) -> String {
|
||||
basis == .roadRoute ? "road route" : "straight-line estimate"
|
||||
}
|
||||
let price = station.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
|
||||
let baselinePrice = baseline?.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
|
||||
func amount(_ value: FuelSavingsFillAmount) -> String {
|
||||
switch value.unit {
|
||||
case .pounds: return String(format: "£%.2f", value.value)
|
||||
case .litres: return String(format: "%.2f L", value.value)
|
||||
}
|
||||
}
|
||||
func rawMoney(_ value: Double?) -> String {
|
||||
value.map { String(format: "£%.6f", $0) } ?? "—"
|
||||
}
|
||||
let candidatePriceValue = station.prices[selectedFuel]
|
||||
let baselinePriceValue = baseline?.prices[selectedFuel]
|
||||
let price = candidatePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
|
||||
let baselinePrice = baselinePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
|
||||
let priceDifference = candidatePriceValue.flatMap { candidate in
|
||||
baselinePriceValue.map { String(format: "%+.1fp/L", $0 - candidate) }
|
||||
} ?? "—"
|
||||
let litresBasis = sessionFuelSavingsAmount.unit == .pounds ? "candidate price" : "fixed litres"
|
||||
let rows: [(String, String)] = [
|
||||
("Station", "\(station.name) (\(station.id))"),
|
||||
("Settings default fill-up", amount(fuelSavingsSettings.preferredAmount)),
|
||||
("Effective/session fill-up", amount(sessionFuelSavingsAmount)),
|
||||
("Fill-up unit", sessionFuelSavingsAmount.unit.displayName),
|
||||
("MPG", String(format: "%.1f UK MPG", fuelSavingsSettings.mpg)),
|
||||
("Candidate price", price),
|
||||
("Baseline price", baselinePrice),
|
||||
("Price difference", priceDifference),
|
||||
("Candidate litres", result.map { String(format: "%.6f L · %@", $0.fillLitres, litresBasis) } ?? "—"),
|
||||
("Gross saving raw", rawMoney(result?.grossSavingGBP)),
|
||||
("Displayed distance", "\(distanceUnit.format(stationDistance.displayDistanceKM)) · \(source(stationDistance.displaySource))"),
|
||||
("Outward road", meters(stationDistance.outwardMeters)),
|
||||
("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"),
|
||||
@@ -581,12 +605,11 @@ struct ContentView: View {
|
||||
("Station journey", miles(journey?.miles)),
|
||||
("Baseline journey", miles(baselineJourney?.miles)),
|
||||
("Additional journey", miles(result?.extraDistanceMiles)),
|
||||
("Additional fuel used", result.map { String(format: "%.6f L", $0.extraFuelLitres) } ?? "—"),
|
||||
("Confidence", result?.journeyConfidence.rawValue ?? "estimated"),
|
||||
("Fuel price", price),
|
||||
("Baseline price", baselinePrice),
|
||||
("Gross saving", result.map { FuelSavingsFormat.money($0.grossSavingGBP) } ?? "—"),
|
||||
("Travel cost", result.map { FuelSavingsFormat.money($0.extraDrivingCostGBP) } ?? "—"),
|
||||
("Net saving", result.map { FuelSavingsFormat.money($0.netSavingGBP) } ?? "—")
|
||||
("Travel cost raw", rawMoney(result?.extraDrivingCostGBP)),
|
||||
("Net saving raw", rawMoney(result?.netSavingGBP)),
|
||||
("Displayed net saving", result?.headline() ?? "—")
|
||||
]
|
||||
return FuelSavingsJourneyDiagnostics(rows: rows)
|
||||
#else
|
||||
@@ -1104,6 +1127,13 @@ struct ContentView: View {
|
||||
ensureBestValueAlertModeIsAvailable()
|
||||
updateAlertMonitor()
|
||||
}
|
||||
.onChange(of: sessionFuelSavingsAmount) { _, _ in
|
||||
// This remains a session-only override: do not write it to FuelStore.
|
||||
// Any active Best Value alert/Live Activity must nevertheless use
|
||||
// the same effective amount as rows, details, favourites and sorting.
|
||||
updateAlertMonitor()
|
||||
updateLiveActivity()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active {
|
||||
// Never start location tracking while onboarding is on screen —
|
||||
@@ -1493,7 +1523,6 @@ struct ContentView: View {
|
||||
fuelSavingsSettings: fuelSavingsSettings,
|
||||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
||||
savingsBaselineStationName: baselineStation?.name,
|
||||
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
||||
onToggleFavourite: toggleFavourite,
|
||||
onRefresh: { await refresh(force: true) }
|
||||
@@ -1515,7 +1544,6 @@ struct ContentView: View {
|
||||
fuelSavingsSettings: fuelSavingsSettings,
|
||||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
||||
savingsBaselineStationName: baselineStation?.name,
|
||||
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
||||
onToggleFavourite: toggleFavourite,
|
||||
onReorder: reorderFavourites,
|
||||
@@ -1914,7 +1942,6 @@ struct StationRow: View {
|
||||
FuelSavingsSummaryView(
|
||||
availability: fuelSavings,
|
||||
amount: fuelSavingsAmount,
|
||||
baselineStationName: nil,
|
||||
compact: true,
|
||||
distanceUnit: distanceUnit
|
||||
)
|
||||
|
||||
@@ -21,10 +21,9 @@ struct FavouritesView: View {
|
||||
/// still never touches the Settings default.
|
||||
let fuelSavingsSettings: FuelSavingsSettings
|
||||
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||
/// Estimated saving for a favourite, against the nearest eligible station —
|
||||
/// the same baseline and definition the Stations list uses.
|
||||
/// Estimated saving for a favourite, against the closest eligible station by
|
||||
/// journey distance — the same baseline and definition the Stations list uses.
|
||||
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||
var savingsBaselineStationName: String?
|
||||
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||
/// Persists a reordered favourites array (after drag-and-drop).
|
||||
@@ -84,7 +83,6 @@ struct FavouritesView: View {
|
||||
fuelSavingsSettings: FuelSavingsSettings = .defaults,
|
||||
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
|
||||
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
|
||||
savingsBaselineStationName: String? = nil,
|
||||
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
|
||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||
@@ -99,7 +97,6 @@ struct FavouritesView: View {
|
||||
self.fuelSavingsSettings = fuelSavingsSettings
|
||||
_fuelSavingsAmount = fuelSavingsAmount
|
||||
self.savingsForStation = savingsForStation
|
||||
self.savingsBaselineStationName = savingsBaselineStationName
|
||||
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
|
||||
self.onToggleFavourite = onToggleFavourite
|
||||
self.onReorder = onReorder
|
||||
@@ -152,7 +149,7 @@ struct FavouritesView: View {
|
||||
referencePricePencePerLitre: cheapestPrice
|
||||
)
|
||||
} footer: {
|
||||
Text(String(localized: "Savings below are estimated against the nearest station with a price for this fuel — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
||||
Text(String(localized: "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +218,6 @@ struct FavouritesView: View {
|
||||
distanceUnit: distanceUnit,
|
||||
fuelSavings: savingsForStation(station),
|
||||
fuelSavingsAmount: fuelSavingsAmount,
|
||||
savingsBaselineStationName: savingsBaselineStationName,
|
||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||
// Every station listed here IS a favourite for the tab's
|
||||
// fuel, so the star starts filled; tapping it unstars.
|
||||
|
||||
@@ -73,11 +73,16 @@ struct FuelSavingsAdjustmentView: View {
|
||||
struct FuelSavingsSummaryView: View {
|
||||
let availability: FuelSavingsAvailability?
|
||||
let amount: FuelSavingsFillAmount
|
||||
let baselineStationName: String?
|
||||
var baselineStationName: String? = nil
|
||||
var compact: Bool = false
|
||||
/// Sizes the "extra return travel" caption in the user's own unit (the
|
||||
/// stored figure is miles) — used by both the row banner and the detail sheet.
|
||||
var distanceUnit: DistanceUnit = .miles
|
||||
var showInfoAction: (() -> Void)? = nil
|
||||
|
||||
private func comparisonLabel(for result: FuelSavingsResult) -> String {
|
||||
FuelSavingsFormat.comparisonLabel(status: result.status)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch availability {
|
||||
@@ -92,8 +97,16 @@ struct FuelSavingsSummaryView: View {
|
||||
// break even, blue baseline. lineLimit + minimumScaleFactor keep
|
||||
// it to one line, so rows stay even.
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
capsule(for: result, compact: true)
|
||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
capsule(for: result, compact: true)
|
||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
||||
Text(comparisonLabel(for: result))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
||||
}
|
||||
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
||||
extraDistanceMiles: result.extraDistanceMiles,
|
||||
unit: distanceUnit,
|
||||
@@ -117,8 +130,19 @@ struct FuelSavingsSummaryView: View {
|
||||
// room for. The old shape led with a status word + headline that
|
||||
// matched neither the listing nor each other.
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
capsule(for: result, compact: false)
|
||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
capsule(for: result, compact: false)
|
||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
||||
Text(comparisonLabel(for: result))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
||||
}
|
||||
LabeledContent("Your fill-up", value: amountText)
|
||||
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
|
||||
.monospacedDigit()
|
||||
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
||||
extraDistanceMiles: result.extraDistanceMiles,
|
||||
unit: distanceUnit,
|
||||
@@ -130,13 +154,30 @@ struct FuelSavingsSummaryView: View {
|
||||
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
|
||||
}
|
||||
if result.status == .baseline {
|
||||
Text("Your closest station with a price for this fuel — every other saving is measured against it.")
|
||||
Text("Your closest eligible station by journey distance is the baseline — every other saving is measured against it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary)
|
||||
LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP))
|
||||
Text("Compared with \(baselineStationName ?? "your closest station").")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
LabeledContent(
|
||||
result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference",
|
||||
value: result.grossSavingGBP >= 0
|
||||
? FuelSavingsFormat.money(result.grossSavingGBP)
|
||||
: "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more"
|
||||
)
|
||||
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
|
||||
Divider()
|
||||
LabeledContent {
|
||||
Text(result.headline())
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(capsuleColor(for: result.status))
|
||||
.monospacedDigit()
|
||||
} label: {
|
||||
Text("Total")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
if result.journeyConfidence.isEstimated {
|
||||
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
|
||||
.font(.caption2)
|
||||
@@ -146,6 +187,22 @@ struct FuelSavingsSummaryView: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let showInfoAction {
|
||||
Rectangle()
|
||||
.fill(Color(.separator))
|
||||
.frame(height: 0.5)
|
||||
.padding(.vertical, 4)
|
||||
Button(action: showInfoAction) {
|
||||
HStack(spacing: 8) {
|
||||
Text("How Best Value works")
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .unavailable(let reason):
|
||||
@@ -197,7 +254,6 @@ struct FuelSavingsSummaryView: View {
|
||||
.accessibilityHidden(true)
|
||||
Text(result.capsuleHeadline())
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.font(compact ? .caption.weight(.semibold) : .headline)
|
||||
.foregroundStyle(capsuleColor(for: result.status))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The "How Best Value works" explanation as its own sheet.
|
||||
///
|
||||
/// It used to sit inline in the Key sheet as a `Section`; it is one shared view
|
||||
/// now so the Key sheet and the More info sheet can both open it, and the copy is
|
||||
/// unchanged (the same `Localizable.strings` keys are reused — the old section
|
||||
/// header string is the sheet's title).
|
||||
struct HowBestValueSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Best Value compares each station with the closest eligible station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
|
||||
Text("Your fill-up amount and MPG are used to show whether you could save overall.")
|
||||
Text("Results are marked “Approx.” until road distances are available.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("How Best Value works")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.fraction(0.42)])
|
||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||
}
|
||||
}
|
||||
|
||||
/// The row that opens `HowBestValueSheet`. A tappable row with a chevron, like
|
||||
/// the other rows in both sheets. `.buttonStyle(.plain)` keeps it from becoming a
|
||||
/// row-wide tap target that could swallow a neighbouring control (see the
|
||||
/// two-buttons-in-one-row rule).
|
||||
struct HowBestValueRow: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Text("How Best Value works")
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// QA hook support: `-showBestValueInfo` opens this sheet without a tap, so it can
|
||||
/// be screenshotted (simctl has no tap command) — the same pattern as
|
||||
/// `-showKeySheet` / `-showDetail`.
|
||||
enum BestValueInfoQA {
|
||||
static var isRequested: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains("-showBestValueInfo")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -98,7 +98,7 @@ struct OnboardingView: View {
|
||||
private var welcomePage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
AnimatedOnboardingIcon(tint: .accentColor) {
|
||||
AnimatedOnboardingIcon(tint: .accentColor, showsHalo: false) {
|
||||
// The app icon itself, with the standard iOS rounded-corner
|
||||
// treatment (~22% of the size), so onboarding matches the
|
||||
// Home Screen.
|
||||
@@ -108,9 +108,9 @@ struct OnboardingView: View {
|
||||
.frame(width: 96, height: 96)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Text(String(localized: "Welcome to FuelBoard", comment: "Onboarding welcome title"))
|
||||
Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title"))
|
||||
.font(.largeTitle.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -121,12 +121,13 @@ struct OnboardingView: View {
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 12)
|
||||
|
||||
featureRow(icon: "globe.europe.africa.fill", text: String(localized: "UK-wide prices — 8,000+ stations, updated twice a day", comment: "Onboarding feature row"))
|
||||
featureRow(icon: "scope", text: String(localized: "Cheapest within your chosen radius, or closest station first", comment: "Onboarding feature row"))
|
||||
featureRow(icon: "star.fill", text: String(localized: "Favourites with instant price comparison", comment: "Onboarding feature row"))
|
||||
featureRow(icon: "chart.bar.fill", text: String(localized: "Green/amber/red rating vs the best nearby price", comment: "Onboarding feature row"))
|
||||
featureRow(icon: "square.grid.2x2.fill", text: String(localized: "Home-screen widget showing the cheapest nearby", comment: "Onboarding feature row"))
|
||||
featureRow(icon: "bell.fill", text: String(localized: "Alerts when you approach the cheapest station", comment: "Onboarding feature row"))
|
||||
featureRow(icon: "map.fill", title: String(localized: "Compare fuel prices near you", comment: "Onboarding feature row title"), subtitle: String(localized: "Over 8,000 stations across Great Britain, with prices updated daily.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
featureRow(icon: "chart.line.uptrend.xyaxis", title: String(localized: "See how much you could save", comment: "Onboarding feature row title"), subtitle: String(localized: "Compare fill-up costs and potential savings at different stations.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
featureRow(icon: "car.fill", title: String(localized: "Know if it's worth the drive", comment: "Onboarding feature row title"), subtitle: String(localized: "Factor in your journey and fuel costs to find the best value.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
featureRow(icon: "star.fill", title: String(localized: "Keep your favourites close", comment: "Onboarding feature row title"), subtitle: String(localized: "Save stations and quickly compare their latest prices.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
@@ -155,7 +156,7 @@ struct OnboardingView: View {
|
||||
permissionStatusLabel(
|
||||
granted: prompter.locationGranted,
|
||||
denied: prompter.locationDenied,
|
||||
deniedText: String(localized: "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it.", comment: "Onboarding location denied text")
|
||||
deniedText: String(localized: "Location was denied. You can still browse all stations, but \"closest\" sorting needs it.", comment: "Onboarding location denied text")
|
||||
)
|
||||
.padding(.top, 24)
|
||||
|
||||
@@ -298,18 +299,23 @@ struct OnboardingView: View {
|
||||
OnboardingPrimaryButton(title: title, action: action)
|
||||
}
|
||||
|
||||
private func featureRow(icon: String, text: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
private func featureRow(icon: String, title: String, subtitle: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 32)
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.bold())
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 36)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
|
||||
@@ -393,6 +399,9 @@ struct AnimatedOnboardingIcon<Content: View>: View {
|
||||
|
||||
let tint: Color
|
||||
var motion: Motion = .gentle
|
||||
/// The soft circle behind the glyph. The welcome page turns it off: it shows
|
||||
/// the app icon itself and reads better with no container around it.
|
||||
var showsHalo: Bool = true
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@State private var settled = false
|
||||
@@ -402,16 +411,18 @@ struct AnimatedOnboardingIcon<Content: View>: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(tint.opacity(0.10))
|
||||
.frame(width: 108, height: 108)
|
||||
.scaleEffect(settled ? 1.0 : 0.94)
|
||||
if showsHalo {
|
||||
Circle()
|
||||
.fill(tint.opacity(0.10))
|
||||
.frame(width: 108, height: 108)
|
||||
.scaleEffect(settled ? 1.0 : 0.94)
|
||||
|
||||
Circle()
|
||||
.stroke(tint.opacity(0.18), lineWidth: 1)
|
||||
.frame(width: 118, height: 118)
|
||||
.scaleEffect(settled ? 1.03 : 0.97)
|
||||
.opacity(settled ? 1.0 : 0.55)
|
||||
Circle()
|
||||
.stroke(tint.opacity(0.18), lineWidth: 1)
|
||||
.frame(width: 118, height: 118)
|
||||
.scaleEffect(settled ? 1.03 : 0.97)
|
||||
.opacity(settled ? 1.0 : 0.55)
|
||||
}
|
||||
|
||||
content
|
||||
.scaleEffect(iconScale)
|
||||
|
||||
@@ -618,7 +618,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
let content = UNMutableNotificationContent()
|
||||
content.sound = .default
|
||||
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
|
||||
content.body = "\(station.name) could leave you \(result.headline()). Tap for directions."
|
||||
content.body = "\(station.name): \(result.headline()). Tap for directions."
|
||||
addAlertRequest(content: content, station: station)
|
||||
logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())")
|
||||
lastAlert = "Best Value · \(brand) · \(result.headline())"
|
||||
@@ -923,7 +923,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
content.sound = .default
|
||||
let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand
|
||||
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
|
||||
content.body = "\(candidate.station.name) could leave you \(candidate.result.headline()). Tap for directions."
|
||||
content.body = "\(candidate.station.name): \(candidate.result.headline()). Tap for directions."
|
||||
let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))"
|
||||
lastTestResult = baseResult
|
||||
logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())")
|
||||
|
||||
@@ -285,7 +285,7 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("Debug")
|
||||
} footer: {
|
||||
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
|
||||
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
|
||||
}
|
||||
.onAppear { onRefreshDebugStatus() }
|
||||
|
||||
|
||||
@@ -29,10 +29,13 @@ struct StationDetailView: View {
|
||||
var onToggleFavourite: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showingReportIssueConfirmation = false
|
||||
/// Transient "Copied" confirmation on the Address row. Cleared by a task so
|
||||
/// Transient "Postcode copied" confirmation on the Address row. Cleared by a task so
|
||||
/// the caption fades out on its own.
|
||||
@State private var showingCopied = false
|
||||
@State private var copyResetTask: Task<Void, Never>?
|
||||
/// The "How Best Value works" sheet, opened from the row at the bottom of the
|
||||
/// Best Value card (the Key sheet opens the same shared view).
|
||||
@State private var showBestValueInfo = false
|
||||
|
||||
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
||||
|
||||
@@ -134,7 +137,7 @@ struct StationDetailView: View {
|
||||
// The whole row is a copy control: it puts JUST the postcode
|
||||
// on the clipboard so it can be pasted into another
|
||||
// navigation app. The copy glyph sits ON the address line,
|
||||
// and the "Copied" confirmation is a capsule that floats OVER
|
||||
// and the "Postcode copied" confirmation is a capsule that floats OVER
|
||||
// THIS ROW — it confirms the address, so it must not hover
|
||||
// over the map. It is an overlay, so nothing moves while it is
|
||||
// up, and `allowsHitTesting(false)` keeps the row tappable
|
||||
@@ -169,7 +172,7 @@ struct StationDetailView: View {
|
||||
if showingCopied {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark")
|
||||
Text("Copied")
|
||||
Text("Postcode copied")
|
||||
}
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.green)
|
||||
@@ -235,7 +238,8 @@ struct StationDetailView: View {
|
||||
availability: fuelSavings,
|
||||
amount: fuelSavingsAmount,
|
||||
baselineStationName: savingsBaselineStationName,
|
||||
distanceUnit: distanceUnit
|
||||
distanceUnit: distanceUnit,
|
||||
showInfoAction: { showBestValueInfo = true }
|
||||
)
|
||||
} header: {
|
||||
Text("Best Value")
|
||||
@@ -265,6 +269,8 @@ struct StationDetailView: View {
|
||||
} message: {
|
||||
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
|
||||
}
|
||||
// Nested sheet: the More info sheet stays put behind the explanation.
|
||||
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
@@ -284,12 +290,12 @@ struct StationDetailView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
// The "Copied" fade-out task must not outlive the sheet.
|
||||
// The "Postcode copied" fade-out task must not outlive the sheet.
|
||||
.onDisappear { copyResetTask?.cancel() }
|
||||
#if DEBUG
|
||||
// QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the
|
||||
// clipboard value (read back with `xcrun simctl pbpaste`) and the
|
||||
// "Copied" confirmation can be verified without a tap — simctl
|
||||
// "Postcode copied" confirmation can be verified without a tap — simctl
|
||||
// cannot drive taps into the Simulator.
|
||||
.task {
|
||||
guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return }
|
||||
|
||||
@@ -19,7 +19,6 @@ struct StationsView: View {
|
||||
let fuelSavingsSettings: FuelSavingsSettings
|
||||
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||
var savingsBaselineStationName: String?
|
||||
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||
var onRefresh: () async -> Void = {}
|
||||
@@ -35,6 +34,11 @@ struct StationsView: View {
|
||||
/// info button in the header — keeps the list focused on stations.
|
||||
@State private var showKey = false
|
||||
|
||||
/// The "How Best Value works" sheet. The explanation used to be a section in
|
||||
/// the Key sheet; it is its own sheet now, opened from a row at the bottom of
|
||||
/// the Key sheet and from the More info sheet.
|
||||
@State private var showBestValueInfo = false
|
||||
|
||||
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||
@State private var detailStation: FuelStation?
|
||||
|
||||
@@ -63,7 +67,7 @@ struct StationsView: View {
|
||||
}
|
||||
if location != nil {
|
||||
if sortMode == .closest {
|
||||
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||
return "\(mode) \(fuel) stations — closest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||
}
|
||||
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
|
||||
}
|
||||
@@ -89,7 +93,7 @@ struct StationsView: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
// In Closest mode the whole country is the pool — the
|
||||
// radius is meaningless ("nearest" must never return an
|
||||
// radius is meaningless ("closest" must never return an
|
||||
// empty state), so the picker is disabled but its value is
|
||||
// kept for when the user switches back to Cheapest.
|
||||
.disabled(sortMode == .closest)
|
||||
@@ -218,6 +222,18 @@ struct StationsView: View {
|
||||
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
||||
showKey = true
|
||||
}
|
||||
// QA hook: `-showBestValueInfo` opens the Key sheet and then the
|
||||
// explanation on top of it, so the copy can be screenshotted
|
||||
// without a tap. It has to chain: the explanation's `.sheet` lives
|
||||
// inside the Key sheet's content, so it cannot present while the
|
||||
// Key sheet is shut.
|
||||
if BestValueInfoQA.isRequested {
|
||||
showKey = true
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
showBestValueInfo = true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.task(id: stations.count) {
|
||||
@@ -249,15 +265,15 @@ struct StationsView: View {
|
||||
VStack(spacing: 10) {
|
||||
keyCard(
|
||||
icon: "checkmark.circle.fill", color: .green,
|
||||
title: "Best value", blurb: "Within 1.5p of the cheapest",
|
||||
threshold: "≤1.5p")
|
||||
title: "Good price", blurb: "Within 1.5p of the cheapest",
|
||||
threshold: "0–1.5p")
|
||||
keyCard(
|
||||
icon: "equal.circle.fill", color: .orange,
|
||||
title: "Okay", blurb: "Within 4p of the cheapest",
|
||||
threshold: "≤4p")
|
||||
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 over the cheapest",
|
||||
title: "Pricey", blurb: "More than 4p above the cheapest",
|
||||
threshold: ">4p")
|
||||
Rectangle()
|
||||
.fill(Color(.separator))
|
||||
@@ -289,15 +305,27 @@ struct StationsView: View {
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.keyCardFill(color: .yellow)
|
||||
Text("Colours match each station's rating on the list")
|
||||
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)
|
||||
.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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Key")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -306,6 +334,8 @@ struct StationsView: View {
|
||||
Button("Done") { showKey = false }
|
||||
}
|
||||
}
|
||||
// Nested sheet: the Key sheet stays behind the explanation.
|
||||
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
|
||||
}
|
||||
.presentationDetents([.fraction(0.6)])
|
||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||
@@ -317,7 +347,6 @@ struct StationsView: View {
|
||||
distanceUnit: distanceUnit,
|
||||
fuelSavings: savingsForStation(station),
|
||||
fuelSavingsAmount: fuelSavingsAmount,
|
||||
savingsBaselineStationName: savingsBaselineStationName,
|
||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||
isFavourite: favouriteIDs.contains(station.id),
|
||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
||||
@@ -347,6 +376,7 @@ struct StationsView: View {
|
||||
Text(LocalizedStringKey(blurb))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
HStack(spacing: 6) {
|
||||
|
||||
@@ -76,7 +76,7 @@ struct WidgetMockScreen: View {
|
||||
let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
|
||||
// 5 miles — the widget config's default Distance. WidgetDistance itself
|
||||
// lives in the widget target, so the preview mirrors its default here.
|
||||
let radiusKM = 5 * 1.60934
|
||||
let radiusKM = 5 * FuelSavingsCalculator.kilometresPerMile
|
||||
let all = FuelStore.loadStations()
|
||||
let selling = all.filter { station in
|
||||
guard station.prices[fuel] != nil else { return false }
|
||||
@@ -94,7 +94,8 @@ struct WidgetMockScreen: View {
|
||||
isFavourites: false,
|
||||
location: location,
|
||||
locationSource: location != nil ? "live" : "none",
|
||||
unit: FuelStore.loadDistanceUnit()
|
||||
unit: FuelStore.loadDistanceUnit(),
|
||||
bestValueResult: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"TOP" = "TOP";
|
||||
|
||||
/* Stations tab */
|
||||
"Closest %@ stations — nearest first, best value within %lld %@ · %lld stations" = "Closest %@ stations — nearest first, best value within %lld %@ · %lld stations";
|
||||
"Closest %@ stations — closest first, best value within %lld %@ · %lld stations" = "Closest %@ stations — closest first, best value within %lld %@ · %lld stations";
|
||||
"Cheapest %@ within %lld %@ — %lld stations · tap a station for directions." = "Cheapest %@ within %lld %@ — %lld stations · tap a station for directions.";
|
||||
"%@ %@ — tap a station for directions." = "%@ %@ — tap a station for directions.";
|
||||
"Fuel type" = "Fuel type";
|
||||
@@ -23,18 +23,22 @@
|
||||
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
|
||||
"All %lld stations shown" = "All %lld stations shown";
|
||||
"Key" = "Key";
|
||||
"Best value" = "Best value";
|
||||
"Good price" = "Good price";
|
||||
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
|
||||
"Okay" = "Okay";
|
||||
"Within 4p of the cheapest" = "Within 4p of the cheapest";
|
||||
"More than 1.5p, up to 4p above the cheapest" = "More than 1.5p, up to 4p above the cheapest";
|
||||
"Pricey" = "Pricey";
|
||||
"More than 4p over the cheapest" = "More than 4p over the cheapest";
|
||||
"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";
|
||||
/* Star button in the More info sheet heading (accessibility labels) */
|
||||
"Add to favourites" = "Add to favourites";
|
||||
"Remove from favourites" = "Remove from favourites";
|
||||
"Colours match each station's rating on the list" = "Colours match each station's rating on the list";
|
||||
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
|
||||
"How Best Value works" = "How Best Value works";
|
||||
"Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
|
||||
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
|
||||
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
|
||||
|
||||
/* Favourites tab */
|
||||
"No favourites yet" = "No favourites yet";
|
||||
@@ -67,8 +71,8 @@
|
||||
"Name" = "Name";
|
||||
"Address" = "Address";
|
||||
"Distance" = "Distance";
|
||||
"Copied" = "Copied";
|
||||
"Copies the postcode" = "Copies the postcode";
|
||||
"Postcode copied" = "Postcode copied";
|
||||
"Address: %@" = "Address: %@";
|
||||
"Directions to %@" = "Directions to %@";
|
||||
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
|
||||
@@ -77,7 +81,7 @@
|
||||
"Test alert notification (real data)" = "Test alert notification (real data)";
|
||||
"Send plain test notification" = "Send plain test notification";
|
||||
"Debug" = "Debug";
|
||||
"The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius." = "The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.";
|
||||
"The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius." = "The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.";
|
||||
"Widget Diagnostics" = "Widget Diagnostics";
|
||||
"Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter." = "Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter.";
|
||||
"SMALL: %@" = "SMALL: %@";
|
||||
@@ -135,7 +139,7 @@
|
||||
"Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day." = "Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day.";
|
||||
"Prices near you" = "Prices near you";
|
||||
"FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device." = "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.";
|
||||
"Location was denied. You can still browse all stations, but \"nearest\" sorting needs it." = "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it.";
|
||||
"Location was denied. You can still browse all stations, but \"closest\" sorting needs it." = "Location was denied. You can still browse all stations, but \"closest\" sorting needs it.";
|
||||
"Allowed" = "Allowed";
|
||||
"The system prompt will appear next." = "The system prompt will appear next.";
|
||||
"FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab." = "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab.";
|
||||
@@ -199,6 +203,7 @@
|
||||
"Cheapest station" = "Cheapest station";
|
||||
"Alert type" = "Alert type";
|
||||
"Best Value" = "Best Value";
|
||||
"Total" = "Total";
|
||||
"Minimum saving" = "Minimum saving";
|
||||
"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.";
|
||||
"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.";
|
||||
|
||||
@@ -211,10 +211,10 @@ final class DistanceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testMilesConversion() {
|
||||
// 5 miles ≈ 8.05 km (app uses × 1.60934)
|
||||
XCTAssertEqual(5 * 1.60934, 8.0467, accuracy: 0.001)
|
||||
// 262 km ≈ 162.8 miles (display uses × 0.621371)
|
||||
XCTAssertEqual(262 * 0.621371, 162.8, accuracy: 0.1)
|
||||
// 5 miles ≈ 8.05 km using the shared exact conversion
|
||||
XCTAssertEqual(5 * FuelSavingsCalculator.kilometresPerMile, 8.04672, accuracy: 0.001)
|
||||
// 262 km ≈ 162.8 miles using the shared exact conversion
|
||||
XCTAssertEqual(262 / FuelSavingsCalculator.kilometresPerMile, 162.8, accuracy: 0.1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,14 +267,14 @@ final class FuelTypeLabelTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testDistanceUnitConversion() {
|
||||
XCTAssertEqual(DistanceUnit.miles.toKM(1), 1.60934, accuracy: 0.00001)
|
||||
XCTAssertEqual(DistanceUnit.miles.toKM(1), 1.609344, accuracy: 0.00001)
|
||||
XCTAssertEqual(DistanceUnit.kilometers.toKM(5), 5)
|
||||
XCTAssertEqual(DistanceUnit.miles.fromKM(1.60934), 1, accuracy: 0.00001)
|
||||
XCTAssertEqual(DistanceUnit.miles.fromKM(1.609344), 1, accuracy: 0.00001)
|
||||
XCTAssertEqual(DistanceUnit.kilometers.fromKM(2.5), 2.5)
|
||||
}
|
||||
|
||||
func testDistanceUnitFormat() {
|
||||
XCTAssertEqual(DistanceUnit.miles.format(1.60934), "1.0 mi")
|
||||
XCTAssertEqual(DistanceUnit.miles.format(1.609344), "1.0 mi")
|
||||
XCTAssertEqual(DistanceUnit.kilometers.format(3.4), "3.4 km")
|
||||
}
|
||||
}
|
||||
@@ -400,7 +400,7 @@ final class AlertRadiusFollowTests: XCTestCase {
|
||||
FuelStore.saveDistanceUnit(.miles)
|
||||
FuelStore.saveStationLimit(5)
|
||||
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: true, manualKM: 3)
|
||||
XCTAssertEqual(eff, 5 * 1.60934, accuracy: 0.001)
|
||||
XCTAssertEqual(eff, 5 * FuelSavingsCalculator.kilometresPerMile, accuracy: 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,7 +731,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
||||
sourceLat: sourceLat, sourceLng: sourceLng,
|
||||
entries: ["far": .init(meters: 23_500, lat: 51.5074, lng: -0.1278, returnMeters: 23_500)])
|
||||
|
||||
let tenMilesKM = 10 * 1.60934
|
||||
let tenMilesKM = 10 * FuelSavingsCalculator.kilometresPerMile
|
||||
XCTAssertLessThan(far.distanceKM(to: sourceLat, lng2: sourceLng), tenMilesKM,
|
||||
"fixture: the straight line must sit INSIDE the 10-mile radius")
|
||||
XCTAssertGreaterThan(FuelStore.displayDistanceKM(station: far, userLat: sourceLat, userLng: sourceLng),
|
||||
@@ -743,7 +743,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
||||
userLat: sourceLat, userLng: sourceLng),
|
||||
"a station whose row reads 14.6 mi must not appear in a 10-mile list")
|
||||
// The same station is back once the radius actually covers its distance.
|
||||
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: far, radiusKM: 15 * 1.60934,
|
||||
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: far, radiusKM: 15 * FuelSavingsCalculator.kilometresPerMile,
|
||||
userLat: sourceLat, userLng: sourceLng))
|
||||
// …and an unknown station (no cached route) still tests its displayed
|
||||
// straight line, so nothing changes for the bulk of the list.
|
||||
@@ -783,10 +783,10 @@ final class RoadDistanceCacheTests: XCTestCase {
|
||||
XCTAssertFalse(confirmed.isApproximate, "a cached road distance is not an approximation")
|
||||
XCTAssertEqual(confirmed.km, 3.2, accuracy: 0.0001)
|
||||
XCTAssertEqual(confirmed.label(unit: .miles), "2.0 mi")
|
||||
XCTAssertEqual(confirmed.labelWithReturn(unit: .miles), "2.0 mi (4.0 mi return)")
|
||||
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.4 km return)")
|
||||
XCTAssertEqual(confirmed.labelWithReturn(unit: .miles), "2.0 mi (4.2 mi return)")
|
||||
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.8 km return)")
|
||||
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles")
|
||||
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.0 miles return")
|
||||
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.2 miles return")
|
||||
|
||||
let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1)
|
||||
XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one")
|
||||
@@ -794,11 +794,40 @@ final class RoadDistanceCacheTests: XCTestCase {
|
||||
XCTAssertEqual(approximate.label(unit: .miles),
|
||||
"Approx. " + DistanceUnit.miles.format(approximate.km))
|
||||
XCTAssertEqual(approximate.labelWithReturn(unit: .miles),
|
||||
"Approx. \(DistanceUnit.miles.format(approximate.km)) (\(DistanceUnit.miles.format(approximate.km * 2)) return)")
|
||||
"Approx. " + DistanceUnit.miles.format(approximate.km) + " (Approx. " + DistanceUnit.miles.format(approximate.km * 2) + " return)")
|
||||
XCTAssertEqual(approximate.spokenLabel(unit: .kilometers),
|
||||
"Approximately " + DistanceUnit.kilometers.spoken(approximate.km))
|
||||
XCTAssertEqual(approximate.spokenLabelWithReturn(unit: .kilometers),
|
||||
"Approximately \(DistanceUnit.kilometers.spoken(approximate.km)), \(DistanceUnit.kilometers.spoken(approximate.km * 2)) return")
|
||||
"Approximately \(DistanceUnit.kilometers.spoken(approximate.km)), Approximately \(DistanceUnit.kilometers.spoken(approximate.km * 2)) return")
|
||||
}
|
||||
|
||||
func testDisplayedReturnUsesTheIndependentlyMeasuredReturnRoute() {
|
||||
FuelStore.setAccurateDistancesEnabled(true)
|
||||
let station = station("separate-legs", 51.5074, -0.1278)
|
||||
FuelStore.saveRoadDistances(
|
||||
sourceLat: 51.6,
|
||||
sourceLng: -0.1,
|
||||
entries: ["separate-legs": .init(
|
||||
meters: 4_650,
|
||||
lat: station.lat,
|
||||
lng: station.lng,
|
||||
returnMeters: 4_820
|
||||
)],
|
||||
complete: true
|
||||
)
|
||||
|
||||
let display = FuelStore.stationDistanceDisplay(
|
||||
station: station,
|
||||
userLat: 51.6,
|
||||
userLng: -0.1
|
||||
)
|
||||
|
||||
XCTAssertEqual(display.km, 4.65, accuracy: 1e-12)
|
||||
XCTAssertEqual(display.returnKM, 9.47, accuracy: 1e-12)
|
||||
XCTAssertFalse(display.isApproximate)
|
||||
XCTAssertFalse(display.returnIsApproximate)
|
||||
XCTAssertEqual(display.labelWithReturn(unit: .miles), "2.9 mi (5.9 mi return)")
|
||||
XCTAssertEqual(display.spokenLabelWithReturn(unit: .miles), "2.9 miles, 5.9 miles return")
|
||||
}
|
||||
|
||||
/// The marker tracks the cache on its own: same station, bare figure once its
|
||||
@@ -961,7 +990,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
||||
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
|
||||
XCTAssertTrue(display.isApproximate, "the browse layer must never serve routes with the mode off")
|
||||
XCTAssertEqual(display.km, routed.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * 1.60934,
|
||||
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * FuelSavingsCalculator.kilometresPerMile,
|
||||
userLat: 51.6, userLng: -0.1),
|
||||
"with the mode off the radius is a straight-line radius, agreeing with the rows")
|
||||
|
||||
@@ -1000,7 +1029,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
||||
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
|
||||
XCTAssertFalse(display.isApproximate)
|
||||
XCTAssertEqual(display.km, 23.5, accuracy: 0.0001)
|
||||
XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * 1.60934,
|
||||
XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * FuelSavingsCalculator.kilometresPerMile,
|
||||
userLat: 51.6, userLng: -0.1),
|
||||
"in this mode a 10-mile radius is 10 miles of driving, matching the routed rows")
|
||||
}
|
||||
|
||||
@@ -82,18 +82,84 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344))
|
||||
}
|
||||
|
||||
// MARK: - 2/4. The three stations from the brief
|
||||
// MARK: - 2. Candidate-priced fill-up
|
||||
|
||||
func testPoundsFillUsesCandidatePriceForLitresAndGrossSaving() throws {
|
||||
let result = try FuelSavingsCalculator.calculate(
|
||||
mpg: 30,
|
||||
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||
baselinePricePencePerLitre: 171.9,
|
||||
candidatePricePencePerLitre: 169.9,
|
||||
baselineJourneyMiles: 0.879,
|
||||
candidateJourneyMiles: 8.306
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.fillLitres, 60 / 1.699, accuracy: 1e-9)
|
||||
XCTAssertEqual(result.grossSavingGBP, (60 / 1.699) * 0.02, accuracy: 1e-9)
|
||||
XCTAssertEqual(FuelSavingsFormat.money(result.grossSavingGBP), "£0.71")
|
||||
XCTAssertEqual(result.extraFuelLitres, 1.1254603476666667, accuracy: 1e-9)
|
||||
XCTAssertEqual(result.extraDrivingCostGBP, 1.9121571306856667, accuracy: 1e-9)
|
||||
XCTAssertEqual(result.netSavingGBP, -1.2058593084372853, accuracy: 1e-9)
|
||||
XCTAssertEqual(result.headline(), "Costs £1.21 more")
|
||||
}
|
||||
|
||||
func testPoundsExamplesScaleFromThirtyToOneHundredPounds() throws {
|
||||
for (budget, expectedLitres, expectedGross) in [
|
||||
(30.0, 17.657445556209535, 0.3531489111241907),
|
||||
(60.0, 35.31489111241907, 0.7062978222483814),
|
||||
(100.0, 58.85815185403179, 1.1771630370806357)
|
||||
] {
|
||||
let result = try FuelSavingsCalculator.calculate(
|
||||
mpg: 30,
|
||||
amount: FuelSavingsFillAmount(value: budget, unit: .pounds),
|
||||
baselinePricePencePerLitre: 171.9,
|
||||
candidatePricePencePerLitre: 169.9,
|
||||
baselineJourneyMiles: 0,
|
||||
candidateJourneyMiles: 0
|
||||
)
|
||||
XCTAssertEqual(result.fillLitres, expectedLitres, accuracy: 1e-8)
|
||||
XCTAssertEqual(result.grossSavingGBP, expectedGross, accuracy: 1e-8)
|
||||
}
|
||||
}
|
||||
|
||||
func testCandidatePriceControlsLitresWhileBaselinePriceOnlyChangesSaving() throws {
|
||||
let amount = FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||
let first = try FuelSavingsCalculator.calculate(
|
||||
mpg: 30, amount: amount,
|
||||
baselinePricePencePerLitre: 171.9,
|
||||
candidatePricePencePerLitre: 169.9,
|
||||
baselineJourneyMiles: 0, candidateJourneyMiles: 0
|
||||
)
|
||||
let differentBaseline = try FuelSavingsCalculator.calculate(
|
||||
mpg: 30, amount: amount,
|
||||
baselinePricePencePerLitre: 179.9,
|
||||
candidatePricePencePerLitre: 169.9,
|
||||
baselineJourneyMiles: 0, candidateJourneyMiles: 0
|
||||
)
|
||||
let differentCandidate = try FuelSavingsCalculator.calculate(
|
||||
mpg: 30, amount: amount,
|
||||
baselinePricePencePerLitre: 179.9,
|
||||
candidatePricePencePerLitre: 159.9,
|
||||
baselineJourneyMiles: 0, candidateJourneyMiles: 0
|
||||
)
|
||||
|
||||
XCTAssertEqual(first.fillLitres, differentBaseline.fillLitres, accuracy: 1e-12)
|
||||
XCTAssertNotEqual(first.grossSavingGBP, differentBaseline.grossSavingGBP)
|
||||
XCTAssertNotEqual(differentBaseline.fillLitres, differentCandidate.fillLitres)
|
||||
}
|
||||
|
||||
// MARK: - 3/5. The three stations from the brief
|
||||
|
||||
func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws {
|
||||
let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0)
|
||||
|
||||
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.grossSavingGBP, 0.349040, accuracy: 1e-6, "1p/litre cheaper on a 34.90 L fill")
|
||||
XCTAssertEqual(result.fillLitres, 35.108250, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.grossSavingGBP, 0.351083, accuracy: 1e-6, "£60 buys 35.11 L at the candidate price")
|
||||
XCTAssertEqual(result.extraDistanceMiles, 2.0, accuracy: 1e-9)
|
||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.netSavingGBP, -0.168911, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.netSavingGBP, -0.166869, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||
XCTAssertEqual(result.headline(), "£0.17 worse off after travel")
|
||||
XCTAssertEqual(result.headline(), "Costs £0.17 more")
|
||||
}
|
||||
|
||||
/// The brief recorded "£0.18 worse off" for this row against a displayed
|
||||
@@ -104,9 +170,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
/// is entirely the rounding of the distance, not of the money.
|
||||
func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws {
|
||||
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(),
|
||||
"£0.17 worse off after travel")
|
||||
"Costs £0.17 more")
|
||||
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(),
|
||||
"£0.18 worse off after travel")
|
||||
"Costs £0.18 more")
|
||||
|
||||
// 2.05 mi shows as "2.0 mi" at one decimal place — the same caption.
|
||||
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles),
|
||||
@@ -118,13 +184,13 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
func testBriefCaseMFGHareAndHoundsIsWorseOff() throws {
|
||||
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
|
||||
|
||||
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.grossSavingGBP, -0.349040, accuracy: 1e-6, "1p/litre dearer costs 34.90 L × 1p")
|
||||
XCTAssertEqual(result.fillLitres, 34.702140, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.grossSavingGBP, -0.347021, accuracy: 1e-6, "£60 buys 34.70 L at the candidate price")
|
||||
XCTAssertEqual(result.extraDistanceMiles, 2.8, accuracy: 1e-9)
|
||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||
XCTAssertEqual(result.headline(), "£1.08 worse off after travel")
|
||||
XCTAssertEqual(result.headline(), "Costs £1.08 more")
|
||||
}
|
||||
|
||||
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
|
||||
@@ -135,7 +201,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6,
|
||||
"with no price difference the whole result is the extra driving")
|
||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||
XCTAssertEqual(result.headline(), "£0.73 worse off after travel")
|
||||
XCTAssertEqual(result.headline(), "Costs £0.73 more")
|
||||
}
|
||||
|
||||
// MARK: - 5/6. Classification, tolerance and rounding
|
||||
@@ -150,15 +216,15 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws {
|
||||
// 171.9p vs 170.9p over a 1.23 mi delta: net +£0.0305.
|
||||
// 171.9p vs 170.9p over a 1.23 mi delta: the small saving remains within tolerance.
|
||||
let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23)
|
||||
XCTAssertEqual(result.netSavingGBP, 0.030500, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.netSavingGBP, 0.03254252417853143, accuracy: 1e-9)
|
||||
XCTAssertEqual(result.status, .breakEven, "±5p reads as break even rather than a meaningless £0.03")
|
||||
XCTAssertEqual(result.headline(), "Break even")
|
||||
}
|
||||
|
||||
func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws {
|
||||
let net = -0.168911
|
||||
let net = -0.166869
|
||||
// A tolerance wider than the loss → break even; narrower → worse off.
|
||||
let lenient = try FuelSavingsCalculator.calculate(
|
||||
mpg: 30,
|
||||
@@ -187,7 +253,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
func testMoneyIsRoundedOnlyForDisplay() throws {
|
||||
// Full precision retained internally…
|
||||
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
|
||||
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
|
||||
XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded")
|
||||
// …and rounded to two places only when shown.
|
||||
XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08")
|
||||
@@ -197,6 +263,23 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
XCTAssertEqual(FuelSavingsFormat.money(0), "£0.00")
|
||||
}
|
||||
|
||||
func testIndependentlyRoundedFinancialComponentsMayDifferFromRoundedTotalByOnePenny() {
|
||||
let gross = 1.234
|
||||
let drivingCost = 0.005
|
||||
let net = gross - drivingCost
|
||||
|
||||
XCTAssertEqual(FuelSavingsFormat.money(gross), "£1.23")
|
||||
XCTAssertEqual(FuelSavingsFormat.money(drivingCost), "£0.01")
|
||||
XCTAssertEqual(FuelSavingsFormat.money(net), "£1.23")
|
||||
let independentlyRoundedSubtraction = 1.23 - 0.01
|
||||
XCTAssertEqual(independentlyRoundedSubtraction, 1.22, accuracy: 1e-12)
|
||||
XCTAssertNotEqual(
|
||||
FuelSavingsFormat.money(independentlyRoundedSubtraction),
|
||||
FuelSavingsFormat.money(net),
|
||||
"the raw total remains authoritative; independently rounded components need not add back exactly"
|
||||
)
|
||||
}
|
||||
|
||||
func testSortingUsesTheUnroundedNetSoRoundedTiesStillOrderCorrectly() {
|
||||
// Two rows that both DISPLAY "£0.18 worse off" but differ in the raw
|
||||
// figure: the sort must use the raw value, never the shown one.
|
||||
@@ -237,12 +320,12 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
baselineJourneyMiles: 2.0,
|
||||
candidateJourneyMiles: 7.0
|
||||
)
|
||||
XCTAssertEqual(result.fillLitres, 27.416038, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.grossSavingGBP, 1.919123, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.fillLitres, 28.797696, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.grossSavingGBP, 2.015839, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.netSavingGBP, 1.217509, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.netSavingGBP, 1.314226, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.status, .worthTheDrive)
|
||||
XCTAssertEqual(result.headline(), "£1.22 better off after travel")
|
||||
XCTAssertEqual(result.headline(), "Save £1.31")
|
||||
}
|
||||
|
||||
func testNearerCandidateIsNeverChargedExtraTravel() throws {
|
||||
@@ -270,9 +353,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
baselineJourneyMiles: 0.8,
|
||||
candidateJourneyMiles: 3.6
|
||||
)
|
||||
XCTAssertEqual(sixty.grossSavingGBP, -0.349040, accuracy: 1e-6, "MPG must not touch the pump saving")
|
||||
XCTAssertEqual(sixty.grossSavingGBP, -0.347021, accuracy: 1e-6, "MPG must not touch the pump saving")
|
||||
XCTAssertEqual(sixty.extraDrivingCostGBP, 0.366809, accuracy: 1e-6)
|
||||
XCTAssertEqual(sixty.netSavingGBP, -0.715849, accuracy: 1e-6)
|
||||
XCTAssertEqual(sixty.netSavingGBP, -0.713830, accuracy: 1e-6)
|
||||
|
||||
let fortyFive = try FuelSavingsCalculator.calculate(
|
||||
mpg: 45,
|
||||
@@ -283,7 +366,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
candidateJourneyMiles: 3.6
|
||||
)
|
||||
XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6)
|
||||
XCTAssertEqual(fortyFive.netSavingGBP, -0.838119, accuracy: 1e-6)
|
||||
XCTAssertEqual(fortyFive.netSavingGBP, -0.836100, accuracy: 1e-6)
|
||||
}
|
||||
|
||||
func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws {
|
||||
@@ -295,10 +378,10 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
baselineJourneyMiles: 0.8,
|
||||
candidateJourneyMiles: 3.6
|
||||
)
|
||||
XCTAssertEqual(forty.fillLitres, 23.269343, accuracy: 1e-6, "£40 buys 23.27 L at 171.9p")
|
||||
XCTAssertEqual(forty.grossSavingGBP, -0.232693, accuracy: 1e-6)
|
||||
XCTAssertEqual(forty.fillLitres, 23.134760, accuracy: 1e-6, "£40 buys 23.13 L at the candidate price")
|
||||
XCTAssertEqual(forty.grossSavingGBP, -0.231348, accuracy: 1e-6)
|
||||
XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent")
|
||||
XCTAssertEqual(forty.netSavingGBP, -0.966311, accuracy: 1e-6)
|
||||
XCTAssertEqual(forty.netSavingGBP, -0.964965, accuracy: 1e-6)
|
||||
|
||||
let ninety = try FuelSavingsCalculator.calculate(
|
||||
mpg: 30,
|
||||
@@ -308,9 +391,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
baselineJourneyMiles: 0.8,
|
||||
candidateJourneyMiles: 3.6
|
||||
)
|
||||
XCTAssertEqual(ninety.fillLitres, 52.356021, accuracy: 1e-6)
|
||||
XCTAssertEqual(ninety.grossSavingGBP, -0.523560, accuracy: 1e-6)
|
||||
XCTAssertEqual(ninety.netSavingGBP, -1.257178, accuracy: 1e-6)
|
||||
XCTAssertEqual(ninety.fillLitres, 52.053210, accuracy: 1e-6)
|
||||
XCTAssertEqual(ninety.grossSavingGBP, -0.520532, accuracy: 1e-6)
|
||||
XCTAssertEqual(ninety.netSavingGBP, -1.254150, accuracy: 1e-6)
|
||||
}
|
||||
|
||||
func testFillUpInLitresIsFixedRegardlessOfPrice() throws {
|
||||
@@ -370,7 +453,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
journey: journey(2.8),
|
||||
baselineJourney: journey(0.8)
|
||||
)
|
||||
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.168911, accuracy: 1e-6)
|
||||
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.166869, accuracy: 1e-6)
|
||||
}
|
||||
|
||||
func testMixedRouteBasisIsReportedAsAnEstimate() {
|
||||
@@ -393,7 +476,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine,
|
||||
"one unmeasured leg makes the whole estimate an estimate")
|
||||
XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated)
|
||||
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. £0.17 worse off after travel")
|
||||
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. Costs £0.17 more")
|
||||
}
|
||||
|
||||
func testBaselineStationIsLabelledClosestStationNotBreakEven() {
|
||||
|
||||
@@ -284,9 +284,9 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi).
|
||||
///
|
||||
/// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is
|
||||
/// correct, not a calculation bug: £60 buys 34.90 L at 171.9p, so the
|
||||
/// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles
|
||||
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33.
|
||||
/// correct, not a calculation bug: £60 buys 35.74 L at 167.9p, so the
|
||||
/// cheaper pump saves 35.74 × 4p = £1.43, while the extra 6.8 return miles
|
||||
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.30.
|
||||
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
|
||||
let baseline = station("rontec-shelf", price: 171.9)
|
||||
let candidate = station("shell-whetley-hill", price: 167.9)
|
||||
@@ -304,16 +304,16 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
guard let result = availability?.result else {
|
||||
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||||
}
|
||||
XCTAssertEqual(result.fillLitres, 34.904, accuracy: 0.005,
|
||||
"the £60 budget buys litres at the BASELINE pump price")
|
||||
XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005)
|
||||
XCTAssertEqual(result.fillLitres, 35.736, accuracy: 0.005,
|
||||
"the £60 budget buys litres at the candidate pump price")
|
||||
XCTAssertEqual(result.grossSavingGBP, 1.429, accuracy: 0.005)
|
||||
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
|
||||
"only the return-trip DELTA counts — the shared journey is never counted twice")
|
||||
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
|
||||
XCTAssertEqual(result.netSavingGBP, -0.334, accuracy: 0.005)
|
||||
XCTAssertEqual(result.netSavingGBP, -0.301, accuracy: 0.005)
|
||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||
XCTAssertEqual(result.journeyConfidence, .estimated)
|
||||
XCTAssertEqual(result.headline(compact: true), "Approx. £0.33 worse off after travel")
|
||||
XCTAssertEqual(result.headline(compact: true), "Approx. Costs £0.30 more")
|
||||
}
|
||||
|
||||
func testMeasuredRoadRoutesDropTheEstimateMarker() {
|
||||
@@ -332,8 +332,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
|
||||
XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed)
|
||||
XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel")
|
||||
XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel")
|
||||
XCTAssertEqual(availability?.result?.headline(compact: true), "Costs £0.30 more")
|
||||
XCTAssertEqual(availability?.result?.headline(), "Costs £0.30 more")
|
||||
}
|
||||
|
||||
func testBaselineRowIsLabelledClosestStation() {
|
||||
@@ -375,10 +375,10 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||||
}
|
||||
XCTAssertEqual(result.status, .worthTheDrive)
|
||||
// £60 buys 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra
|
||||
// return miles cost ~£0.09. Net ≈ £3.66.
|
||||
XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005)
|
||||
XCTAssertEqual(result.headline(compact: true), "Approx. £3.66 better off after travel")
|
||||
// £60 buys 40 L at 150p; 10p/litre cheaper saves £4.00; 0.4 extra
|
||||
// return miles cost ~£0.09. Net ≈ £3.91.
|
||||
XCTAssertEqual(result.netSavingGBP, 3.909, accuracy: 0.005)
|
||||
XCTAssertEqual(result.headline(compact: true), "Approx. Save £3.91")
|
||||
}
|
||||
|
||||
func testNeutralResultHeadlineIsBreakEven() {
|
||||
@@ -413,7 +413,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
netSavingGBP: 0.52,
|
||||
status: .worthTheDrive,
|
||||
distanceBasis: .roadRoute)
|
||||
XCTAssertEqual(saving.headline(), "£0.52 better off after travel")
|
||||
XCTAssertEqual(saving.headline(), "Save £0.52")
|
||||
|
||||
let loss = FuelSavingsResult(fillLitres: 34.9,
|
||||
grossSavingGBP: 1.40,
|
||||
@@ -422,7 +422,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
netSavingGBP: -0.31,
|
||||
status: .notWorthTheDrive,
|
||||
distanceBasis: .roadRoute)
|
||||
XCTAssertEqual(loss.headline(), "£0.31 worse off after travel")
|
||||
XCTAssertEqual(loss.headline(), "Costs £0.31 more")
|
||||
}
|
||||
|
||||
/// The sort must order by NET saving, not pump price: a pricier station can
|
||||
@@ -454,6 +454,25 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
|
||||
// MARK: Row copy
|
||||
|
||||
func testCompactComparisonLabelUsesGenericNearestStationWording() {
|
||||
XCTAssertEqual(
|
||||
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
|
||||
"vs closest station"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
|
||||
"vs closest station"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
FuelSavingsFormat.comparisonLabel(status: .breakEven),
|
||||
"vs closest station"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
FuelSavingsFormat.comparisonLabel(status: .baseline),
|
||||
"your baseline"
|
||||
)
|
||||
}
|
||||
|
||||
/// The row's extra-driving caption must follow the DISPLAY unit: the stored
|
||||
/// figure is always miles, and converting by the current unit instead would
|
||||
/// leave a km user's figure unlabelled and 1.6× too large.
|
||||
@@ -486,7 +505,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
netSavingGBP: 0,
|
||||
status: .baseline
|
||||
)
|
||||
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station · your baseline")
|
||||
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station")
|
||||
|
||||
let saving = FuelSavingsResult(
|
||||
fillLitres: 30,
|
||||
@@ -497,7 +516,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
status: .worthTheDrive,
|
||||
distanceBasis: .straightLine
|
||||
)
|
||||
XCTAssertEqual(saving.capsuleHeadline(), "Approx. £1.00 better off after travel")
|
||||
XCTAssertEqual(saving.capsuleHeadline(), "Approx. Save £1.00")
|
||||
|
||||
let worse = FuelSavingsResult(
|
||||
fillLitres: 30,
|
||||
@@ -507,7 +526,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
netSavingGBP: -0.31,
|
||||
status: .notWorthTheDrive
|
||||
)
|
||||
XCTAssertEqual(worse.capsuleHeadline(), "£0.31 worse off after travel")
|
||||
XCTAssertEqual(worse.capsuleHeadline(), "Costs £0.31 more")
|
||||
}
|
||||
|
||||
// MARK: - Estimate wording (Approx. labels)
|
||||
@@ -524,7 +543,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
status: .worthTheDrive,
|
||||
distanceBasis: .straightLine
|
||||
)
|
||||
XCTAssertEqual(saving.headline(), "Approx. £1.25 better off after travel")
|
||||
XCTAssertEqual(saving.headline(), "Approx. Save £1.25")
|
||||
|
||||
let loss = FuelSavingsResult(
|
||||
fillLitres: 30,
|
||||
@@ -535,7 +554,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
status: .notWorthTheDrive,
|
||||
distanceBasis: .straightLine
|
||||
)
|
||||
XCTAssertEqual(loss.headline(), "Approx. £0.77 worse off after travel")
|
||||
XCTAssertEqual(loss.headline(), "Approx. Costs £0.77 more")
|
||||
}
|
||||
|
||||
/// A confirmed road-route comparison must never carry the marker — in either
|
||||
@@ -551,7 +570,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
distanceBasis: .roadRoute,
|
||||
journeyConfidence: .confirmed
|
||||
)
|
||||
XCTAssertEqual(saving.headline(), "£1.25 better off after travel")
|
||||
XCTAssertEqual(saving.headline(), "Save £1.25")
|
||||
|
||||
let loss = FuelSavingsResult(
|
||||
fillLitres: 30,
|
||||
@@ -563,7 +582,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
distanceBasis: .roadRoute,
|
||||
journeyConfidence: .confirmed
|
||||
)
|
||||
XCTAssertEqual(loss.headline(), "£0.77 worse off after travel")
|
||||
XCTAssertEqual(loss.headline(), "Costs £0.77 more")
|
||||
|
||||
for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] {
|
||||
XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate")
|
||||
@@ -597,7 +616,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
status: .notWorthTheDrive,
|
||||
distanceBasis: .straightLine
|
||||
)
|
||||
XCTAssertEqual(estimated.spokenHeadline(), "Approximately £0.77 worse off after travel")
|
||||
XCTAssertEqual(estimated.spokenHeadline(), "Approximately Costs £0.77 more")
|
||||
|
||||
XCTAssertEqual(
|
||||
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
|
||||
|
||||
@@ -8,15 +8,15 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
||||
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws {
|
||||
func testPoundsBudgetConvertsToLitresUsingGivenPrice() throws {
|
||||
let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
|
||||
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160)
|
||||
XCTAssertEqual(litres, 25.0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testLitresAmountPassesThrough() throws {
|
||||
let amount = FuelSavingsFillAmount(value: 35, unit: .litres)
|
||||
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
|
||||
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160)
|
||||
XCTAssertEqual(litres, 35, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
||||
baselineJourneyMiles: 2,
|
||||
candidateJourneyMiles: 8
|
||||
)
|
||||
XCTAssertEqual(result.fillLitres, 25, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.fillLitres, 26.6667, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.grossSavingGBP, 2.6667, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.netSavingGBP, 1.5908, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.netSavingGBP, 1.7574, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.status, .worthTheDrive)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testInvalidInputsRejected() {
|
||||
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 0))
|
||||
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), pricePencePerLitre: 0))
|
||||
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
||||
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ private enum WatchDistanceUnit: String, Codable {
|
||||
func format(km: Double) -> String {
|
||||
switch self {
|
||||
case .miles:
|
||||
return String(format: "%.1f mi", km * 0.621371)
|
||||
return String(format: "%.1f mi", km / 1.609344)
|
||||
case .kilometers:
|
||||
return String(format: "%.1f km", km)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
data: FuelPriceWidgetEntryData(
|
||||
date: Date(), stations: Array(sample.prefix(8)),
|
||||
fuel: .e10, sort: .cheapest, isFavourites: false,
|
||||
location: nil, locationSource: "none", unit: unit
|
||||
location: nil, locationSource: "none", unit: unit,
|
||||
bestValueResult: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -146,8 +147,9 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
// Per-widget config: fuel + sort + distance come from THIS widget instance.
|
||||
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
|
||||
let isFavourites = configuration.sort == .favourites
|
||||
let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest
|
||||
let radiusKM = Double(configuration.distance.miles) * 1.60934
|
||||
let sort: SortMode = configuration.sort == .closest ? .closest :
|
||||
(configuration.sort == .worthTheDrive ? .worthTheDrive : .cheapest)
|
||||
let radiusKM = Double(configuration.distance.miles) * FuelSavingsCalculator.kilometresPerMile
|
||||
|
||||
// 1) Try a fresh location fix (bounded to a few seconds). The fix's own
|
||||
// accuracy is kept: the extension runs its own CLLocationManager and is
|
||||
@@ -200,6 +202,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
|
||||
// 3) Load + order stations per widget mode.
|
||||
let ordered: [FuelStation]
|
||||
var bestValueResult: FuelSavingsResult?
|
||||
if isFavourites {
|
||||
let favourites = FuelStore.loadFavourites()
|
||||
.filter { $0.station.prices[$0.fuel] != nil }
|
||||
@@ -265,7 +268,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
let filtered = stations.filter { $0.prices[fuel] != nil }
|
||||
switch sort {
|
||||
case .closest:
|
||||
// Nearest first; price only breaks ties.
|
||||
// Closest first; price only breaks ties.
|
||||
ordered = filtered.sorted { lhs, rhs in
|
||||
if let origin {
|
||||
let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng)
|
||||
@@ -274,9 +277,59 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
}
|
||||
return lhs.prices[fuel]! < rhs.prices[fuel]!
|
||||
}
|
||||
case .cheapest, .worthTheDrive:
|
||||
// Cheapest first; distance only breaks ties. Worth-the-Drive is app-only;
|
||||
// widgets fall back to cheapest ordering if a stored config ever carries it.
|
||||
case .worthTheDrive:
|
||||
guard let origin else {
|
||||
ordered = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
||||
break
|
||||
}
|
||||
let journey: (FuelStation) -> FuelSavingsJourney? = { station in
|
||||
if let meters = FuelStore.roadJourneyMeters(for: station, userLat: origin.lat, userLng: origin.lng) {
|
||||
let confidence: JourneyConfidence = FuelStore.hasMeasuredReturnRoadDistance(
|
||||
for: station, userLat: origin.lat, userLng: origin.lng
|
||||
) ? .confirmed : .estimated
|
||||
return FuelSavingsJourney(
|
||||
miles: meters / FuelSavingsCalculator.metresPerMile,
|
||||
basis: .roadRoute,
|
||||
confidence: confidence
|
||||
)
|
||||
}
|
||||
return FuelSavingsJourney(
|
||||
miles: FuelStore.displayDistanceKM(station: station, userLat: origin.lat, userLng: origin.lng) /
|
||||
FuelSavingsCalculator.kilometresPerMile * 2,
|
||||
basis: .straightLine,
|
||||
confidence: .estimated
|
||||
)
|
||||
}
|
||||
let settings = FuelStore.loadFuelSavingsSettings()
|
||||
guard settings.isConfigured else {
|
||||
ordered = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
||||
break
|
||||
}
|
||||
let amount = settings.preferredAmount
|
||||
let baseline = FuelSavingsEstimator.baselineStation(filtered, fuel: fuel, journeyMiles: journey)
|
||||
let ranked = FuelSavingsEstimator.ranked(
|
||||
filtered,
|
||||
fuel: fuel,
|
||||
baseline: baseline,
|
||||
settings: settings,
|
||||
amount: amount,
|
||||
journeyMiles: journey
|
||||
)
|
||||
ordered = ranked
|
||||
if let first = ranked.first, let baseline {
|
||||
let baselineJourney = journey(baseline)
|
||||
let result = FuelSavingsEstimator.availability(
|
||||
station: first,
|
||||
fuel: fuel,
|
||||
baseline: baseline,
|
||||
settings: settings,
|
||||
amount: amount,
|
||||
journey: journey(first),
|
||||
baselineJourney: baselineJourney
|
||||
)
|
||||
bestValueResult = result?.result
|
||||
}
|
||||
case .cheapest:
|
||||
ordered = filtered.sorted { lhs, rhs in
|
||||
let lPrice = lhs.prices[fuel]!
|
||||
let rPrice = rhs.prices[fuel]!
|
||||
@@ -295,7 +348,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
date: Date(), stations: Array(ordered.prefix(8)),
|
||||
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
||||
location: origin, locationSource: source,
|
||||
unit: unit
|
||||
unit: unit, bestValueResult: bestValueResult
|
||||
)
|
||||
), originSource)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ enum WidgetFuel: String, AppEnum, CaseIterable, Codable {
|
||||
enum WidgetSort: String, AppEnum, CaseIterable, Codable {
|
||||
case cheapest
|
||||
case closest
|
||||
case worthTheDrive
|
||||
case favourites
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Sort by"
|
||||
@@ -37,6 +38,7 @@ enum WidgetSort: String, AppEnum, CaseIterable, Codable {
|
||||
static var caseDisplayRepresentations: [WidgetSort: DisplayRepresentation] = [
|
||||
.cheapest: "Cheapest",
|
||||
.closest: "Closest",
|
||||
.worthTheDrive: "Best Value",
|
||||
.favourites: "Favourites",
|
||||
]
|
||||
}
|
||||
@@ -112,9 +114,17 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConf
|
||||
\.$sort
|
||||
}
|
||||
} otherwise: {
|
||||
Summary("Show \(\.$fuel) by \(\.$sort)") {
|
||||
\.$fuel
|
||||
\.$sort
|
||||
When(\.$sort, .equalTo, WidgetSort.worthTheDrive) {
|
||||
Summary("Show \(\.$fuel) by \(\.$sort) within \(\.$distance)") {
|
||||
\.$fuel
|
||||
\.$sort
|
||||
\.$distance
|
||||
}
|
||||
} otherwise: {
|
||||
Summary("Show \(\.$fuel) by \(\.$sort)") {
|
||||
\.$fuel
|
||||
\.$sort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,12 @@ struct FuelPriceWidgetEntryData {
|
||||
let date: Date
|
||||
let stations: [FuelStation] // already filtered + sorted per config
|
||||
let fuel: FuelType
|
||||
let sort: SortMode // per-widget: cheapest | closest
|
||||
let isFavourites: Bool // favourites mode: pinned stations, cheapest-first
|
||||
let sort: SortMode // per-widget: cheapest | closest | Best Value
|
||||
let isFavourites: Bool // favourites mode: pinned stations
|
||||
let location: Coordinate?
|
||||
let locationSource: String // "live" | "cached" | "none"
|
||||
let unit: DistanceUnit // user's display unit for distances
|
||||
let bestValueResult: FuelSavingsResult? // only populated for the small Best Value face
|
||||
}
|
||||
|
||||
/// The widget face, parameterised by family so the app can preview it.
|
||||
@@ -67,9 +68,11 @@ struct FuelPriceWidgetContent: View {
|
||||
|
||||
private var heading: String {
|
||||
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
|
||||
return entry.sort == .closest
|
||||
? "Closest \(entry.fuel.displayName)"
|
||||
: "Cheapest \(entry.fuel.displayName)"
|
||||
switch entry.sort {
|
||||
case .closest: return "Closest \(entry.fuel.displayName)"
|
||||
case .worthTheDrive: return "Best Value \(entry.fuel.displayName)"
|
||||
case .cheapest: return "Cheapest \(entry.fuel.displayName)"
|
||||
}
|
||||
}
|
||||
|
||||
private var singleStation: some View {
|
||||
@@ -85,7 +88,15 @@ struct FuelPriceWidgetContent: View {
|
||||
Text(station.name)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
if let price = station.prices[entry.fuel] {
|
||||
if entry.sort == .worthTheDrive, let result = entry.bestValueResult {
|
||||
Text(result.headline(compact: true))
|
||||
.font(.title3.weight(.bold))
|
||||
.lineLimit(1)
|
||||
Text(result.status == .baseline ? "your baseline" : "vs closest station")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else if let price = station.prices[entry.fuel] {
|
||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||
}
|
||||
if let location = entry.location {
|
||||
|
||||
+22
-12
@@ -82,6 +82,7 @@ enum JourneyConfidence: String, Codable, Equatable {
|
||||
struct FuelSavingsResult: Codable, Equatable {
|
||||
let fillLitres: Double
|
||||
let grossSavingGBP: Double
|
||||
let extraFuelLitres: Double
|
||||
let extraDrivingCostGBP: Double
|
||||
let extraDistanceMiles: Double
|
||||
let netSavingGBP: Double
|
||||
@@ -91,6 +92,7 @@ struct FuelSavingsResult: Codable, Equatable {
|
||||
|
||||
init(fillLitres: Double,
|
||||
grossSavingGBP: Double,
|
||||
extraFuelLitres: Double = 0,
|
||||
extraDrivingCostGBP: Double,
|
||||
extraDistanceMiles: Double,
|
||||
netSavingGBP: Double,
|
||||
@@ -99,6 +101,7 @@ struct FuelSavingsResult: Codable, Equatable {
|
||||
journeyConfidence: JourneyConfidence? = nil) {
|
||||
self.fillLitres = fillLitres
|
||||
self.grossSavingGBP = grossSavingGBP
|
||||
self.extraFuelLitres = extraFuelLitres
|
||||
self.extraDrivingCostGBP = extraDrivingCostGBP
|
||||
self.extraDistanceMiles = extraDistanceMiles
|
||||
self.netSavingGBP = netSavingGBP
|
||||
@@ -140,6 +143,13 @@ enum FuelSavingsFormat {
|
||||
: "\(distanceText) extra return travel"
|
||||
}
|
||||
|
||||
/// Compact comparison copy kept outside the coloured verdict capsule.
|
||||
/// "Closest" refers to the existing shortest-journey baseline selection;
|
||||
/// it does not claim a straight-line comparison.
|
||||
static func comparisonLabel(status: FuelSavingsStatus) -> String {
|
||||
status == .baseline ? "your baseline" : "vs closest station"
|
||||
}
|
||||
|
||||
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
|
||||
/// are spoken as words ("Approximately 6.4 miles extra return travel") because
|
||||
/// synthesizers render "Approx." and "mi" inconsistently.
|
||||
@@ -174,6 +184,7 @@ extension FuelSavingsResult {
|
||||
FuelSavingsResult(
|
||||
fillLitres: fillLitres,
|
||||
grossSavingGBP: 0,
|
||||
extraFuelLitres: 0,
|
||||
extraDrivingCostGBP: 0,
|
||||
extraDistanceMiles: 0,
|
||||
netSavingGBP: 0,
|
||||
@@ -199,9 +210,9 @@ extension FuelSavingsResult {
|
||||
case .breakEven:
|
||||
return Self.breakEvenHeadline
|
||||
case .worthTheDrive:
|
||||
return "\(prefix)\(FuelSavingsFormat.money(netSavingGBP)) better off after travel"
|
||||
return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP))"
|
||||
case .notWorthTheDrive:
|
||||
return "\(prefix)\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel"
|
||||
return "\(prefix)Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,13 +222,11 @@ extension FuelSavingsResult {
|
||||
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
|
||||
}
|
||||
|
||||
/// Row capsule copy: the headline, plus what the baseline row IS. Without the
|
||||
/// suffix the baseline capsule just repeated the status word above it
|
||||
/// ("Closest station" twice) and said nothing about the saving being measured
|
||||
/// against it.
|
||||
/// Row capsule copy. The baseline relationship is shown as secondary text
|
||||
/// beside the capsule in the station listing and detail summary, so the
|
||||
/// capsule itself contains only the verdict.
|
||||
func capsuleHeadline() -> String {
|
||||
let headline = headline(compact: true)
|
||||
return status == .baseline ? "\(headline) · your baseline" : headline
|
||||
headline(compact: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,16 +330,16 @@ enum FuelSavingsCalculator {
|
||||
distanceMiles / mpg * imperialGallonLitres
|
||||
}
|
||||
|
||||
static func fillLitres(amount: FuelSavingsFillAmount, baselinePricePencePerLitre: Double) throws -> Double {
|
||||
static func fillLitres(amount: FuelSavingsFillAmount, pricePencePerLitre: Double) throws -> Double {
|
||||
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
|
||||
switch amount.unit {
|
||||
case .litres:
|
||||
return amount.value
|
||||
case .pounds:
|
||||
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else {
|
||||
guard pricePencePerLitre.isFinite, pricePencePerLitre > 0 else {
|
||||
throw FuelSavingsError.invalidPrice
|
||||
}
|
||||
return amount.value / (baselinePricePencePerLitre / 100.0)
|
||||
return amount.value / (pricePencePerLitre / 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +385,7 @@ enum FuelSavingsCalculator {
|
||||
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
|
||||
throw FuelSavingsError.invalidDistance
|
||||
}
|
||||
let fillLitres = try fillLitres(amount: amount, baselinePricePencePerLitre: baselinePricePencePerLitre)
|
||||
let fillLitres = try fillLitres(amount: amount, pricePencePerLitre: candidatePricePencePerLitre)
|
||||
let baselinePriceGBP = baselinePricePencePerLitre / 100.0
|
||||
let candidatePriceGBP = candidatePricePencePerLitre / 100.0
|
||||
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
|
||||
@@ -395,6 +404,7 @@ enum FuelSavingsCalculator {
|
||||
return FuelSavingsResult(
|
||||
fillLitres: fillLitres,
|
||||
grossSavingGBP: grossSaving,
|
||||
extraFuelLitres: extraFuelLitres,
|
||||
extraDrivingCostGBP: extraFuelCost,
|
||||
extraDistanceMiles: extraDistance,
|
||||
netSavingGBP: netSaving,
|
||||
|
||||
+59
-32
@@ -183,13 +183,13 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
|
||||
/// shown in this unit, rounded to a whole number for picker labels:
|
||||
/// 5 miles -> "5" (miles unit) or "8" (km unit).
|
||||
func displayMiles(_ miles: Int) -> Int {
|
||||
Int(fromKM(Double(miles) * 1.60934).rounded())
|
||||
Int(fromKM(Double(miles) * FuelSavingsCalculator.kilometresPerMile).rounded())
|
||||
}
|
||||
|
||||
/// Convert a value expressed in this unit to km.
|
||||
func toKM(_ value: Double) -> Double {
|
||||
switch self {
|
||||
case .miles: return value * 1.60934
|
||||
case .miles: return value * FuelSavingsCalculator.kilometresPerMile
|
||||
case .kilometers: return value
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
|
||||
/// Convert a km value to this unit.
|
||||
func fromKM(_ km: Double) -> Double {
|
||||
switch self {
|
||||
case .miles: return km * 0.621371
|
||||
case .miles: return km / FuelSavingsCalculator.kilometresPerMile
|
||||
case .kilometers: return km
|
||||
}
|
||||
}
|
||||
@@ -979,7 +979,7 @@ struct FuelStore {
|
||||
|
||||
/// "Follow search" caps the geofence at 8 miles so a 10/15-mile Stations
|
||||
/// search never creates huge region-monitoring circles.
|
||||
static let alertFollowCapKM: Double = 8 * 1.60934
|
||||
static let alertFollowCapKM: Double = 8 * FuelSavingsCalculator.kilometresPerMile
|
||||
|
||||
/// The effective alert radius: the manual radius, or — when alerts follow
|
||||
/// the Stations-tab search — that distance (miles, converted via the
|
||||
@@ -1618,10 +1618,22 @@ struct FuelStore {
|
||||
// what the rows show.
|
||||
if accurateDistancesEnabled,
|
||||
accurateDistancesReady(userLat: userLat, userLng: userLng),
|
||||
let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
|
||||
return StationDistanceDisplay(km: meters / 1000.0, isApproximate: false)
|
||||
let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) {
|
||||
let returnKM = (entry.meters + (entry.returnMeters ?? entry.meters)) / 1000.0
|
||||
return StationDistanceDisplay(
|
||||
km: entry.meters / 1000.0,
|
||||
isApproximate: false,
|
||||
returnKM: returnKM,
|
||||
returnIsApproximate: entry.returnMeters == nil
|
||||
)
|
||||
}
|
||||
return StationDistanceDisplay(km: station.distanceKM(to: userLat, lng2: userLng), isApproximate: true)
|
||||
let km = station.distanceKM(to: userLat, lng2: userLng)
|
||||
return StationDistanceDisplay(
|
||||
km: km,
|
||||
isApproximate: true,
|
||||
returnKM: km * 2,
|
||||
returnIsApproximate: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Radius test on the SAME distance the UI shows. Filtering straight-line
|
||||
@@ -1710,35 +1722,50 @@ struct RoadDistanceDiagnostics: Equatable {
|
||||
/// road distance or the straight-line fallback. The confidence travels WITH the
|
||||
/// value, so a label can never claim a confidence the figure doesn't have.
|
||||
struct StationDistanceDisplay: Equatable {
|
||||
let km: Double
|
||||
let isApproximate: Bool
|
||||
let km: Double
|
||||
let isApproximate: Bool
|
||||
let returnKM: Double
|
||||
let returnIsApproximate: Bool
|
||||
|
||||
/// Row/sheet label: a straight-line fallback is marked "Approx." so a figure
|
||||
/// that is NOT a routed road distance can never be read as one, and a cached
|
||||
/// road distance stays a bare figure. It updates by itself once routing lands,
|
||||
/// because `FuelStore.stationDistanceDisplay` re-reads the cache every call.
|
||||
func label(unit: DistanceUnit) -> String {
|
||||
let text = unit.format(km)
|
||||
return isApproximate ? "Approx. \(text)" : text
|
||||
}
|
||||
init(km: Double,
|
||||
isApproximate: Bool,
|
||||
returnKM: Double? = nil,
|
||||
returnIsApproximate: Bool? = nil) {
|
||||
self.km = km
|
||||
self.isApproximate = isApproximate
|
||||
self.returnKM = returnKM ?? km * 2
|
||||
self.returnIsApproximate = returnIsApproximate ?? isApproximate
|
||||
}
|
||||
|
||||
/// Row/sheet label that keeps the one-way station distance primary, then
|
||||
/// adds the out-and-back distance in brackets. This matches Best Value's
|
||||
/// "extra return travel" basis without changing the actual distance model.
|
||||
func labelWithReturn(unit: DistanceUnit) -> String {
|
||||
"\(label(unit: unit)) (\(unit.format(km * 2)) return)"
|
||||
}
|
||||
/// Row/sheet label: a straight-line fallback is marked "Approx." so a figure
|
||||
/// that is NOT a routed road distance can never be read as one, and a cached
|
||||
/// road distance stays a bare figure. It updates by itself once routing lands,
|
||||
/// because `FuelStore.stationDistanceDisplay` re-reads the cache every call.
|
||||
func label(unit: DistanceUnit) -> String {
|
||||
let text = unit.format(km)
|
||||
return isApproximate ? "Approx. \(text)" : text
|
||||
}
|
||||
|
||||
/// VoiceOver form of `label(unit:)`.
|
||||
func spokenLabel(unit: DistanceUnit) -> String {
|
||||
let text = unit.spoken(km)
|
||||
return isApproximate ? "Approximately \(text)" : text
|
||||
}
|
||||
/// Row/sheet label that keeps the one-way station distance primary, then
|
||||
/// adds the independently measured or estimated out-and-back distance.
|
||||
func labelWithReturn(unit: DistanceUnit) -> String {
|
||||
let returnText = unit.format(returnKM)
|
||||
let returnLabel = returnIsApproximate ? "Approx. \(returnText)" : returnText
|
||||
return "\(label(unit: unit)) (\(returnLabel) return)"
|
||||
}
|
||||
|
||||
/// VoiceOver form of `labelWithReturn(unit:)`.
|
||||
func spokenLabelWithReturn(unit: DistanceUnit) -> String {
|
||||
"\(spokenLabel(unit: unit)), \(unit.spoken(km * 2)) return"
|
||||
}
|
||||
/// VoiceOver form of `label(unit:)`.
|
||||
func spokenLabel(unit: DistanceUnit) -> String {
|
||||
let text = unit.spoken(km)
|
||||
return isApproximate ? "Approximately \(text)" : text
|
||||
}
|
||||
|
||||
/// VoiceOver form of `labelWithReturn(unit:)`.
|
||||
func spokenLabelWithReturn(unit: DistanceUnit) -> String {
|
||||
let returnText = unit.spoken(returnKM)
|
||||
let returnLabel = returnIsApproximate ? "Approximately \(returnText)" : returnText
|
||||
return "\(spokenLabel(unit: unit)), \(returnLabel) return"
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached Apple-Maps road distances for nearby stations (see
|
||||
|
||||
@@ -21,7 +21,7 @@ enum SiriCheapestLookup {
|
||||
lng: Double,
|
||||
withinMiles: Double
|
||||
) -> FuelStation? {
|
||||
let radiusKM = withinMiles * 1.60934
|
||||
let radiusKM = withinMiles * FuelSavingsCalculator.kilometresPerMile
|
||||
// Same radius rule as the app list: the distance the answer would SHOW
|
||||
// (cached road route where there is one).
|
||||
return stations
|
||||
|
||||
Reference in New Issue
Block a user