Clarify fuel savings comparison labels

This commit is contained in:
FuelBoard Contributor
2026-10-15 11:38:24 +01:00
parent 558f956d8d
commit a5688cbac2
14 changed files with 176 additions and 73 deletions
+8 -8
View File
@@ -533,7 +533,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -599,7 +599,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -663,7 +663,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -728,7 +728,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -826,7 +826,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -851,7 +851,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -875,7 +875,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
@@ -900,7 +900,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
-3
View File
@@ -1523,7 +1523,6 @@ struct ContentView: View {
fuelSavingsSettings: fuelSavingsSettings, fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: $sessionFuelSavingsAmount, fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys), savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
savingsBaselineStationName: baselineStation?.name,
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) }, journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
onToggleFavourite: toggleFavourite, onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) } onRefresh: { await refresh(force: true) }
@@ -1545,7 +1544,6 @@ struct ContentView: View {
fuelSavingsSettings: fuelSavingsSettings, fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: $sessionFuelSavingsAmount, fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys), savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
savingsBaselineStationName: baselineStation?.name,
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) }, journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
onToggleFavourite: toggleFavourite, onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites, onReorder: reorderFavourites,
@@ -1944,7 +1942,6 @@ struct StationRow: View {
FuelSavingsSummaryView( FuelSavingsSummaryView(
availability: fuelSavings, availability: fuelSavings,
amount: fuelSavingsAmount, amount: fuelSavingsAmount,
baselineStationName: nil,
compact: true, compact: true,
distanceUnit: distanceUnit distanceUnit: distanceUnit
) )
-4
View File
@@ -24,7 +24,6 @@ struct FavouritesView: View {
/// Estimated saving for a favourite, against the nearest eligible station — /// Estimated saving for a favourite, against the nearest eligible station —
/// the same baseline and definition the Stations list uses. /// the same baseline and definition the Stations list uses.
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil } var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var savingsBaselineStationName: String?
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil } var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop). /// Persists a reordered favourites array (after drag-and-drop).
@@ -84,7 +83,6 @@ struct FavouritesView: View {
fuelSavingsSettings: FuelSavingsSettings = .defaults, fuelSavingsSettings: FuelSavingsSettings = .defaults,
fuelSavingsAmount: Binding<FuelSavingsFillAmount>, fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil }, savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
savingsBaselineStationName: String? = nil,
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }, journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }, onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }, onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
@@ -99,7 +97,6 @@ struct FavouritesView: View {
self.fuelSavingsSettings = fuelSavingsSettings self.fuelSavingsSettings = fuelSavingsSettings
_fuelSavingsAmount = fuelSavingsAmount _fuelSavingsAmount = fuelSavingsAmount
self.savingsForStation = savingsForStation self.savingsForStation = savingsForStation
self.savingsBaselineStationName = savingsBaselineStationName
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
self.onToggleFavourite = onToggleFavourite self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder self.onReorder = onReorder
@@ -221,7 +218,6 @@ struct FavouritesView: View {
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount, fuelSavingsAmount: fuelSavingsAmount,
savingsBaselineStationName: savingsBaselineStationName,
journeyDiagnostics: journeyDiagnosticsForStation(station), journeyDiagnostics: journeyDiagnosticsForStation(station),
// Every station listed here IS a favourite for the tab's // Every station listed here IS a favourite for the tab's
// fuel, so the star starts filled; tapping it unstars. // fuel, so the star starts filled; tapping it unstars.
+14 -1
View File
@@ -73,13 +73,17 @@ struct FuelSavingsAdjustmentView: View {
struct FuelSavingsSummaryView: View { struct FuelSavingsSummaryView: View {
let availability: FuelSavingsAvailability? let availability: FuelSavingsAvailability?
let amount: FuelSavingsFillAmount let amount: FuelSavingsFillAmount
let baselineStationName: String? var baselineStationName: String? = nil
var compact: Bool = false var compact: Bool = false
/// Sizes the "extra return travel" caption in the user's own unit (the /// 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. /// stored figure is miles) — used by both the row banner and the detail sheet.
var distanceUnit: DistanceUnit = .miles var distanceUnit: DistanceUnit = .miles
var showInfoAction: (() -> Void)? = nil var showInfoAction: (() -> Void)? = nil
private func comparisonLabel(for result: FuelSavingsResult) -> String {
FuelSavingsFormat.comparisonLabel(status: result.status)
}
var body: some View { var body: some View {
switch availability { switch availability {
case .available(let result): case .available(let result):
@@ -93,8 +97,17 @@ struct FuelSavingsSummaryView: View {
// break even, blue baseline. lineLimit + minimumScaleFactor keep // break even, blue baseline. lineLimit + minimumScaleFactor keep
// it to one line, so rows stay even. // it to one line, so rows stay even.
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
capsule(for: result, compact: true) capsule(for: result, compact: true)
.accessibilityLabel(accessibilityHeadline(for: result)) .accessibilityLabel(accessibilityHeadline(for: result))
Text(comparisonLabel(for: result))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
.layoutPriority(1)
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
}
if let extraTravel = FuelSavingsFormat.extraTravelLabel( if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles, extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit, unit: distanceUnit,
-2
View File
@@ -19,7 +19,6 @@ struct StationsView: View {
let fuelSavingsSettings: FuelSavingsSettings let fuelSavingsSettings: FuelSavingsSettings
@Binding var fuelSavingsAmount: FuelSavingsFillAmount @Binding var fuelSavingsAmount: FuelSavingsFillAmount
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil } var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var savingsBaselineStationName: String?
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil } var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onRefresh: () async -> Void = {} var onRefresh: () async -> Void = {}
@@ -348,7 +347,6 @@ struct StationsView: View {
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount, fuelSavingsAmount: fuelSavingsAmount,
savingsBaselineStationName: savingsBaselineStationName,
journeyDiagnostics: journeyDiagnosticsForStation(station), journeyDiagnostics: journeyDiagnosticsForStation(station),
isFavourite: favouriteIDs.contains(station.id), isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) } onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
+1 -1
View File
@@ -76,7 +76,7 @@ struct WidgetMockScreen: View {
let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) } let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
// 5 miles — the widget config's default Distance. WidgetDistance itself // 5 miles — the widget config's default Distance. WidgetDistance itself
// lives in the widget target, so the preview mirrors its default here. // 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 all = FuelStore.loadStations()
let selling = all.filter { station in let selling = all.filter { station in
guard station.prices[fuel] != nil else { return false } guard station.prices[fuel] != nil else { return false }
@@ -211,10 +211,10 @@ final class DistanceTests: XCTestCase {
} }
func testMilesConversion() { func testMilesConversion() {
// 5 miles ≈ 8.05 km (app uses × 1.60934) // 5 miles ≈ 8.05 km using the shared exact conversion
XCTAssertEqual(5 * 1.60934, 8.0467, accuracy: 0.001) XCTAssertEqual(5 * FuelSavingsCalculator.kilometresPerMile, 8.04672, accuracy: 0.001)
// 262 km ≈ 162.8 miles (display uses × 0.621371) // 262 km ≈ 162.8 miles using the shared exact conversion
XCTAssertEqual(262 * 0.621371, 162.8, accuracy: 0.1) XCTAssertEqual(262 / FuelSavingsCalculator.kilometresPerMile, 162.8, accuracy: 0.1)
} }
} }
@@ -267,14 +267,14 @@ final class FuelTypeLabelTests: XCTestCase {
} }
func testDistanceUnitConversion() { 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.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) XCTAssertEqual(DistanceUnit.kilometers.fromKM(2.5), 2.5)
} }
func testDistanceUnitFormat() { 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") XCTAssertEqual(DistanceUnit.kilometers.format(3.4), "3.4 km")
} }
} }
@@ -400,7 +400,7 @@ final class AlertRadiusFollowTests: XCTestCase {
FuelStore.saveDistanceUnit(.miles) FuelStore.saveDistanceUnit(.miles)
FuelStore.saveStationLimit(5) FuelStore.saveStationLimit(5)
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: true, manualKM: 3) 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, sourceLat: sourceLat, sourceLng: sourceLng,
entries: ["far": .init(meters: 23_500, lat: 51.5074, lng: -0.1278, returnMeters: 23_500)]) 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, XCTAssertLessThan(far.distanceKM(to: sourceLat, lng2: sourceLng), tenMilesKM,
"fixture: the straight line must sit INSIDE the 10-mile radius") "fixture: the straight line must sit INSIDE the 10-mile radius")
XCTAssertGreaterThan(FuelStore.displayDistanceKM(station: far, userLat: sourceLat, userLng: sourceLng), XCTAssertGreaterThan(FuelStore.displayDistanceKM(station: far, userLat: sourceLat, userLng: sourceLng),
@@ -743,7 +743,7 @@ final class RoadDistanceCacheTests: XCTestCase {
userLat: sourceLat, userLng: sourceLng), userLat: sourceLat, userLng: sourceLng),
"a station whose row reads 14.6 mi must not appear in a 10-mile list") "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. // 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)) userLat: sourceLat, userLng: sourceLng))
// …and an unknown station (no cached route) still tests its displayed // …and an unknown station (no cached route) still tests its displayed
// straight line, so nothing changes for the bulk of the list. // 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") XCTAssertFalse(confirmed.isApproximate, "a cached road distance is not an approximation")
XCTAssertEqual(confirmed.km, 3.2, accuracy: 0.0001) XCTAssertEqual(confirmed.km, 3.2, accuracy: 0.0001)
XCTAssertEqual(confirmed.label(unit: .miles), "2.0 mi") XCTAssertEqual(confirmed.label(unit: .miles), "2.0 mi")
XCTAssertEqual(confirmed.labelWithReturn(unit: .miles), "2.0 mi (4.0 mi return)") XCTAssertEqual(confirmed.labelWithReturn(unit: .miles), "2.0 mi (4.2 mi return)")
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.4 km return)") XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.8 km return)")
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles") 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) let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1)
XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one") 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), XCTAssertEqual(approximate.label(unit: .miles),
"Approx. " + DistanceUnit.miles.format(approximate.km)) "Approx. " + DistanceUnit.miles.format(approximate.km))
XCTAssertEqual(approximate.labelWithReturn(unit: .miles), 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), XCTAssertEqual(approximate.spokenLabel(unit: .kilometers),
"Approximately " + DistanceUnit.kilometers.spoken(approximate.km)) "Approximately " + DistanceUnit.kilometers.spoken(approximate.km))
XCTAssertEqual(approximate.spokenLabelWithReturn(unit: .kilometers), 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 /// 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) 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") 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) 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), userLat: 51.6, userLng: -0.1),
"with the mode off the radius is a straight-line radius, agreeing with the rows") "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) let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
XCTAssertFalse(display.isApproximate) XCTAssertFalse(display.isApproximate)
XCTAssertEqual(display.km, 23.5, accuracy: 0.0001) 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), userLat: 51.6, userLng: -0.1),
"in this mode a 10-mile radius is 10 miles of driving, matching the routed rows") "in this mode a 10-mile radius is 10 miles of driving, matching the routed rows")
} }
@@ -263,6 +263,23 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(FuelSavingsFormat.money(0), "£0.00") 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() { func testSortingUsesTheUnroundedNetSoRoundedTiesStillOrderCorrectly() {
// Two rows that both DISPLAY "£0.18 worse off" but differ in the raw // 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. // figure: the sort must use the raw value, never the shown one.
@@ -454,6 +454,25 @@ final class FuelSavingsEstimateTests: XCTestCase {
// MARK: Row copy // MARK: Row copy
func testCompactComparisonLabelUsesGenericNearestStationWording() {
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
"vs nearest station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
"vs nearest station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .breakEven),
"vs nearest station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .baseline),
"your baseline"
)
}
/// The row's extra-driving caption must follow the DISPLAY unit: the stored /// 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 /// figure is always miles, and converting by the current unit instead would
/// leave a km user's figure unlabelled and 1.6× too large. /// leave a km user's figure unlabelled and 1.6× too large.
+1 -1
View File
@@ -51,7 +51,7 @@ private enum WatchDistanceUnit: String, Codable {
func format(km: Double) -> String { func format(km: Double) -> String {
switch self { switch self {
case .miles: case .miles:
return String(format: "%.1f mi", km * 0.621371) return String(format: "%.1f mi", km / 1.609344)
case .kilometers: case .kilometers:
return String(format: "%.1f km", km) return String(format: "%.1f km", km)
} }
+1 -1
View File
@@ -147,7 +147,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10 let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
let isFavourites = configuration.sort == .favourites let isFavourites = configuration.sort == .favourites
let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest
let radiusKM = Double(configuration.distance.miles) * 1.60934 let radiusKM = Double(configuration.distance.miles) * FuelSavingsCalculator.kilometresPerMile
// 1) Try a fresh location fix (bounded to a few seconds). The fix's own // 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 // accuracy is kept: the extension runs its own CLLocationManager and is
+7
View File
@@ -143,6 +143,13 @@ enum FuelSavingsFormat {
: "\(distanceText) extra return travel" : "\(distanceText) extra return travel"
} }
/// Compact comparison copy kept outside the coloured verdict capsule.
/// "Nearest" 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 nearest station"
}
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix /// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
/// are spoken as words ("Approximately 6.4 miles extra return travel") because /// are spoken as words ("Approximately 6.4 miles extra return travel") because
/// synthesizers render "Approx." and "mi" inconsistently. /// synthesizers render "Approx." and "mi" inconsistently.
+38 -11
View File
@@ -183,13 +183,13 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
/// shown in this unit, rounded to a whole number for picker labels: /// shown in this unit, rounded to a whole number for picker labels:
/// 5 miles -> "5" (miles unit) or "8" (km unit). /// 5 miles -> "5" (miles unit) or "8" (km unit).
func displayMiles(_ miles: Int) -> Int { 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. /// Convert a value expressed in this unit to km.
func toKM(_ value: Double) -> Double { func toKM(_ value: Double) -> Double {
switch self { switch self {
case .miles: return value * 1.60934 case .miles: return value * FuelSavingsCalculator.kilometresPerMile
case .kilometers: return value case .kilometers: return value
} }
} }
@@ -197,7 +197,7 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
/// Convert a km value to this unit. /// Convert a km value to this unit.
func fromKM(_ km: Double) -> Double { func fromKM(_ km: Double) -> Double {
switch self { switch self {
case .miles: return km * 0.621371 case .miles: return km / FuelSavingsCalculator.kilometresPerMile
case .kilometers: return km case .kilometers: return km
} }
} }
@@ -979,7 +979,7 @@ struct FuelStore {
/// "Follow search" caps the geofence at 8 miles so a 10/15-mile Stations /// "Follow search" caps the geofence at 8 miles so a 10/15-mile Stations
/// search never creates huge region-monitoring circles. /// 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 effective alert radius: the manual radius, or — when alerts follow
/// the Stations-tab search — that distance (miles, converted via the /// the Stations-tab search — that distance (miles, converted via the
@@ -1618,10 +1618,22 @@ struct FuelStore {
// what the rows show. // what the rows show.
if accurateDistancesEnabled, if accurateDistancesEnabled,
accurateDistancesReady(userLat: userLat, userLng: userLng), accurateDistancesReady(userLat: userLat, userLng: userLng),
let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) { let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) {
return StationDistanceDisplay(km: meters / 1000.0, isApproximate: false) 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 /// Radius test on the SAME distance the UI shows. Filtering straight-line
@@ -1712,6 +1724,18 @@ struct RoadDistanceDiagnostics: Equatable {
struct StationDistanceDisplay: Equatable { struct StationDistanceDisplay: Equatable {
let km: Double let km: Double
let isApproximate: Bool let isApproximate: Bool
let returnKM: Double
let returnIsApproximate: Bool
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: a straight-line fallback is marked "Approx." so a figure /// 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 /// that is NOT a routed road distance can never be read as one, and a cached
@@ -1723,10 +1747,11 @@ struct StationDistanceDisplay: Equatable {
} }
/// Row/sheet label that keeps the one-way station distance primary, then /// 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 /// adds the independently measured or estimated out-and-back distance.
/// "extra return travel" basis without changing the actual distance model.
func labelWithReturn(unit: DistanceUnit) -> String { func labelWithReturn(unit: DistanceUnit) -> String {
"\(label(unit: unit)) (\(unit.format(km * 2)) return)" let returnText = unit.format(returnKM)
let returnLabel = returnIsApproximate ? "Approx. \(returnText)" : returnText
return "\(label(unit: unit)) (\(returnLabel) return)"
} }
/// VoiceOver form of `label(unit:)`. /// VoiceOver form of `label(unit:)`.
@@ -1737,7 +1762,9 @@ struct StationDistanceDisplay: Equatable {
/// VoiceOver form of `labelWithReturn(unit:)`. /// VoiceOver form of `labelWithReturn(unit:)`.
func spokenLabelWithReturn(unit: DistanceUnit) -> String { func spokenLabelWithReturn(unit: DistanceUnit) -> String {
"\(spokenLabel(unit: unit)), \(unit.spoken(km * 2)) return" let returnText = unit.spoken(returnKM)
let returnLabel = returnIsApproximate ? "Approximately \(returnText)" : returnText
return "\(spokenLabel(unit: unit)), \(returnLabel) return"
} }
} }
+1 -1
View File
@@ -21,7 +21,7 @@ enum SiriCheapestLookup {
lng: Double, lng: Double,
withinMiles: Double withinMiles: Double
) -> FuelStation? { ) -> FuelStation? {
let radiusKM = withinMiles * 1.60934 let radiusKM = withinMiles * FuelSavingsCalculator.kilometresPerMile
// Same radius rule as the app list: the distance the answer would SHOW // Same radius rule as the app list: the distance the answer would SHOW
// (cached road route where there is one). // (cached road route where there is one).
return stations return stations