Station and Favourite rows share one capsule, so both now read
"Save / £3.10" and "Costs / £1.20 more" instead of the amount-free
"Saves" / "Costs more". "(approx.)" is appended on its own line when the
journey behind the figure was estimated. Break even carries no figure —
±£0.05 is noise — so it stays a single line unless the qualifier adds one.
- Split `listingCapsuleHeadline()` into a structured `ListingVerdict`
(headline / amount / qualifier) drawn as one Text per line, so where the
capsule breaks is decided by the data rather than by the width the price
column happened to hand it: the figure can never straddle two lines.
- The capsule's shape follows its line count: one line stays a Capsule, two
or more switch to a rounded rect at 8pt, whose corners do not crowd a
taller block the way a pill's semicircular ends do.
- The Key sheet's legend renders the same component with `showsAmount:
false`, so it demonstrates the wording without displaying the placeholder
£1.00 figure it was built from.
- The new words ("Save", "Costs", "(approx.)", "%@ more") go into
Localizable.strings so the copy editor owns them; amounts run through
FuelSavingsFormat.money and the amount line is monospaced.
- VoiceOver keeps the single-line form, since a drawn break is a pause
mid-phrase when spoken.
Tests cover the line structure for all four statuses, the estimated
qualifier and the amount-free legend. Pixel measurement on the simulator
confirms the shape rule: one line measures a radius of h/2 (a pill), two
lines measure 8pt, in both light and dark.
794 lines
34 KiB
Swift
794 lines
34 KiB
Swift
import Foundation
|
||
|
||
enum FuelSavingsFillUnit: String, Codable, CaseIterable, Identifiable {
|
||
case pounds
|
||
case litres
|
||
|
||
var id: String { rawValue }
|
||
|
||
var displayName: String {
|
||
switch self {
|
||
case .pounds: return "£"
|
||
case .litres: return "Litres"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct FuelSavingsFillAmount: Codable, Equatable {
|
||
var value: Double
|
||
var unit: FuelSavingsFillUnit
|
||
|
||
var isValid: Bool {
|
||
value.isFinite && value > 0 && value <= FuelSavingsCalculator.maximumFillAmount(for: unit)
|
||
}
|
||
}
|
||
|
||
struct FuelSavingsSettings: Codable, Equatable {
|
||
var enabled: Bool
|
||
var mpg: Double
|
||
var preferredAmount: FuelSavingsFillAmount
|
||
|
||
static let defaultMPG: Double = 45
|
||
static let defaultPreferredAmount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||
|
||
static var defaults: FuelSavingsSettings {
|
||
FuelSavingsSettings(enabled: false, mpg: defaultMPG, preferredAmount: defaultPreferredAmount)
|
||
}
|
||
|
||
var isConfigured: Bool {
|
||
enabled && FuelSavingsCalculator.isValidMPG(mpg) && preferredAmount.isValid
|
||
}
|
||
}
|
||
|
||
enum FuelSavingsStatus: String, Codable, Equatable {
|
||
case worthTheDrive
|
||
case breakEven
|
||
case notWorthTheDrive
|
||
/// The comparison baseline itself — the station every other saving is
|
||
/// measured against. Shown as "Closest station", never "Break even".
|
||
case baseline
|
||
|
||
var displayName: String {
|
||
switch self {
|
||
case .worthTheDrive: return "Worth the drive"
|
||
case .breakEven: return "Break even"
|
||
case .notWorthTheDrive: return "Not worth the drive"
|
||
case .baseline: return FuelSavingsResult.baselineHeadline
|
||
}
|
||
}
|
||
}
|
||
|
||
/// How the journey distances behind an estimate were obtained.
|
||
enum FuelSavingsDistanceBasis: String, Codable, Equatable {
|
||
/// Measured road route, out and back, from the routing service.
|
||
case roadRoute
|
||
/// Straight-line out-and-back estimate, used until a route is measured.
|
||
case straightLine
|
||
|
||
var isEstimate: Bool { self == .straightLine }
|
||
}
|
||
|
||
/// Whether a Best Value comparison is backed by complete road-route data.
|
||
enum JourneyConfidence: String, Codable, Equatable {
|
||
/// Both baseline and candidate have measured outward and return road routes.
|
||
case confirmed
|
||
/// At least one journey is straight-line, missing, failed, or using a legacy
|
||
/// one-way road cache whose return leg is inferred rather than measured.
|
||
case estimated
|
||
|
||
var isEstimated: Bool { self == .estimated }
|
||
}
|
||
|
||
struct FuelSavingsResult: Codable, Equatable {
|
||
let fillLitres: Double
|
||
let grossSavingGBP: Double
|
||
let extraFuelLitres: Double
|
||
let extraDrivingCostGBP: Double
|
||
let extraDistanceMiles: Double
|
||
let netSavingGBP: Double
|
||
let status: FuelSavingsStatus
|
||
let distanceBasis: FuelSavingsDistanceBasis
|
||
let journeyConfidence: JourneyConfidence
|
||
|
||
init(fillLitres: Double,
|
||
grossSavingGBP: Double,
|
||
extraFuelLitres: Double = 0,
|
||
extraDrivingCostGBP: Double,
|
||
extraDistanceMiles: Double,
|
||
netSavingGBP: Double,
|
||
status: FuelSavingsStatus,
|
||
distanceBasis: FuelSavingsDistanceBasis = .roadRoute,
|
||
journeyConfidence: JourneyConfidence? = nil) {
|
||
self.fillLitres = fillLitres
|
||
self.grossSavingGBP = grossSavingGBP
|
||
self.extraFuelLitres = extraFuelLitres
|
||
self.extraDrivingCostGBP = extraDrivingCostGBP
|
||
self.extraDistanceMiles = extraDistanceMiles
|
||
self.netSavingGBP = netSavingGBP
|
||
self.status = status
|
||
self.distanceBasis = distanceBasis
|
||
self.journeyConfidence = journeyConfidence ?? (distanceBasis.isEstimate ? .estimated : .confirmed)
|
||
}
|
||
}
|
||
|
||
/// Money formatting for savings strings, in one place so the exact wording the
|
||
/// UI shows is testable.
|
||
enum FuelSavingsFormat {
|
||
static func money(_ value: Double) -> String {
|
||
String(format: "£%.2f", value)
|
||
}
|
||
|
||
/// A distance stored in MILES, formatted in the display unit. The figures
|
||
/// behind an estimate are stored in miles whatever the user's unit, so this
|
||
/// is the one place that conversion lives.
|
||
static func distance(_ miles: Double, unit: DistanceUnit) -> String {
|
||
// Convert miles→km FIRST, then let the unit format it. `unit.toKM` would
|
||
// convert by the CURRENT unit instead, leaving a km user's figure
|
||
// unlabelled and 1.6× too large.
|
||
unit.format(miles * FuelSavingsCalculator.kilometresPerMile)
|
||
}
|
||
|
||
/// Row label for the extra driving a saving has to beat. The stored figure is
|
||
/// always MILES while the row's distance line is one-way, so the wording names
|
||
/// the RETURN journey: the figure is an out-and-back DELTA between two
|
||
/// journeys and can legitimately exceed the one-way distance, which read as a
|
||
/// contradiction when it said "more driving than closest".
|
||
static func extraTravelLabel(extraDistanceMiles: Double,
|
||
unit: DistanceUnit,
|
||
confidence: JourneyConfidence = .confirmed) -> String? {
|
||
guard let distanceText = extraTravelDistanceLabel(extraDistanceMiles: extraDistanceMiles,
|
||
unit: unit,
|
||
confidence: confidence) else { return nil }
|
||
return "\(distanceText) extra return travel"
|
||
}
|
||
|
||
/// The extra-travel FIGURE on its own, with its confidence marker: "4.3 mi"
|
||
/// or "Approx. 4.3 mi".
|
||
///
|
||
/// A row form ("Extra return travel | 4.3 mi") names the journey in its own
|
||
/// label, so this is the same figure and the same "Approx." rule as
|
||
/// `extraTravelLabel`, from the same inputs — never rebuilt at a call site,
|
||
/// or one surface marks an estimate while another drops the marker.
|
||
static func extraTravelDistanceLabel(extraDistanceMiles: Double,
|
||
unit: DistanceUnit,
|
||
confidence: JourneyConfidence = .confirmed) -> String? {
|
||
guard extraDistanceMiles > 0.05 else { return nil }
|
||
let distanceText = distance(extraDistanceMiles, unit: unit)
|
||
return confidence == .estimated ? "Approx. \(distanceText)" : distanceText
|
||
}
|
||
|
||
/// Compact comparison copy kept outside the coloured verdict capsule.
|
||
/// "Closest" refers to the existing shortest-journey baseline selection;
|
||
/// it does not claim a straight-line comparison.
|
||
static func comparisonLabel(status: FuelSavingsStatus) -> String {
|
||
status == .baseline ? "your baseline" : "vs closest station"
|
||
}
|
||
|
||
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
|
||
/// are spoken as words ("Approximately 6.4 miles extra return travel") because
|
||
/// synthesizers render "Approx." and "mi" inconsistently.
|
||
static func extraTravelSpokenLabel(extraDistanceMiles: Double,
|
||
unit: DistanceUnit,
|
||
confidence: JourneyConfidence = .confirmed) -> String? {
|
||
guard extraDistanceMiles > 0.05 else { return nil }
|
||
let distanceText = spokenDistance(extraDistanceMiles, unit: unit)
|
||
return confidence == .estimated
|
||
? "Approximately \(distanceText) extra return travel"
|
||
: "\(distanceText) extra return travel"
|
||
}
|
||
|
||
/// A distance stored in MILES, spoken: "6.4 miles" / "10.3 kilometres". The
|
||
/// miles→km hop is deliberate: the stored figure is miles while
|
||
/// `DistanceUnit.spoken` speaks a KM value, the same boundary `distance(_:unit:)`
|
||
/// crosses.
|
||
static func spokenDistance(_ miles: Double, unit: DistanceUnit) -> String {
|
||
unit.spoken(miles * FuelSavingsCalculator.kilometresPerMile)
|
||
}
|
||
}
|
||
|
||
extension FuelSavingsResult {
|
||
/// Baseline row label — required wording.
|
||
static let baselineHeadline = "Closest station"
|
||
/// Neutral result label — required wording.
|
||
static let breakEvenHeadline = "Break even"
|
||
|
||
/// The same estimate relabelled as the comparison baseline: nothing to save
|
||
/// or lose against itself, and no extra travel.
|
||
func asBaseline() -> FuelSavingsResult {
|
||
FuelSavingsResult(
|
||
fillLitres: fillLitres,
|
||
grossSavingGBP: 0,
|
||
extraFuelLitres: 0,
|
||
extraDrivingCostGBP: 0,
|
||
extraDistanceMiles: 0,
|
||
netSavingGBP: 0,
|
||
status: .baseline,
|
||
distanceBasis: distanceBasis,
|
||
journeyConfidence: journeyConfidence
|
||
)
|
||
}
|
||
|
||
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
|
||
/// travel", "Break even", "Closest station".
|
||
///
|
||
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
|
||
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
|
||
/// tech may skip it) instead of saying "this is an estimate". Break-even and
|
||
/// baseline stay plain words, with the estimate context carried by the
|
||
/// extra-distance caption or the detail explanation.
|
||
func headline(compact: Bool = false) -> String {
|
||
let prefix = journeyConfidence.isEstimated ? "Approx. " : ""
|
||
switch status {
|
||
case .baseline:
|
||
return Self.baselineHeadline
|
||
case .breakEven:
|
||
return Self.breakEvenHeadline
|
||
case .worthTheDrive:
|
||
return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP))"
|
||
case .notWorthTheDrive:
|
||
return "\(prefix)Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more"
|
||
}
|
||
}
|
||
|
||
/// VoiceOver form of `headline()`: "Approx." is spelled out so a synthesizer
|
||
/// cannot read the abbreviation as "approx" or drop it entirely.
|
||
func spokenHeadline() -> String {
|
||
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
|
||
}
|
||
|
||
/// Full monetary verdict used by Station Details. Estimated confidence is a
|
||
/// trailing qualifier so the amount remains the first figure users scan.
|
||
func detailCapsuleHeadline() -> String {
|
||
let qualifier = journeyConfidence.isEstimated ? " (approx.)" : ""
|
||
switch status {
|
||
case .baseline:
|
||
return Self.baselineHeadline
|
||
case .breakEven:
|
||
return "\(Self.breakEvenHeadline)\(qualifier)"
|
||
case .worthTheDrive:
|
||
return "Save \(FuelSavingsFormat.money(netSavingGBP))\(qualifier)"
|
||
case .notWorthTheDrive:
|
||
return "Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more\(qualifier)"
|
||
}
|
||
}
|
||
|
||
func detailCapsuleSpokenHeadline() -> String {
|
||
detailCapsuleHeadline().replacingOccurrences(of: "(approx.)", with: "(approximately)")
|
||
}
|
||
|
||
/// Row capsule copy. The baseline relationship is shown as secondary text
|
||
/// beside the capsule in the station listing and detail summary, so the
|
||
/// capsule itself contains only the verdict.
|
||
func capsuleHeadline() -> String {
|
||
headline(compact: true)
|
||
}
|
||
|
||
/// The station-listing capsule's verdict, split into the lines the capsule
|
||
/// draws.
|
||
///
|
||
/// The amount always takes its OWN line, so where the capsule breaks is
|
||
/// decided by the data, never by the width it was handed: the figure can
|
||
/// never straddle two lines, and a taller verdict grows the capsule downward
|
||
/// instead of widening the row's fixed price column.
|
||
struct ListingVerdict: Equatable {
|
||
/// "Save", "Costs", "Break even" or "Closest station".
|
||
let headline: String
|
||
/// "£3.10" / "£1.20 more" — nil when there is no figure to show.
|
||
let amount: String?
|
||
/// "(approx.)" when the journey behind the figure was estimated.
|
||
let qualifier: String?
|
||
|
||
var lines: [String] { [headline, amount, qualifier].compactMap { $0 } }
|
||
|
||
/// Single-line form, for VoiceOver and tests. A drawn newline is a pause
|
||
/// in the middle of a phrase when spoken.
|
||
var spokenText: String { lines.joined(separator: " ") }
|
||
}
|
||
|
||
/// Verdict for the station listing rows — Stations and Favourites share one
|
||
/// row, so they share this. `showsAmount: false` is the Key sheet's legend,
|
||
/// which demonstrates the wording without inventing a figure.
|
||
func listingVerdict(showsAmount: Bool = true) -> ListingVerdict {
|
||
let qualifier = journeyConfidence.isEstimated ? String(localized: "(approx.)") : nil
|
||
switch status {
|
||
case .worthTheDrive:
|
||
return ListingVerdict(
|
||
headline: String(localized: "Save"),
|
||
amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil,
|
||
qualifier: qualifier
|
||
)
|
||
case .notWorthTheDrive:
|
||
return ListingVerdict(
|
||
headline: String(localized: "Costs"),
|
||
amount: showsAmount ? Self.amountWithMore(abs(netSavingGBP)) : nil,
|
||
qualifier: qualifier
|
||
)
|
||
case .breakEven:
|
||
// No figure: ±£0.05 is noise, so this stays a single line unless the
|
||
// estimate qualifier adds its own.
|
||
return ListingVerdict(
|
||
headline: String(localized: "Break even"),
|
||
amount: nil,
|
||
qualifier: qualifier
|
||
)
|
||
case .baseline:
|
||
return ListingVerdict(
|
||
headline: Self.baselineHeadline,
|
||
amount: nil,
|
||
qualifier: nil
|
||
)
|
||
}
|
||
}
|
||
|
||
/// "£1.20 more" — kept as a format string so the copy editor owns the word.
|
||
private static func amountWithMore(_ value: Double) -> String {
|
||
String(format: String(localized: "%@ more"), FuelSavingsFormat.money(value))
|
||
}
|
||
|
||
/// One-line verdict for voice and tests; the capsule itself renders
|
||
/// `listingVerdict()`'s lines separately.
|
||
func listingCapsuleHeadline() -> String {
|
||
listingVerdict().spokenText
|
||
}
|
||
}
|
||
|
||
/// One station's journey distance, plus how it was obtained.
|
||
struct FuelSavingsJourney: Equatable {
|
||
let miles: Double
|
||
let basis: FuelSavingsDistanceBasis
|
||
let confidence: JourneyConfidence
|
||
|
||
init(miles: Double,
|
||
basis: FuelSavingsDistanceBasis,
|
||
confidence: JourneyConfidence? = nil) {
|
||
self.miles = miles
|
||
self.basis = basis
|
||
self.confidence = confidence ?? (basis.isEstimate ? .estimated : .confirmed)
|
||
}
|
||
}
|
||
|
||
enum FuelSavingsAvailability: Equatable {
|
||
case available(FuelSavingsResult)
|
||
case unavailable(String)
|
||
|
||
var result: FuelSavingsResult? {
|
||
if case .available(let result) = self { return result }
|
||
return nil
|
||
}
|
||
}
|
||
|
||
struct FuelSavingsRankedStation: Equatable {
|
||
let stationID: String
|
||
let result: FuelSavingsAvailability
|
||
let distanceMiles: Double
|
||
let pricePencePerLitre: Double
|
||
}
|
||
|
||
/// The user-facing copy shown by the Stations tab's Key sheet. Keeping the
|
||
/// descriptions in one Foundation-only model lets tests pin the exact meaning of
|
||
/// each visual indicator without coupling the wording to SwiftUI layout.
|
||
enum KeySheetContent {
|
||
struct Item: Equatable {
|
||
let label: String
|
||
let description: String
|
||
}
|
||
|
||
static let priceRatings = [
|
||
Item(label: "Good price", description: "Within 1.5p/L of the cheapest."),
|
||
Item(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
|
||
Item(label: "Pricey", description: "More than 4p/L above the cheapest.")
|
||
]
|
||
|
||
static let savings = [
|
||
Item(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
|
||
Item(label: "Break even", description: "The saving or extra cost is negligible."),
|
||
Item(label: "Costs more", description: "Filling up here costs more than at your closest station, including travel.")
|
||
]
|
||
|
||
static let stationLabels = [
|
||
Item(label: "BEST VALUE", description: "Meets your minimum saving after travel costs."),
|
||
Item(label: "CHEAPEST", description: "The lowest fuel price per litre within your selected range."),
|
||
Item(label: "CLOSEST", description: "The nearest station by journey distance."),
|
||
Item(label: "TOP", description: "The cheapest station in your Favourites for the selected fuel.")
|
||
]
|
||
|
||
static let favourite = Item(
|
||
label: "Favourites",
|
||
description: "Star a station to save it to your Favourites."
|
||
)
|
||
|
||
static let helpTitle = "How does Best Value work?"
|
||
}
|
||
|
||
/// Which recommendation badge a station listing row shows, if any.
|
||
///
|
||
/// Presentation only — no new rules: the BEST VALUE qualification reuses the
|
||
/// app's existing Best Value gate (the user's alert threshold, the same figure
|
||
/// `FuelSavingsCalculator.bestValueAlertCandidate` is given) and CHEAPEST
|
||
/// reuses the caller's existing "cheapest in range" flag (the station behind
|
||
/// `topStationID`). One badge per row: a station that is both the cheapest and
|
||
/// worth the drive shows BEST VALUE, the stronger of the two.
|
||
enum StationRecommendationBadge: Equatable {
|
||
case bestValue
|
||
case cheapest
|
||
|
||
static func badge(netSavingGBP: Double?,
|
||
isCheapest: Bool,
|
||
bestValueThresholdGBP: Double) -> StationRecommendationBadge? {
|
||
if let netSavingGBP, netSavingGBP.isFinite, bestValueThresholdGBP.isFinite,
|
||
netSavingGBP >= bestValueThresholdGBP {
|
||
return .bestValue
|
||
}
|
||
return isCheapest ? .cheapest : nil
|
||
}
|
||
}
|
||
|
||
struct BestValueAlertCandidate: Equatable {
|
||
let station: FuelStation
|
||
let result: FuelSavingsResult
|
||
}
|
||
|
||
enum FuelSavingsError: Error, Equatable {
|
||
case invalidMPG
|
||
case invalidAmount
|
||
case invalidPrice
|
||
case invalidDistance
|
||
}
|
||
|
||
enum FuelSavingsCalculator {
|
||
/// 1 UK (imperial) gallon. Must never be the US gallon (3.785411784 L):
|
||
/// UK pumps are priced per litre and drivers quote UK MPG, so a US gallon
|
||
/// would understate every travel cost by 17%.
|
||
static let imperialGallonLitres = 4.54609
|
||
/// Exact mile↔kilometre factors (1 mile = 1.609344 km). Every figure the
|
||
/// savings maths keeps is in MILES, and every displayed or fallback
|
||
/// distance converts through these, so one quantity is never converted with
|
||
/// two different constants. The audit found 1.60934, 1609.34 and 0.621371
|
||
/// mixed in the same path — a ~2 parts-per-million difference, immaterial to
|
||
/// the money, but three constants where there should be one.
|
||
static let kilometresPerMile = 1.609344
|
||
static let metresPerMile = 1609.344
|
||
static let breakEvenToleranceGBP = 0.05
|
||
static let minimumMPG: Double = 5
|
||
static let maximumMPG: Double = 200
|
||
|
||
static func maximumFillAmount(for unit: FuelSavingsFillUnit) -> Double {
|
||
switch unit {
|
||
case .pounds: return 500
|
||
case .litres: return 250
|
||
}
|
||
}
|
||
|
||
/// Bounds for every fill-up SLIDER (Settings and the in-list adjustment
|
||
/// share them, so the two controls can never offer different ranges). The
|
||
/// validity ceiling above is deliberately wider, so a stored outlier is
|
||
/// displayed rather than destroyed by the control that shows it.
|
||
static func fillAmountSliderRange(for unit: FuelSavingsFillUnit) -> ClosedRange<Double> {
|
||
switch unit {
|
||
case .pounds: return 10...100
|
||
case .litres: return 10...80
|
||
}
|
||
}
|
||
|
||
static func fillAmountSliderStep(for unit: FuelSavingsFillUnit) -> Double {
|
||
switch unit {
|
||
case .pounds: return 5
|
||
case .litres: return 1
|
||
}
|
||
}
|
||
|
||
/// Bounds for the average-MPG slider. The validity bounds (5...200) are
|
||
/// wide enough for any stored figure but far too wide to fine-tune by
|
||
/// touch; every real-world UK car sits inside this range.
|
||
static let mpgSliderRange: ClosedRange<Double> = 10...100
|
||
static let mpgSliderStep: Double = 1
|
||
|
||
static func isValidMPG(_ mpg: Double) -> Bool {
|
||
mpg.isFinite && mpg >= minimumMPG && mpg <= maximumMPG
|
||
}
|
||
|
||
static func fuelUsedLitres(distanceMiles: Double, mpg: Double) -> Double {
|
||
distanceMiles / mpg * imperialGallonLitres
|
||
}
|
||
|
||
static func fillLitres(amount: FuelSavingsFillAmount, pricePencePerLitre: Double) throws -> Double {
|
||
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
|
||
switch amount.unit {
|
||
case .litres:
|
||
return amount.value
|
||
case .pounds:
|
||
guard pricePencePerLitre.isFinite, pricePencePerLitre > 0 else {
|
||
throw FuelSavingsError.invalidPrice
|
||
}
|
||
return amount.value / (pricePencePerLitre / 100.0)
|
||
}
|
||
}
|
||
|
||
/// Changing between a per-candidate monetary budget and a fixed litre
|
||
/// quantity has no lossless conversion across all stations. Start a new
|
||
/// comparison at that mode's stated default; the UI must tell the user
|
||
/// explicitly that the comparison basis changed and invite adjustment.
|
||
static func newComparisonAmount(afterSwitching amount: FuelSavingsFillAmount,
|
||
to unit: FuelSavingsFillUnit) -> FuelSavingsFillAmount {
|
||
guard amount.unit != unit else { return amount }
|
||
switch unit {
|
||
case .pounds: return FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||
case .litres: return FuelSavingsFillAmount(value: 30, unit: .litres)
|
||
}
|
||
}
|
||
|
||
static func convert(amount: FuelSavingsFillAmount,
|
||
to unit: FuelSavingsFillUnit,
|
||
referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount {
|
||
guard referencePricePencePerLitre.isFinite, referencePricePencePerLitre > 0 else {
|
||
throw FuelSavingsError.invalidPrice
|
||
}
|
||
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
|
||
guard amount.unit != unit else { return amount }
|
||
switch (amount.unit, unit) {
|
||
case (.pounds, .litres):
|
||
return FuelSavingsFillAmount(
|
||
value: amount.value / (referencePricePencePerLitre / 100.0),
|
||
unit: .litres
|
||
)
|
||
case (.litres, .pounds):
|
||
return FuelSavingsFillAmount(
|
||
value: amount.value * (referencePricePencePerLitre / 100.0),
|
||
unit: .pounds
|
||
)
|
||
case (.pounds, .pounds), (.litres, .litres):
|
||
return amount
|
||
}
|
||
}
|
||
|
||
static func calculate(mpg: Double,
|
||
amount: FuelSavingsFillAmount,
|
||
baselinePricePencePerLitre: Double,
|
||
candidatePricePencePerLitre: Double,
|
||
baselineJourneyMiles: Double,
|
||
candidateJourneyMiles: Double,
|
||
distanceBasis: FuelSavingsDistanceBasis = .roadRoute,
|
||
journeyConfidence: JourneyConfidence? = nil,
|
||
breakEvenToleranceGBP: Double = Self.breakEvenToleranceGBP) throws -> FuelSavingsResult {
|
||
guard isValidMPG(mpg) else { throw FuelSavingsError.invalidMPG }
|
||
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0,
|
||
candidatePricePencePerLitre.isFinite, candidatePricePencePerLitre > 0 else {
|
||
throw FuelSavingsError.invalidPrice
|
||
}
|
||
guard baselineJourneyMiles.isFinite, candidateJourneyMiles.isFinite,
|
||
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
|
||
throw FuelSavingsError.invalidDistance
|
||
}
|
||
let fillLitres = try fillLitres(amount: amount, pricePencePerLitre: candidatePricePencePerLitre)
|
||
let baselinePriceGBP = baselinePricePencePerLitre / 100.0
|
||
let candidatePriceGBP = candidatePricePencePerLitre / 100.0
|
||
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
|
||
let extraDistance = max(0, candidateJourneyMiles - baselineJourneyMiles)
|
||
let extraFuelLitres = fuelUsedLitres(distanceMiles: extraDistance, mpg: mpg)
|
||
let extraFuelCost = extraFuelLitres * candidatePriceGBP
|
||
let netSaving = grossSaving - extraFuelCost
|
||
let status: FuelSavingsStatus
|
||
if netSaving > breakEvenToleranceGBP {
|
||
status = .worthTheDrive
|
||
} else if netSaving < -breakEvenToleranceGBP {
|
||
status = .notWorthTheDrive
|
||
} else {
|
||
status = .breakEven
|
||
}
|
||
return FuelSavingsResult(
|
||
fillLitres: fillLitres,
|
||
grossSavingGBP: grossSaving,
|
||
extraFuelLitres: extraFuelLitres,
|
||
extraDrivingCostGBP: extraFuelCost,
|
||
extraDistanceMiles: extraDistance,
|
||
netSavingGBP: netSaving,
|
||
status: status,
|
||
distanceBasis: distanceBasis,
|
||
journeyConfidence: journeyConfidence
|
||
)
|
||
}
|
||
|
||
static func sortedForWorthTheDrive(_ stations: [FuelSavingsRankedStation]) -> [FuelSavingsRankedStation] {
|
||
stations.sorted { lhs, rhs in
|
||
switch (lhs.result.result, rhs.result.result) {
|
||
case let (l?, r?):
|
||
if l.netSavingGBP != r.netSavingGBP { return l.netSavingGBP > r.netSavingGBP }
|
||
if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles }
|
||
if lhs.pricePencePerLitre != rhs.pricePencePerLitre { return lhs.pricePencePerLitre < rhs.pricePencePerLitre }
|
||
return lhs.stationID < rhs.stationID
|
||
case (_?, nil):
|
||
return true
|
||
case (nil, _?):
|
||
return false
|
||
case (nil, nil):
|
||
if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles }
|
||
return lhs.stationID < rhs.stationID
|
||
}
|
||
}
|
||
}
|
||
|
||
static func bestValueAlertCandidate(
|
||
stations: [FuelStation],
|
||
fuel: FuelType,
|
||
settings: FuelSavingsSettings,
|
||
amount: FuelSavingsFillAmount,
|
||
minimumNetSavingGBP: Double,
|
||
journeyMiles: (FuelStation) -> FuelSavingsJourney?
|
||
) -> BestValueAlertCandidate? {
|
||
guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil }
|
||
let sellers = stations.filter { $0.prices[fuel] != nil }
|
||
guard !sellers.isEmpty else { return nil }
|
||
let baseline = FuelSavingsEstimator.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles)
|
||
var best: BestValueAlertCandidate?
|
||
var bestDistance = Double.greatestFiniteMagnitude
|
||
var bestPrice = Double.infinity
|
||
var baselineJourneyCache: FuelSavingsJourney?
|
||
var baselineJourneyLoaded = false
|
||
for station in sellers {
|
||
let journey = journeyMiles(station)
|
||
let baselineJourney: FuelSavingsJourney? = {
|
||
guard let baseline else { return nil }
|
||
if baseline.id == station.id { return journey }
|
||
if !baselineJourneyLoaded {
|
||
baselineJourneyCache = journeyMiles(baseline)
|
||
baselineJourneyLoaded = true
|
||
}
|
||
return baselineJourneyCache
|
||
}()
|
||
guard case .available(let result) = FuelSavingsEstimator.availability(
|
||
station: station,
|
||
fuel: fuel,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: journey,
|
||
baselineJourney: baselineJourney
|
||
), result.status == .worthTheDrive,
|
||
result.netSavingGBP >= minimumNetSavingGBP else { continue }
|
||
let distance = journey?.miles ?? .greatestFiniteMagnitude
|
||
let price = station.prices[fuel] ?? .infinity
|
||
guard let current = best else {
|
||
best = BestValueAlertCandidate(station: station, result: result)
|
||
bestDistance = distance
|
||
bestPrice = price
|
||
continue
|
||
}
|
||
if result.netSavingGBP > current.result.netSavingGBP ||
|
||
(result.netSavingGBP == current.result.netSavingGBP && distance < bestDistance) ||
|
||
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price < bestPrice) ||
|
||
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price == bestPrice && station.id < current.station.id) {
|
||
best = BestValueAlertCandidate(station: station, result: result)
|
||
bestDistance = distance
|
||
bestPrice = price
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
}
|
||
|
||
/// Turns stations into savings estimates.
|
||
///
|
||
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
|
||
/// rules are unit tested directly.
|
||
enum FuelSavingsEstimator {
|
||
/// Baseline station for Best Value: the station with the shortest journey
|
||
/// among stations that sell the selected fuel, using the SAME journey source
|
||
/// the savings calculation will use. This keeps the row labelled "Closest
|
||
/// station" and every "extra return travel" value tied to one
|
||
/// reference, instead of mixing a separate straight-line baseline with
|
||
/// road-route savings.
|
||
static func baselineStation(_ stations: [FuelStation],
|
||
fuel: FuelType,
|
||
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? {
|
||
var bestStation: FuelStation?
|
||
var bestMiles = Double.infinity
|
||
for station in stations where station.prices[fuel] != nil {
|
||
guard let journey = journeyMiles(station) else { continue }
|
||
let miles = journey.miles
|
||
guard miles.isFinite, miles >= 0 else { continue }
|
||
if miles < bestMiles {
|
||
bestMiles = miles
|
||
bestStation = station
|
||
}
|
||
}
|
||
return bestStation
|
||
}
|
||
|
||
/// Savings for a single station against `baseline`, or nil when there is
|
||
/// nothing to show at all (feature off).
|
||
static func availability(station: FuelStation,
|
||
fuel: FuelType,
|
||
baseline: FuelStation?,
|
||
settings: FuelSavingsSettings,
|
||
amount: FuelSavingsFillAmount,
|
||
journey: FuelSavingsJourney?,
|
||
baselineJourney: FuelSavingsJourney?) -> FuelSavingsAvailability? {
|
||
guard settings.isConfigured else { return nil }
|
||
guard let baseline, let baselineJourney else { return .unavailable("No comparison station") }
|
||
guard let baselinePrice = baseline.prices[fuel] else { return .unavailable("No comparison station") }
|
||
guard let price = station.prices[fuel] else { return .unavailable("No price") }
|
||
guard let journey else { return .unavailable("Distance unavailable") }
|
||
// Both journeys must be measured, complete road routes for the comparison
|
||
// to be confirmed. Legacy one-way caches keep the road-route basis for
|
||
// distance arithmetic but are still provisional because the return leg is
|
||
// inferred rather than measured.
|
||
let basis: FuelSavingsDistanceBasis =
|
||
(journey.basis == .roadRoute && baselineJourney.basis == .roadRoute) ? .roadRoute : .straightLine
|
||
let confidence: JourneyConfidence =
|
||
(journey.confidence == .confirmed && baselineJourney.confidence == .confirmed) ? .confirmed : .estimated
|
||
do {
|
||
let result = try FuelSavingsCalculator.calculate(
|
||
mpg: settings.mpg,
|
||
amount: amount,
|
||
baselinePricePencePerLitre: baselinePrice,
|
||
candidatePricePencePerLitre: price,
|
||
baselineJourneyMiles: baselineJourney.miles,
|
||
candidateJourneyMiles: journey.miles,
|
||
distanceBasis: basis,
|
||
journeyConfidence: confidence
|
||
)
|
||
// The baseline compares against itself: label it as the closest
|
||
// station rather than a meaningless "Break even".
|
||
return .available(station.id == baseline.id ? result.asBaseline() : result)
|
||
} catch {
|
||
return .unavailable("Distance unavailable")
|
||
}
|
||
}
|
||
|
||
/// Ranks a whole pool for the Best Value sort.
|
||
///
|
||
/// EVERY priced station in the pool gets an estimate, not just the handful
|
||
/// whose road routes happen to have been measured: journeys that have no
|
||
/// measured route fall back to a straight-line out-and-back estimate (see
|
||
/// `FuelSavingsDistanceBasis`). Capping this to a shortlist dropped cheaper
|
||
/// stations that were a few miles further away — the exact stations the
|
||
/// sort exists to surface — and left later pages with no savings at all.
|
||
static func ranked(_ stations: [FuelStation],
|
||
fuel: FuelType,
|
||
baseline: FuelStation?,
|
||
settings: FuelSavingsSettings,
|
||
amount: FuelSavingsFillAmount,
|
||
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> [FuelStation] {
|
||
var journeys: [String: FuelSavingsJourney] = [:]
|
||
journeys.reserveCapacity(stations.count)
|
||
for station in stations {
|
||
if let journey = journeyMiles(station) {
|
||
journeys[station.id] = journey
|
||
}
|
||
}
|
||
// The baseline is normally in the pool, so this is the same lookup it has
|
||
// always been; asking for its journey directly covers the case where the
|
||
// list being ranked is filtered tighter than the baseline was chosen from,
|
||
// which would otherwise mark every row unavailable.
|
||
let baselineJourney = baseline.flatMap { journeys[$0.id] ?? journeyMiles($0) }
|
||
let ranked = stations.map { station -> FuelSavingsRankedStation in
|
||
let journey = journeys[station.id]
|
||
let availability = availability(station: station,
|
||
fuel: fuel,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: journey,
|
||
baselineJourney: baselineJourney) ?? .unavailable("Unavailable")
|
||
return FuelSavingsRankedStation(
|
||
stationID: station.id,
|
||
result: availability,
|
||
distanceMiles: journey?.miles ?? .greatestFiniteMagnitude,
|
||
pricePencePerLitre: station.prices[fuel] ?? .infinity
|
||
)
|
||
}
|
||
var order: [String: Int] = [:]
|
||
for (offset, rankedStation) in FuelSavingsCalculator.sortedForWorthTheDrive(ranked).enumerated() {
|
||
if order[rankedStation.stationID] == nil {
|
||
order[rankedStation.stationID] = offset
|
||
}
|
||
}
|
||
return stations.sorted { lhs, rhs in
|
||
let left = order[lhs.id] ?? Int.max
|
||
let right = order[rhs.id] ?? Int.max
|
||
if left != right { return left < right }
|
||
return lhs.id < rhs.id
|
||
}
|
||
}
|
||
}
|