Compare commits

...
20 changed files with 296 additions and 114 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
) )
+3 -7
View File
@@ -21,10 +21,9 @@ struct FavouritesView: View {
/// still never touches the Settings default. /// still never touches the Settings default.
let fuelSavingsSettings: FuelSavingsSettings let fuelSavingsSettings: FuelSavingsSettings
@Binding var fuelSavingsAmount: FuelSavingsFillAmount @Binding var fuelSavingsAmount: FuelSavingsFillAmount
/// Estimated saving for a favourite, against the nearest eligible station — /// Estimated saving for a favourite, against the closest eligible station by
/// the same baseline and definition the Stations list uses. /// journey distance — 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
@@ -152,7 +149,7 @@ struct FavouritesView: View {
referencePricePencePerLitre: cheapestPrice referencePricePencePerLitre: cheapestPrice
) )
} footer: { } 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, 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.
+27 -8
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,16 @@ 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) {
capsule(for: result, compact: true) HStack(alignment: .firstTextBaseline, spacing: 6) {
.accessibilityLabel(accessibilityHeadline(for: result)) 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( if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles, extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit, unit: distanceUnit,
@@ -118,8 +130,16 @@ struct FuelSavingsSummaryView: View {
// room for. The old shape led with a status word + headline that // room for. The old shape led with a status word + headline that
// matched neither the listing nor each other. // matched neither the listing nor each other.
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
capsule(for: result, compact: false) HStack(alignment: .firstTextBaseline, spacing: 6) {
.accessibilityLabel(accessibilityHeadline(for: result)) 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("Your fill-up", value: amountText)
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres)) LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
.monospacedDigit() .monospacedDigit()
@@ -134,11 +154,11 @@ struct FuelSavingsSummaryView: View {
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel)) .accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
} }
if result.status == .baseline { 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) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
Text("Compared with \(baselineStationName ?? "your nearest station").") Text("Compared with \(baselineStationName ?? "your closest station").")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
LabeledContent( LabeledContent(
@@ -234,7 +254,6 @@ struct FuelSavingsSummaryView: View {
.accessibilityHidden(true) .accessibilityHidden(true)
Text(result.capsuleHeadline()) Text(result.capsuleHeadline())
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.8)
} }
.font(compact ? .caption.weight(.semibold) : .headline) .font(compact ? .caption.weight(.semibold) : .headline)
.foregroundStyle(capsuleColor(for: result.status)) .foregroundStyle(capsuleColor(for: result.status))
+1 -1
View File
@@ -14,7 +14,7 @@ struct HowBestValueSheet: View {
List { List {
Section { Section {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("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.") 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("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.") Text("Results are marked “Approx.” until road distances are available.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
+2 -2
View File
@@ -111,7 +111,7 @@ struct OnboardingView: View {
.padding(.bottom, 16) .padding(.bottom, 16)
Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title")) Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title"))
.font(.title.bold()) .font(.largeTitle.bold())
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle")) Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle"))
@@ -156,7 +156,7 @@ struct OnboardingView: View {
permissionStatusLabel( permissionStatusLabel(
granted: prompter.locationGranted, granted: prompter.locationGranted,
denied: prompter.locationDenied, 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) .padding(.top, 24)
+1 -1
View File
@@ -285,7 +285,7 @@ struct SettingsView: View {
} header: { } header: {
Text("Debug") Text("Debug")
} footer: { } 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() } .onAppear { onRefreshDebugStatus() }
+2 -4
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 = {}
@@ -68,7 +67,7 @@ struct StationsView: View {
} }
if location != nil { if location != nil {
if sortMode == .closest { 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)" return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
} }
@@ -94,7 +93,7 @@ struct StationsView: View {
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
// In Closest mode the whole country is the pool — the // 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 // empty state), so the picker is disabled but its value is
// kept for when the user switches back to Cheapest. // kept for when the user switches back to Cheapest.
.disabled(sortMode == .closest) .disabled(sortMode == .closest)
@@ -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) }
+3 -2
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 }
@@ -94,7 +94,8 @@ struct WidgetMockScreen: View {
isFavourites: false, isFavourites: false,
location: location, location: location,
locationSource: location != nil ? "live" : "none", locationSource: location != nil ? "live" : "none",
unit: FuelStore.loadDistanceUnit() unit: FuelStore.loadDistanceUnit(),
bestValueResult: nil
) )
} }
} }
+3 -3
View File
@@ -12,7 +12,7 @@
"TOP" = "TOP"; "TOP" = "TOP";
/* Stations tab */ /* 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."; "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."; "%@ %@ — tap a station for directions." = "%@ %@ — tap a station for directions.";
"Fuel type" = "Fuel type"; "Fuel type" = "Fuel type";
@@ -81,7 +81,7 @@
"Test alert notification (real data)" = "Test alert notification (real data)"; "Test alert notification (real data)" = "Test alert notification (real data)";
"Send plain test notification" = "Send plain test notification"; "Send plain test notification" = "Send plain test notification";
"Debug" = "Debug"; "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"; "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."; "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: %@"; "SMALL: %@" = "SMALL: %@";
@@ -139,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."; "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"; "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."; "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"; "Allowed" = "Allowed";
"The system prompt will appear next." = "The system prompt will appear next."; "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."; "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.";
@@ -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 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 /// 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.
@@ -486,7 +505,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: 0, netSavingGBP: 0,
status: .baseline status: .baseline
) )
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station · your baseline") XCTAssertEqual(baseline.capsuleHeadline(), "Closest station")
let saving = FuelSavingsResult( let saving = FuelSavingsResult(
fillLitres: 30, fillLitres: 30,
+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)
} }
+61 -8
View File
@@ -95,7 +95,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
data: FuelPriceWidgetEntryData( data: FuelPriceWidgetEntryData(
date: Date(), stations: Array(sample.prefix(8)), date: Date(), stations: Array(sample.prefix(8)),
fuel: .e10, sort: .cheapest, isFavourites: false, 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. // Per-widget config: fuel + sort + distance come from THIS widget instance.
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 :
let radiusKM = Double(configuration.distance.miles) * 1.60934 (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 // 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
@@ -200,6 +202,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
// 3) Load + order stations per widget mode. // 3) Load + order stations per widget mode.
let ordered: [FuelStation] let ordered: [FuelStation]
var bestValueResult: FuelSavingsResult?
if isFavourites { if isFavourites {
let favourites = FuelStore.loadFavourites() let favourites = FuelStore.loadFavourites()
.filter { $0.station.prices[$0.fuel] != nil } .filter { $0.station.prices[$0.fuel] != nil }
@@ -265,7 +268,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
let filtered = stations.filter { $0.prices[fuel] != nil } let filtered = stations.filter { $0.prices[fuel] != nil }
switch sort { switch sort {
case .closest: case .closest:
// Nearest first; price only breaks ties. // Closest first; price only breaks ties.
ordered = filtered.sorted { lhs, rhs in ordered = filtered.sorted { lhs, rhs in
if let origin { if let origin {
let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng) 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]! return lhs.prices[fuel]! < rhs.prices[fuel]!
} }
case .cheapest, .worthTheDrive: case .worthTheDrive:
// Cheapest first; distance only breaks ties. Worth-the-Drive is app-only; guard let origin else {
// widgets fall back to cheapest ordering if a stored config ever carries it. 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 ordered = filtered.sorted { lhs, rhs in
let lPrice = lhs.prices[fuel]! let lPrice = lhs.prices[fuel]!
let rPrice = rhs.prices[fuel]! let rPrice = rhs.prices[fuel]!
@@ -295,7 +348,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
date: Date(), stations: Array(ordered.prefix(8)), date: Date(), stations: Array(ordered.prefix(8)),
fuel: fuel, sort: sort, isFavourites: isFavourites, fuel: fuel, sort: sort, isFavourites: isFavourites,
location: origin, locationSource: source, location: origin, locationSource: source,
unit: unit unit: unit, bestValueResult: bestValueResult
) )
), originSource) ), originSource)
} }
+13 -3
View File
@@ -30,6 +30,7 @@ enum WidgetFuel: String, AppEnum, CaseIterable, Codable {
enum WidgetSort: String, AppEnum, CaseIterable, Codable { enum WidgetSort: String, AppEnum, CaseIterable, Codable {
case cheapest case cheapest
case closest case closest
case worthTheDrive
case favourites case favourites
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Sort by" static var typeDisplayRepresentation: TypeDisplayRepresentation = "Sort by"
@@ -37,6 +38,7 @@ enum WidgetSort: String, AppEnum, CaseIterable, Codable {
static var caseDisplayRepresentations: [WidgetSort: DisplayRepresentation] = [ static var caseDisplayRepresentations: [WidgetSort: DisplayRepresentation] = [
.cheapest: "Cheapest", .cheapest: "Cheapest",
.closest: "Closest", .closest: "Closest",
.worthTheDrive: "Best Value",
.favourites: "Favourites", .favourites: "Favourites",
] ]
} }
@@ -112,9 +114,17 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConf
\.$sort \.$sort
} }
} otherwise: { } otherwise: {
Summary("Show \(\.$fuel) by \(\.$sort)") { When(\.$sort, .equalTo, WidgetSort.worthTheDrive) {
\.$fuel Summary("Show \(\.$fuel) by \(\.$sort) within \(\.$distance)") {
\.$sort \.$fuel
\.$sort
\.$distance
}
} otherwise: {
Summary("Show \(\.$fuel) by \(\.$sort)") {
\.$fuel
\.$sort
}
} }
} }
} }
+17 -6
View File
@@ -29,11 +29,12 @@ struct FuelPriceWidgetEntryData {
let date: Date let date: Date
let stations: [FuelStation] // already filtered + sorted per config let stations: [FuelStation] // already filtered + sorted per config
let fuel: FuelType let fuel: FuelType
let sort: SortMode // per-widget: cheapest | closest let sort: SortMode // per-widget: cheapest | closest | Best Value
let isFavourites: Bool // favourites mode: pinned stations, cheapest-first let isFavourites: Bool // favourites mode: pinned stations
let location: Coordinate? let location: Coordinate?
let locationSource: String // "live" | "cached" | "none" let locationSource: String // "live" | "cached" | "none"
let unit: DistanceUnit // user's display unit for distances 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. /// The widget face, parameterised by family so the app can preview it.
@@ -67,9 +68,11 @@ struct FuelPriceWidgetContent: View {
private var heading: String { private var heading: String {
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" } if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
return entry.sort == .closest switch entry.sort {
? "Closest \(entry.fuel.displayName)" case .closest: return "Closest \(entry.fuel.displayName)"
: "Cheapest \(entry.fuel.displayName)" case .worthTheDrive: return "Best Value \(entry.fuel.displayName)"
case .cheapest: return "Cheapest \(entry.fuel.displayName)"
}
} }
private var singleStation: some View { private var singleStation: some View {
@@ -85,7 +88,15 @@ struct FuelPriceWidgetContent: View {
Text(station.name) Text(station.name)
.font(.headline) .font(.headline)
.lineLimit(1) .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) FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
} }
if let location = entry.location { if let location = entry.location {
+11 -6
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.
/// "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 /// 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.
@@ -215,13 +222,11 @@ extension FuelSavingsResult {
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ") headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
} }
/// Row capsule copy: the headline, plus what the baseline row IS. Without the /// Row capsule copy. The baseline relationship is shown as secondary text
/// suffix the baseline capsule just repeated the status word above it /// beside the capsule in the station listing and detail summary, so the
/// ("Closest station" twice) and said nothing about the saving being measured /// capsule itself contains only the verdict.
/// against it.
func capsuleHeadline() -> String { func capsuleHeadline() -> String {
let headline = headline(compact: true) headline(compact: true)
return status == .baseline ? "\(headline) · your baseline" : headline
} }
} }
+59 -32
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
@@ -1710,35 +1722,50 @@ struct RoadDistanceDiagnostics: Equatable {
/// road distance or the straight-line fallback. The confidence travels WITH the /// 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. /// value, so a label can never claim a confidence the figure doesn't have.
struct StationDistanceDisplay: Equatable { struct StationDistanceDisplay: Equatable {
let km: Double let km: Double
let isApproximate: Bool let isApproximate: Bool
let returnKM: Double
let returnIsApproximate: Bool
/// Row/sheet label: a straight-line fallback is marked "Approx." so a figure init(km: Double,
/// that is NOT a routed road distance can never be read as one, and a cached isApproximate: Bool,
/// road distance stays a bare figure. It updates by itself once routing lands, returnKM: Double? = nil,
/// because `FuelStore.stationDistanceDisplay` re-reads the cache every call. returnIsApproximate: Bool? = nil) {
func label(unit: DistanceUnit) -> String { self.km = km
let text = unit.format(km) self.isApproximate = isApproximate
return isApproximate ? "Approx. \(text)" : text self.returnKM = returnKM ?? km * 2
} self.returnIsApproximate = returnIsApproximate ?? isApproximate
}
/// Row/sheet label that keeps the one-way station distance primary, then /// Row/sheet label: a straight-line fallback is marked "Approx." so a figure
/// adds the out-and-back distance in brackets. This matches Best Value's /// that is NOT a routed road distance can never be read as one, and a cached
/// "extra return travel" basis without changing the actual distance model. /// road distance stays a bare figure. It updates by itself once routing lands,
func labelWithReturn(unit: DistanceUnit) -> String { /// because `FuelStore.stationDistanceDisplay` re-reads the cache every call.
"\(label(unit: unit)) (\(unit.format(km * 2)) return)" func label(unit: DistanceUnit) -> String {
} let text = unit.format(km)
return isApproximate ? "Approx. \(text)" : text
}
/// VoiceOver form of `label(unit:)`. /// Row/sheet label that keeps the one-way station distance primary, then
func spokenLabel(unit: DistanceUnit) -> String { /// adds the independently measured or estimated out-and-back distance.
let text = unit.spoken(km) func labelWithReturn(unit: DistanceUnit) -> String {
return isApproximate ? "Approximately \(text)" : text let returnText = unit.format(returnKM)
} let returnLabel = returnIsApproximate ? "Approx. \(returnText)" : returnText
return "\(label(unit: unit)) (\(returnLabel) return)"
}
/// VoiceOver form of `labelWithReturn(unit:)`. /// VoiceOver form of `label(unit:)`.
func spokenLabelWithReturn(unit: DistanceUnit) -> String { func spokenLabel(unit: DistanceUnit) -> String {
"\(spokenLabel(unit: unit)), \(unit.spoken(km * 2)) return" 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 /// Cached Apple-Maps road distances for nearby stations (see
+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