The listing's verdict capsule now carries white text on muted semantic fills instead of the neutral secondary tint: Saves #45825E, Costs #A9635B, Break even #707070, Closest #58789D. Fixed tones, not translucent tints, because white text on a 15% tint over a white row measures ~1.8:1. Each tone sits at a relative luminance of ~0.18 — the band where white clears 4.5:1 AND the pill still separates ~3:1 from both a white row and the dark card fill — so one value each works in light and dark without a dynamic colour. The saturated palette colours cannot carry white text at all: systemGreen measures 2.2:1, systemRed 3.6:1. "Save" becomes "Saves", matching the detail sheet and reading as a statement of the outcome; amounts, symbols, padding, radius and the shape-follows-line-count rule are unchanged. Saves and Costs share an 84pt minimum width so their trailing edges line up down the list. The price and its capsule are now one vertically grouped element (5pt), with the badge keeping the column's full 12pt above, so the row no longer reads as three equal lines. Station names cap at three lines with a tail ellipsis: unbounded wrap let one very long forecourt name set the height of the whole row. 59 of the 8,073 names in the dump exceed three lines and now ellipsise; the full name is always in Station Details. Addresses still wrap freely. The baseline pill moves onto ListingFuelSavingsCapsule so the listing shows the same soft blue and white text as its siblings, leaving FuelSavingsCapsule to the detail sheet, which it now alone uses. Badges keep their tinted styling: they annotate a price rather than report an outcome. Verified: 261/261 swift test; Debug simulator and Release device builds; all four fills and their white text measured from simulator pixels in both appearances, and the detail sheet's .blue absent from the listing entirely.
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 {
|
||
/// "Saves", "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: "Saves"),
|
||
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
|
||
}
|
||
}
|
||
}
|