Add Best Value small widget and standardise Closest wording
This commit is contained in:
@@ -21,8 +21,8 @@ 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 journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
@@ -149,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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ struct FuelSavingsSummaryView: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.75)
|
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
||||||
}
|
}
|
||||||
@@ -131,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) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
capsule(for: result, compact: false)
|
capsule(for: result, compact: false)
|
||||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
.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()
|
||||||
@@ -147,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(
|
||||||
@@ -247,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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|
||||||
|
|||||||
@@ -67,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)"
|
||||||
}
|
}
|
||||||
@@ -93,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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.";
|
||||||
|
|||||||
@@ -457,15 +457,15 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
func testCompactComparisonLabelUsesGenericNearestStationWording() {
|
func testCompactComparisonLabelUsesGenericNearestStationWording() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
|
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
|
||||||
"vs nearest station"
|
"vs closest station"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
|
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
|
||||||
"vs nearest station"
|
"vs closest station"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
FuelSavingsFormat.comparisonLabel(status: .breakEven),
|
FuelSavingsFormat.comparisonLabel(status: .breakEven),
|
||||||
"vs nearest station"
|
"vs closest station"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
FuelSavingsFormat.comparisonLabel(status: .baseline),
|
FuelSavingsFormat.comparisonLabel(status: .baseline),
|
||||||
@@ -505,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,
|
||||||
|
|||||||
@@ -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,7 +147,8 @@ 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 :
|
||||||
|
(configuration.sort == .worthTheDrive ? .worthTheDrive : .cheapest)
|
||||||
let radiusKM = Double(configuration.distance.miles) * FuelSavingsCalculator.kilometresPerMile
|
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
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -111,6 +113,13 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConf
|
|||||||
\.$fuel
|
\.$fuel
|
||||||
\.$sort
|
\.$sort
|
||||||
}
|
}
|
||||||
|
} otherwise: {
|
||||||
|
When(\.$sort, .equalTo, WidgetSort.worthTheDrive) {
|
||||||
|
Summary("Show \(\.$fuel) by \(\.$sort) within \(\.$distance)") {
|
||||||
|
\.$fuel
|
||||||
|
\.$sort
|
||||||
|
\.$distance
|
||||||
|
}
|
||||||
} otherwise: {
|
} otherwise: {
|
||||||
Summary("Show \(\.$fuel) by \(\.$sort)") {
|
Summary("Show \(\.$fuel) by \(\.$sort)") {
|
||||||
\.$fuel
|
\.$fuel
|
||||||
@@ -120,3 +129,4 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -144,10 +144,10 @@ enum FuelSavingsFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compact comparison copy kept outside the coloured verdict capsule.
|
/// Compact comparison copy kept outside the coloured verdict capsule.
|
||||||
/// "Nearest" refers to the existing shortest-journey baseline selection;
|
/// "Closest" refers to the existing shortest-journey baseline selection;
|
||||||
/// it does not claim a straight-line comparison.
|
/// it does not claim a straight-line comparison.
|
||||||
static func comparisonLabel(status: FuelSavingsStatus) -> String {
|
static func comparisonLabel(status: FuelSavingsStatus) -> String {
|
||||||
status == .baseline ? "your baseline" : "vs nearest station"
|
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
|
||||||
@@ -222,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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user