diff --git a/FuelBoard.xcodeproj/project.pbxproj b/FuelBoard.xcodeproj/project.pbxproj index 3d0b664..fb4661e 100644 --- a/FuelBoard.xcodeproj/project.pbxproj +++ b/FuelBoard.xcodeproj/project.pbxproj @@ -533,7 +533,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 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"; diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index b9b536f..480fb74 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -1523,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) } @@ -1545,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, @@ -1944,7 +1942,6 @@ struct StationRow: View { FuelSavingsSummaryView( availability: fuelSavings, amount: fuelSavingsAmount, - baselineStationName: nil, compact: true, distanceUnit: distanceUnit ) diff --git a/FuelBoard/FavouritesView.swift b/FuelBoard/FavouritesView.swift index ba5f1ba..f1ddd58 100644 --- a/FuelBoard/FavouritesView.swift +++ b/FuelBoard/FavouritesView.swift @@ -24,7 +24,6 @@ struct FavouritesView: View { /// Estimated saving for a favourite, against the nearest eligible station — /// 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, 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 @@ -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. diff --git a/FuelBoard/FuelSavingsViews.swift b/FuelBoard/FuelSavingsViews.swift index cbec107..a9f1318 100644 --- a/FuelBoard/FuelSavingsViews.swift +++ b/FuelBoard/FuelSavingsViews.swift @@ -73,13 +73,17 @@ 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 { case .available(let result): @@ -93,8 +97,17 @@ 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) + .minimumScaleFactor(0.75) + .layoutPriority(1) + .accessibilityLabel("Comparison: \(comparisonLabel(for: result))") + } if let extraTravel = FuelSavingsFormat.extraTravelLabel( extraDistanceMiles: result.extraDistanceMiles, unit: distanceUnit, diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index 4a16fc9..b44b3a3 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -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 = {} @@ -348,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) } diff --git a/FuelBoard/WidgetMockScreen.swift b/FuelBoard/WidgetMockScreen.swift index af218ee..d1d6577 100644 --- a/FuelBoard/WidgetMockScreen.swift +++ b/FuelBoard/WidgetMockScreen.swift @@ -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 } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 0353bbf..c7918c8 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -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") } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift index ac3ab95..d7996fa 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift @@ -263,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. diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift index 0fa0d45..e42e450 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift @@ -454,6 +454,25 @@ final class FuelSavingsEstimateTests: XCTestCase { // 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 /// figure is always miles, and converting by the current unit instead would /// leave a km user's figure unlabelled and 1.6× too large. diff --git a/FuelBoardWatch Watch App/ContentView.swift b/FuelBoardWatch Watch App/ContentView.swift index 07a96e0..99816af 100644 --- a/FuelBoardWatch Watch App/ContentView.swift +++ b/FuelBoardWatch Watch App/ContentView.swift @@ -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) } diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 7994574..ca7a5e2 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -147,7 +147,7 @@ struct FuelPriceTimelineProvider String { + status == .baseline ? "your baseline" : "vs nearest 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. diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 854f0aa..bc639c6 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -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 diff --git a/Shared/SiriCheapestLookup.swift b/Shared/SiriCheapestLookup.swift index 884eb6a..d2a1355 100644 --- a/Shared/SiriCheapestLookup.swift +++ b/Shared/SiriCheapestLookup.swift @@ -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