Files
fuelboard/Shared/FuelStore.swift
FuelBoard Contributor e5a2b2ebdb Settings: concise copy and tighter hierarchy
Rewrite the Settings screen in short British English and remove the
duplicated paragraphs, keeping every control, binding and preference key as
it was.

- Fuel Savings: one-line descriptions for Best Value, average fuel economy,
  fill-up amount (worded for the unit in use) and minimum saving; one concise
  general explanation replaces the long footer; the orange "New comparison
  basis" notice is removed.
- Units: "Station sign" becomes "Pence per litre"; the combined paragraph
  becomes two contextual descriptions.
- Distances: "Accurate distances" becomes "Driving distances" with a short
  description; the route-request and search-radii paragraph is removed.
- Tap action: "Open map"/"More info" become "Directions"/"Station details"
  with a short description.
- Data checking: Standard and Smart summaries cut to one line each, one shared
  background-update note, duplicated footer removed.
- Replay onboarding: shorter description. Support FuelBoard untouched.

Copy and layout only: no calculations, ranking, refresh schedules, stored
values or persistence changed. New and reworded literals live in
FuelBoard/en.lproj/Localizable.strings.
2026-10-17 10:50:59 +01:00

1912 lines
84 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FuelBoard shared model — compiled into BOTH the app and the widget extension.
// Kept Foundation-only so both targets can compile it (no SwiftUI dependency).
//
// Storage strategy (same as WidgetBoard — proven on free SideStore accounts):
// App-group UserDefaults + Keychain (generic password). Keychain first:
// shared group $(AppIdentifierPrefix)com.apt.fuelboard.shared is listed FIRST
// in the entitlements so it is the default access group. Read order:
// keychain → app-group defaults → fallback.
import Foundation
import SwiftUI
import Security
#if canImport(AppIntents)
import AppIntents
#endif
import SwiftUI
// MARK: - Fuel types
struct Coordinate: Equatable, Codable {
let lat: Double
let lng: Double
}
/// A favourite = a station pinned for ONE fuel type. Starring a row while
/// viewing Unleaded only creates an Unleaded favourite, so the same station
/// can be favourite for Diesel independently (or not at all).
struct FavouriteEntry: Identifiable, Codable, Equatable {
var station: FuelStation
let fuel: FuelType
var id: String { "\(fuel.rawValue)|\(station.id)" }
}
/// Persisted baseline for the per-fuel cheapest-favourite price-drop alert.
/// Stored separately from the favourites list so refresh-time comparison can
/// survive app relaunches without immediately alerting on first fetch.
struct FavouriteAlertSnapshot: Codable, Equatable {
let fuel: FuelType
let stationID: String
let stationName: String
let price: Double
}
enum SortMode: String, Codable, CaseIterable, Identifiable {
case cheapest
case closest
case worthTheDrive
var id: String { rawValue }
var displayName: String {
switch self {
case .cheapest: return "Cheapest"
case .closest: return "Closest"
// "Best Value" is the user-facing name of this sort — the segmented
// control, the Stations caption, the Settings toggle and narrative, and
// the sheet's section header all read it. The raw value stays
// `worthTheDrive` so stored preferences and widget configs keep decoding.
case .worthTheDrive: return "Best Value"
}
}
}
enum AlertMode: String, Codable, CaseIterable, Identifiable {
case cheapest
case bestValue
var id: String { rawValue }
var displayName: String {
switch self {
case .cheapest: return "Cheapest station"
case .bestValue: return "Best Value"
}
}
}
/// RAG value rating for a station's price against the cheapest available.
/// Thumb rules: within 1.5p = green (great value), within 4p = amber (okay),
/// beyond that = red (pricey). Deliberately coarse so it reads at a glance.
enum RAGRating: Int, Codable {
case green = 0
case amber = 1
case red = 2
static func rating(price: Double, cheapest: Double) -> RAGRating {
let delta = price - cheapest
if delta <= 1.5 { return .green }
if delta <= 4.0 { return .amber }
return .red
}
}
enum FuelType: String, Codable, CaseIterable, Identifiable {
#if canImport(AppIntents)
typealias DisplayRepresentation = AppIntents.DisplayRepresentation
typealias TypeDisplayRepresentation = AppIntents.TypeDisplayRepresentation
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [
.e10: "Unleaded",
.e5: "Premium",
.diesel: "Diesel",
]
#endif
case e10 // Unleaded (E10)
case e5 // Premium (E5)
case diesel // B7 diesel
var id: String { rawValue }
var displayName: String {
switch self {
case .e10: return "Unleaded"
case .e5: return "Premium"
case .diesel: return "Diesel"
}
}
}
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Lives here in Shared
/// so the app, widget, and Live Activity all tint the pump/fuel glyphs from one
/// definition.
extension FuelType {
var tintColor: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
}
#if canImport(AppIntents)
extension FuelType: AppEnum {}
#endif
/// Display unit for all distances in the app + widget. Internally distances
/// are always stored/computed in km; conversion happens at the display and
/// filter boundary so nothing else needs to know the unit.
enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
case miles
case kilometers
var id: String { rawValue }
var displayName: String {
switch self {
case .miles: return "Miles"
case .kilometers: return "Kilometres"
}
}
/// Short suffix for values ("5 mi", "3.2 km").
var shortName: String {
switch self {
case .miles: return "mi"
case .kilometers: return "km"
}
}
/// Full word for narrative text ("within 5 miles", "within 8 km").
var label: String {
switch self {
case .miles: return "miles"
case .kilometers: return "km"
}
}
/// Full word for a specific value — pluralizes miles ("1 mile" vs
/// "5 miles"); metric is always "km".
func label(for value: Double) -> String {
switch self {
case .miles: return value == 1 ? "mile" : "miles"
case .kilometers: return "km"
}
}
/// A whole-mile distance (as stored/used by search + Live Activity)
/// shown in this unit, rounded to a whole number for picker labels:
/// 5 miles -> "5" (miles unit) or "8" (km unit).
func displayMiles(_ miles: Int) -> Int {
Int(fromKM(Double(miles) * FuelSavingsCalculator.kilometresPerMile).rounded())
}
/// Convert a value expressed in this unit to km.
func toKM(_ value: Double) -> Double {
switch self {
case .miles: return value * FuelSavingsCalculator.kilometresPerMile
case .kilometers: return value
}
}
/// Convert a km value to this unit.
func fromKM(_ km: Double) -> Double {
switch self {
case .miles: return km / FuelSavingsCalculator.kilometresPerMile
case .kilometers: return km
}
}
/// Format a km distance in this unit ("1.2 mi", "3.4 km").
func format(_ km: Double) -> String {
String(format: "%.1f %@", fromKM(km), shortName)
}
/// Spoken form of a KM distance ("6.4 miles", "10.3 kilometres"), for
/// VoiceOver. `format(_:)` returns the compact "6.4 mi"/"1.2 km" suffix that a
/// synthesizer may read as letters or skip, and "Approx." needs a word too, so
/// every spoken distance goes through here.
func spoken(_ km: Double) -> String {
switch self {
case .miles:
let miles = fromKM(km)
return String(format: "%.1f %@", miles, label(for: miles))
case .kilometers:
return String(format: "%.1f kilometres", km)
}
}
}
/// Display style for fuel prices. Internally prices are always stored and
/// computed in pence-per-litre (GOV.UK's unit, e.g. 129.9); the style only
/// affects RENDERING: the station-sign convention shows the bare pence
/// figure ("129.9", no £ — exactly what a UK forecourt sign shows), pounds &
/// pence shows the converted value in the forecourt's superscript style
/// ("£1.29⁹/L"). Calculations never see this.
enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
case stationSign // 129.9 — bare pence figure, like the roadside sign
case poundsPence // £1.29⁹/L — small superscript third digit, small /L
var id: String { rawValue }
var displayName: String {
switch self {
case .stationSign: return "Pence per litre"
case .poundsPence: return "Pounds & pence"
}
}
}
// MARK: - Station tap action
/// What happens when a station row is tapped in the Stations/Favourites tabs:
/// open Apple Maps directions immediately (historic behaviour, default), or
/// show a detail sheet (name, address, distance, Directions button).
enum StationTapAction: String, Codable, CaseIterable, Identifiable {
case openMap // tap → Apple Maps directions straight away
case showDetails // tap → detail sheet, Directions button inside
var id: String { rawValue }
var displayName: String {
switch self {
case .openMap: return "Directions"
case .showDetails: return "Station details"
}
}
}
// MARK: - Station model
struct FuelStation: Identifiable, Codable, Equatable {
let id: String
var name: String
let brand: String
let address: String
let postcode: String
let lat: Double
let lng: Double
/// Price per fuel type in pence per litre. Missing type = station doesn't sell it.
let prices: [FuelType: Double]
/// Station-level price timestamp (seconds since 1970): the relay's
/// `price_updated`, i.e. the newest price submission across the grades it
/// maps — never a per-grade time. nil when the source omits it (sample data).
let priceUpdated: TimeInterval?
init(id: String, name: String, brand: String, address: String, postcode: String,
lat: Double, lng: Double, prices: [FuelType: Double],
priceUpdated: TimeInterval?) {
self.id = id
self.name = name
self.brand = brand
self.address = address
self.postcode = postcode
self.lat = lat
self.lng = lng
self.prices = prices
self.priceUpdated = priceUpdated
}
private enum CodingKeys: String, CodingKey {
case id, name, brand, address, postcode, lat, lng, prices
case priceUpdated = "priceUpdated"
case priceUpdatedSnake = "price_updated"
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
name = try values.decode(String.self, forKey: .name)
brand = try values.decode(String.self, forKey: .brand)
address = try values.decode(String.self, forKey: .address)
postcode = try values.decode(String.self, forKey: .postcode)
lat = try values.decode(Double.self, forKey: .lat)
lng = try values.decode(Double.self, forKey: .lng)
prices = try values.decode([FuelType: Double].self, forKey: .prices)
priceUpdated = try values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdated)
?? values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdatedSnake)
}
func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(id, forKey: .id)
try values.encode(name, forKey: .name)
try values.encode(brand, forKey: .brand)
try values.encode(address, forKey: .address)
try values.encode(postcode, forKey: .postcode)
try values.encode(lat, forKey: .lat)
try values.encode(lng, forKey: .lng)
try values.encode(prices, forKey: .prices)
try values.encodeIfPresent(priceUpdated, forKey: .priceUpdated)
}
/// Cheap haversine distance to a location, in km.
func distanceKM(to lat2: Double, lng2: Double) -> Double {
let r = 6371.0
let dLat = (lat2 - lat) * .pi / 180
let dLng = (lng2 - lng) * .pi / 180
let a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat * .pi / 180) * cos(lat2 * .pi / 180) *
sin(dLng / 2) * sin(dLng / 2)
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
/// Apple Maps directions URL — used by the app's rows/notifications, which
/// open it directly via `UIApplication.shared.open`.
var mapsDirectionsURL: URL? {
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
}
/// Widget tap URL — the native Maps scheme (`maps://`). From the iOS Home
/// Screen the system may either (a) open Maps directly, or (b) deliver the
/// URL to the containing app, where FuelBoardApp.onOpenURL forwards it to
/// Maps. In CarPlay there is no containing app, but because `maps://`
/// targets Apple Maps — an app that IS in CarPlay — the system may route
/// the tap straight to the car display, bypassing the app entirely. This
/// is the only scheme with a chance of working in CarPlay; the old
/// `fuelboard://` relay is kept as a handled fallback in onOpenURL.
var widgetDirectionsURL: URL? {
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
}
/// In-app deep link to this station's More info sheet. Carries the
/// stationID plus coordinate/name fallback so the app can still show the
/// map sheet when the station has left the cache.
var detailDeepLinkURL: URL? {
var components = URLComponents()
components.scheme = "fuelboard"
components.host = "station"
components.queryItems = [
URLQueryItem(name: "stationID", value: id),
URLQueryItem(name: "lat", value: String(lat)),
URLQueryItem(name: "lng", value: String(lng)),
URLQueryItem(name: "name", value: name),
]
return components.url
}
/// Widget/Live Activity tap destination honouring Settings → Tap station:
/// Open map → Apple Maps directions; More info → in-app detail deep link.
var tapDestinationURL: URL? {
FuelStore.loadStationTapAction() == .showDetails
? detailDeepLinkURL
: widgetDirectionsURL
}
/// Name of the bundled brand logo asset, or nil if unknown.
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
var brandImageName: String? {
let raw = brand.uppercased()
let known: [(String, String)] = [
("CIRCLE K", "brand_circle_k"),
("MAXOL", "brand_maxol"),
("SPAR", "brand_spar"),
("SHELL", "brand_shell"),
("SAINSBURY", "brand_sainsburys"),
("MORRISONS", "brand_morrisons"),
("APPLEGREEN", "brand_applegreen"),
("TESCO", "brand_tesco"),
("TEXACO", "brand_texaco"),
("ESSAR", "brand_essar"),
("ESSO", "brand_esso"),
("ASDA", "brand_asda"),
("GULF", "brand_gulf"),
("VALERO", "brand_valero"),
("WELCOME BREAK", "brand_welcome_break"),
("THE CO-OPERATIVE", "brand_the_co_operative"),
("CO-OPERATIVE", "brand_the_co_operative"),
("CO OPERATIVE", "brand_the_co_operative"),
("CO-OP", "brand_the_co_operative"),
("CO OP", "brand_the_co_operative"),
("MURCO", "brand_murco"),
("GLEANER", "brand_gleaner"),
("HIGHLAND FUELS", "brand_highland_fuels"),
("BP", "brand_bp"),
("JET", "brand_jet"),
]
for (needle, asset) in known where raw.contains(needle) {
return asset
}
if raw.contains("EG ON THE MOVE") { return "brand_esso" }
if raw == "GO" || raw.contains("GO FORECOURT") || raw.contains("GO FUEL") { return "brand_gulf" }
return nil
}
}
extension String {
/// Word-capitalises station titles to title case:
/// "SHELL SALTERHEBBLE" → "Shell Salterhebble"
/// "SAINSBURYS HALIFAX" → "Sainsbury's Halifax"
/// "birmingham road service station" → "Birmingham Road Service Station"
/// Known acronyms (BP, MFG, ASDA, MOTO, SPAR, UK, NI…) stay uppercase; short
/// all-caps tokens in mixed names are treated as initials (TJ, WR, SJS…);
/// connectors (and/of/the/on/ta/t-a) stay lowercase; "LTD" → "Ltd";
/// apostrophes keep their chunk together; hyphens/parens/& reset a chunk.
var sanitizedStationTitle: String {
guard rangeOfCharacter(from: .letters) != nil else { return self }
let keepUppercase: Set<String> = ["BP", "MFG", "ASDA", "MOTO", "SPAR", "UK", "NI", "SS"]
let keepLowercase: Set<String> = ["of", "and", "the", "on", "ta", "t/a"]
let isAllCaps = self == self.uppercased()
return self.split(separator: " ").map { rawWord in
let w = String(rawWord)
if w.uppercased() == "SAINSBURYS" { return "Sainsbury's" }
if keepLowercase.contains(w.lowercased()) { return w.lowercased() }
let core = w.filter { $0.isLetter }
if keepUppercase.contains(core.uppercased()) { return w.uppercased() }
if core.uppercased() == "LTD" { return "Ltd" }
if !isAllCaps, w == w.uppercased(), (1...3).contains(core.count) { return w }
return w.capitalizedChunks
}.joined(separator: " ")
}
/// Capitalises the first letter of each alpha-chunk and lowercases the rest.
/// Apostrophes do NOT reset the chunk ("Sainsbury's", "Adam's");
/// hyphens, &, parens and dots do ("NEWCASTLE-UNDER-LYME" → "Newcastle-Under-Lyme",
/// "(MEADOWHALL" → "(Meadowhall").
private var capitalizedChunks: String {
var out = ""
var newChunk = true
for ch in self {
if ch.isLetter {
if newChunk {
out.append(ch.uppercased())
newChunk = false
} else {
out.append(ch.lowercased())
}
} else if ch == "'" {
out.append(ch)
} else {
out.append(ch)
newChunk = true
}
}
return out
}
}
// MARK: - Shared store
struct FuelStore {
static let appGroupSuite = "group.com.apt.fuelboard"
static let stationsKey = "fuelboard.stations" // [FuelStation] JSON
static let locationKey = "fuelboard.lastLocation" // "lat,lng,timestamp"
static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value
static let sortModeKey = "fuelboard.sortMode" // SortMode raw value
static let stationLimitKey = "fuelboard.stationLimitMiles" // Int miles (5/10/15)
static let distanceUnitKey = "fuelboard.distanceUnit" // DistanceUnit raw value
static let favouritesKey = "fuelboard.favourites" // [FavouriteEntry] JSON
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
static let alertsModeKey = "fuelboard.alertsMode" // AlertMode raw value
static let alertsBestValueThresholdKey = "fuelboard.alertsBestValueThresholdGBP" // Double £
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance
static let favouriteDropAlertsEnabledKey = "fuelboard.favouriteDropAlertsEnabled" // Bool
static let favouriteDropAlertsFuelKey = "fuelboard.favouriteDropAlertsFuel" // FuelType raw value
static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON
static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
static let liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
static let dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value
static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // TimeInterval (seconds since 1970)
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" // TimeInterval (seconds since 1970)
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
static let stationCountKey = "fuelboard.stationCount" // String — station count
static let dataUpdatedKey = "fuelboard.dataUpdated" // govUK dataset update time
static let fuelSavingsEnabledKey = "fuelboard.fuelSavings.enabled"
static let fuelSavingsMPGKey = "fuelboard.fuelSavings.mpg"
static let fuelSavingsAmountKey = "fuelboard.fuelSavings.amount"
static let fuelSavingsUnitKey = "fuelboard.fuelSavings.unit"
// Large shared payloads belong in the App Group container, not
// UserDefaults/CFPreferences (which has a roughly 4 MB limit).
static let stationsFileName = "fuelboard.stations.json"
/// Where a shared payload is written: the App Group container when the
/// provisioning profile grants it, otherwise the app's OWN container.
///
/// This fallback is essential in the field. Free/personal-team and SideStore
/// profiles do NOT include the `com.apple.security.application-groups`
/// entitlement, so `containerURL(forSecurityApplicationGroupIdentifier:)`
/// returns nil at runtime. Without this fallback NOTHING persisted on those
/// installs: `loadStations()` was permanently empty (so the freshness gate
/// could skip the fetch for ever and leave a blank list) and the ~2.8 MB
/// dump was re-downloaded on every refresh. The app's own container is not
/// shared with the widget/watch extension, but the app itself must keep a
/// working cache.
static func appGroupFileURL(_ name: String) -> URL? {
let fm = FileManager.default
if let group = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite),
fm.fileExists(atPath: group.path) {
// Only use the App Group container when the system actually
// provisioned it at install time (i.e. the profile carries the
// `com.apple.security.application-groups` entitlement). Never try to
// create it: when the entitlement is missing the path either does
// not exist or is not writable, and probing it that way failed every
// write instead of falling back.
return group.appendingPathComponent(name, isDirectory: false)
}
guard let dir = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
if !fm.fileExists(atPath: dir.path),
(try? fm.createDirectory(at: dir, withIntermediateDirectories: true)) == nil {
return nil
}
return dir.appendingPathComponent(name, isDirectory: false)
}
static func loadAppGroupFile(_ name: String) -> Data? {
guard let url = appGroupFileURL(name) else { return nil }
return try? Data(contentsOf: url)
}
@discardableResult
static func saveAppGroupFile(_ data: Data, name: String) -> Bool {
guard let url = appGroupFileURL(name) else { return false }
do {
try data.write(to: url, options: .atomic)
return true
} catch {
return false
}
}
// MARK: Stations
// The full-UK dataset is stored as an App Group file. UserDefaults is
// reserved for small preferences and has a roughly 4 MB total limit.
/// Stations from `stations` tied with `price` for `fuel` (within 0.01p),
/// sorted nearest-first from `lat`/`lng`. Used to offer multiple
/// cheapest-station choices in an alert notification.
static func tiedStations(
in stations: [FuelStation],
fuel: FuelType,
price: Double,
fromLat lat: Double, lng: Double
) -> [FuelStation] {
stations
.filter { abs(($0.prices[fuel] ?? .infinity) - price) < 0.01 }
.sorted { lhs, rhs in
lhs.distanceKM(to: lat, lng2: lng) < rhs.distanceKM(to: lat, lng2: lng)
}
}
static func loadStations() -> [FuelStation] {
#if DEBUG
let probe = appGroupFileURL(stationsFileName)
print("FB-STORAGE load \(stationsFileName) url=\(probe?.path ?? "nil") present=\(probe.map { FileManager.default.fileExists(atPath: $0.path) } ?? false)")
#endif
if let data = loadAppGroupFile(stationsFileName),
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
!stations.isEmpty {
return stations.map(sanitized)
}
// Migrate the legacy App Group UserDefaults value once.
if let defaults = UserDefaults(suiteName: appGroupSuite),
let data = defaults.data(forKey: stationsKey),
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
!stations.isEmpty {
if saveAppGroupFile(data, name: stationsFileName) {
defaults.removeObject(forKey: stationsKey)
}
return stations.map(sanitized)
}
if let data = keychainData(service: stationsKey),
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
!stations.isEmpty {
return stations.map(sanitized)
}
return []
}
/// Re-runs the title-case sanitizer on cached stations so names fixed by
/// newer sanitizer logic appear without waiting for the next fetch.
private static func sanitized(_ station: FuelStation) -> FuelStation {
var s = station
s.name = station.name.sanitizedStationTitle
return s
}
static func saveStations(_ stations: [FuelStation]) {
if let data = try? JSONEncoder().encode(stations) {
let ok = saveAppGroupFile(data, name: stationsFileName)
#if DEBUG
print("FB-STORAGE save \(stationsFileName) bytes=\(data.count) ok=\(ok) url=\(appGroupFileURL(stationsFileName)?.path ?? "nil")")
#endif
}
}
// MARK: Last known location ("lat,lng,unixTime")
static func loadLocation() -> Coordinate? {
let raw = loadString(service: locationKey)
let parts = raw?.split(separator: ",").compactMap { Double($0) }
guard let parts, parts.count == 3 else { return nil }
return Coordinate(lat: parts[0], lng: parts[1])
}
/// Location + the timestamp it was saved, for debug display (fix age).
static func loadLocationWithDate() -> (coordinate: Coordinate, date: Date)? {
let raw = loadString(service: locationKey)
let parts = raw?.split(separator: ",").compactMap { Double($0) }
guard let parts, parts.count == 3 else { return nil }
return (Coordinate(lat: parts[0], lng: parts[1]),
Date(timeIntervalSince1970: parts[2]))
}
static func saveLocation(lat: Double, lng: Double, date: Date = Date()) {
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: locationKey)
}
/// A fix the WIDGET EXTENSION obtained on its own, kept apart from
/// `locationKey`.
///
/// The extension's fix comes from its own `requestLocation()` and is often
/// coarse; writing it over `locationKey` replaced the app's GPS fix with a
/// Wi-Fi-grade one that could sit hundreds of metres away. That stored fix is
/// what the app seeds itself with on launch and what
/// `distanceDisplayOrigin` trusts when a coarse fix disagrees with the route
/// cache — so a widget fix must never be mistaken for the app's position.
static let widgetLocationKey = "fuelboard.widgetLocation"
static func saveWidgetLocation(lat: Double, lng: Double, date: Date = Date()) {
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: widgetLocationKey)
}
static func loadWidgetLocation() -> (coordinate: Coordinate, date: Date)? {
let raw = loadString(service: widgetLocationKey)
guard let parts = raw?.split(separator: ",").compactMap({ Double($0) }),
parts.count == 3 else { return nil }
return (Coordinate(lat: parts[0], lng: parts[1]),
Date(timeIntervalSince1970: parts[2]))
}
// MARK: Selected fuel
static func loadSelectedFuel() -> FuelType {
if let raw = loadString(service: fuelKey), let fuel = FuelType(rawValue: raw) {
return fuel
}
return .e10
}
static func saveSelectedFuel(_ fuel: FuelType) {
saveString(fuel.rawValue, service: fuelKey)
}
// MARK: Alerts fuel
/// The fuel proximity alerts monitor — independent of the Stations-tab
/// selection so users can browse any fuel without re-targeting alerts.
static func loadAlertsFuel() -> FuelType {
if let raw = loadString(service: alertsFuelKey), let fuel = FuelType(rawValue: raw) {
return fuel
}
return .e10
}
static func saveAlertsFuel(_ fuel: FuelType) {
saveString(fuel.rawValue, service: alertsFuelKey)
}
static func loadAlertsMode() -> AlertMode {
if let raw = loadString(service: alertsModeKey), let mode = AlertMode(rawValue: raw) {
return mode
}
return .cheapest
}
static func saveAlertsMode(_ mode: AlertMode) {
saveString(mode.rawValue, service: alertsModeKey)
}
/// The Best Value gate's options, in 50p steps from 50p to £5. The gate is
/// the same figure for the listing's BEST VALUE badges and for the alert, so
/// its granularity decides how finely the two can be tuned: the old four
/// values (50p, £1, £2, £5) made £2.50 and £3 unreachable, and a whole extra
/// detour's worth of saving sat between £1 and £2. Multiples of 0.5 are
/// exactly representable, so the stored value and the picker's tags cannot
/// drift apart.
static let bestValueAlertThresholdOptionsGBP: [Double] = [
0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0
]
static func loadAlertsBestValueThresholdGBP() -> Double {
if let raw = loadString(service: alertsBestValueThresholdKey),
let value = Double(raw),
bestValueAlertThresholdOptionsGBP.contains(value) {
return value
}
return 1.0
}
static func saveAlertsBestValueThresholdGBP(_ threshold: Double) {
let snapped = bestValueAlertThresholdOptionsGBP
.min(by: { abs($0 - threshold) < abs($1 - threshold) }) ?? 1.0
saveString(String(snapped), service: alertsBestValueThresholdKey)
}
// MARK: Sort mode
static func loadSortMode() -> SortMode {
if let raw = loadString(service: sortModeKey), let mode = SortMode(rawValue: raw) {
return mode
}
return .cheapest
}
static func saveSortMode(_ mode: SortMode) {
saveString(mode.rawValue, service: sortModeKey)
}
// MARK: Station search radius (miles)
/// Search radius options — the results filter shows stations within this
/// many miles of the current location.
static let stationRadiusOptions = [5, 10, 15]
static func loadStationLimit() -> Int {
if let raw = loadString(service: stationLimitKey), let value = Int(raw), stationRadiusOptions.contains(value) {
return value
}
return 5
}
static func saveStationLimit(_ miles: Int) {
saveString(String(miles), service: stationLimitKey)
}
// MARK: Distance unit — miles or kilometres. Stored raw value; default
// miles for backward compatibility with pre-toggle installs.
static func loadDistanceUnit() -> DistanceUnit {
if let raw = loadString(service: distanceUnitKey), let unit = DistanceUnit(rawValue: raw) {
return unit
}
return .miles
}
static func saveDistanceUnit(_ unit: DistanceUnit) {
saveString(unit.rawValue, service: distanceUnitKey)
}
// MARK: Price display — station sign (129.9) vs pounds & pence (£1.29⁹/L).
// Prices are always stored/computed in pence-per-litre; this style only
// changes how they are RENDERED, so it can never affect calculations.
// Default station sign = the forecourt convention.
static let priceDisplayStyleKey = "fuelboard.priceDisplayStyle"
static func loadPriceDisplayStyle() -> PriceDisplayStyle {
if let raw = loadString(service: priceDisplayStyleKey), let style = PriceDisplayStyle(rawValue: raw) {
return style
}
return .stationSign
}
static func savePriceDisplayStyle(_ style: PriceDisplayStyle) {
saveString(style.rawValue, service: priceDisplayStyleKey)
}
// MARK: Station tap action — open map vs detail sheet. Stored raw value;
// default open map preserves the historic tap behaviour for existing installs.
static let stationTapActionKey = "fuelboard.stationTapAction"
static func loadStationTapAction() -> StationTapAction {
if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) {
return action
}
return .openMap
}
static func saveStationTapAction(_ action: StationTapAction) {
saveString(action.rawValue, service: stationTapActionKey)
}
// MARK: Fuel savings — Worth the Drive?
static func loadFuelSavingsSettings() -> FuelSavingsSettings {
let enabled = loadString(service: fuelSavingsEnabledKey) == "1"
let mpg = loadString(service: fuelSavingsMPGKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultMPG
let amount = loadString(service: fuelSavingsAmountKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultPreferredAmount.value
let unit = loadString(service: fuelSavingsUnitKey).flatMap(FuelSavingsFillUnit.init(rawValue:)) ?? FuelSavingsSettings.defaultPreferredAmount.unit
#if DEBUG
// The two stores are mirrors and can DISAGREE (the keychain survives
// app deletion, the app-group defaults do not). Print both raw copies
// plus which one won, so a setting that flips between launches is
// diagnosable instead of guesswork.
func raw(_ service: String) -> (keychain: String, appGroup: String) {
let keychain = keychainData(service: service).flatMap { String(data: $0, encoding: .utf8) } ?? "-"
let appGroup = UserDefaults(suiteName: appGroupSuite)?.string(forKey: service) ?? "-"
return (keychain, appGroup)
}
let enabledRaw = raw(fuelSavingsEnabledKey)
let mpgRaw = raw(fuelSavingsMPGKey)
print("FB-SAVINGS read enabled=\(enabled ? 1 : 0) [kc=\(enabledRaw.keychain) ag=\(enabledRaw.appGroup)] mpg=\(mpg) [kc=\(mpgRaw.keychain) ag=\(mpgRaw.appGroup)]")
#endif
return FuelSavingsSettings(
enabled: enabled,
mpg: mpg,
preferredAmount: FuelSavingsFillAmount(value: amount, unit: unit)
)
}
static func saveFuelSavingsSettings(_ settings: FuelSavingsSettings) {
saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey)
saveString(String(settings.mpg), service: fuelSavingsMPGKey)
saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey)
saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
}
/// One field of the Fuel Savings settings. A control that edits a single
/// value saves just that field, so moving the MPG slider rewrites the MPG key
/// instead of all four — four UserDefaults writes and four keychain round
/// trips per gesture is what made those sliders stutter.
enum FuelSavingsField {
case enabled
case mpg
case amount
case unit
}
static func saveFuelSavingsField(_ field: FuelSavingsField, from settings: FuelSavingsSettings) {
switch field {
case .enabled: saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey)
case .mpg: saveString(String(settings.mpg), service: fuelSavingsMPGKey)
case .amount: saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey)
case .unit: saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
}
}
/// Superscript digit glyphs for the pounds & pence format's small raised
/// third digit (the forecourt style: "£1.29⁹").
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
/// Render a pence-per-litre price per the saved style:
/// stationSign -> "129.9" (bare pence figure, no £ — the roadside sign),
/// poundsPence -> "£1.29⁹/L" (pounds with a superscript third digit and a
/// small /L unit, matching UK garage displays).
static func priceText(_ pence: Double, style: PriceDisplayStyle? = nil) -> String {
switch style ?? loadPriceDisplayStyle() {
case .stationSign:
return String(format: "%.1f", pence)
case .poundsPence:
let tenths = Int((pence * 10).rounded())
let whole = tenths / 1000
let major = (tenths % 1000) / 10
let minor = tenths % 10
return String(format: "£%d.%02d", whole, major)
+ String(superscriptDigits[minor])
+ "/L"
}
}
/// Rich, forecourt-styled price text for SwiftUI surfaces. Same formats as
/// `priceText`, but the pounds & pence mode renders the third digit small
/// and superscripted and /L small and muted — proper text styling instead
/// of Unicode glyphs. Pass the surface's size/weight/color; the returned
/// Text carries its own fonts, so do NOT apply `.font(...)` on top.
static func priceTextAttributed(_ pence: Double, style: PriceDisplayStyle? = nil,
size: CGFloat = 17, weight: Font.Weight = .semibold,
color: Color = .primary) -> Text {
let s = style ?? loadPriceDisplayStyle()
let base = Font.system(size: size, weight: weight).monospaced()
switch s {
case .stationSign:
return Text(priceText(pence, style: s)).font(base).foregroundColor(color)
case .poundsPence:
let tenths = Int((pence * 10).rounded())
let whole = tenths / 1000
let major = (tenths % 1000) / 10
let minor = tenths % 10
let amount = Text(String(format: "£%d.%02d", whole, major))
.font(base)
.foregroundColor(color)
let sup = Text(String(superscriptDigits[minor]))
.font(.system(size: size * 0.6, weight: weight).monospaced())
.baselineOffset(size * 0.35)
.foregroundColor(color)
let perL = Text("/L")
.font(.system(size: size * 0.5, weight: .regular).monospaced())
.foregroundColor(color.opacity(0.55))
return Text("\(amount)\(sup)\(perL)")
}
}
/// Speech-safe pounds form for Siri dialogs — Siri would read "£129.9"
/// aloud as "one hundred and twenty-nine pounds", so the SPOKEN answer
/// always uses pounds regardless of the display style.
static func priceTextSpoken(_ pence: Double) -> String {
String(format: "£%.3f", pence / 100)
}
// MARK: Debug mode
/// Hidden developer flag. NOT exposed in the UI: toggled by tapping the
/// Settings → About → Version row five times. When on, the Settings tab
/// shows the Debug section (test notification buttons); when off, that
/// section is completely hidden from the UI. Stored keychain-first like
/// every other small setting so it survives app deletion during the
/// delete → reinstall test loop.
static func loadDebugMode() -> Bool {
loadString(service: debugModeKey) == "1"
}
static func saveDebugMode(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: debugModeKey)
}
// MARK: Favourites
/// A favourite pins a station FOR ONE fuel type. Starring a row while
/// viewing Unleaded only creates an Unleaded favourite — the station is
/// not automatically favourited for Premium or Diesel.
static func loadFavourites() -> [FavouriteEntry] {
if let data = keychainData(service: favouritesKey),
let entries = try? JSONDecoder().decode([FavouriteEntry].self, from: data) {
return entries.map { entry in
var e = entry
e.station.name = entry.station.name.sanitizedStationTitle
return e
}
}
if let defaults = UserDefaults(suiteName: appGroupSuite),
let data = defaults.data(forKey: favouritesKey),
let entries = try? JSONDecoder().decode([FavouriteEntry].self, from: data) {
return entries
}
return []
}
static func saveFavourites(_ favourites: [FavouriteEntry]) {
if let data = try? JSONEncoder().encode(favourites) {
UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: favouritesKey)
writeKeychain(data: data, service: favouritesKey)
}
}
/// Returns favourites with fresh prices applied from the given station list
/// (favourites keep their cached snapshot when not in the current results).
/// The fuel scoping is preserved per entry.
static func refreshedFavourites(_ favourites: [FavouriteEntry], from stations: [FuelStation]) -> [FavouriteEntry] {
favourites.map { entry in
guard let fresh = stations.first(where: { $0.id == entry.station.id }) else { return entry }
return FavouriteEntry(station: fresh, fuel: entry.fuel)
}
}
/// The cheapest favourite for one fuel, preserving the user's stored order
/// on price ties. Used by the favourite price-drop alert so a tie does not
/// flap between equally-priced favourites.
static func cheapestFavourite(in favourites: [FavouriteEntry], fuel: FuelType) -> FavouriteEntry? {
var best: FavouriteEntry?
for entry in favourites where entry.fuel == fuel {
guard let price = entry.station.prices[fuel] else { continue }
guard let current = best, let currentPrice = current.station.prices[fuel] else {
best = entry
continue
}
if price < currentPrice {
best = entry
}
}
return best
}
/// Reorders ONE fuel's favourites within the global array (drag-and-drop in
/// the Favourites tab). The moved fuel's block stays at its original
/// position in the array; other fuels keep their relative order. The array
/// order IS the widget order — the first favourite of a fuel is the
/// "single widget" favourite.
static func reorderedFavourites(_ favourites: [FavouriteEntry],
fuel: FuelType,
fromOffsets source: IndexSet,
toOffset destination: Int) -> [FavouriteEntry] {
var fuelEntries = favourites.filter { $0.fuel == fuel }
// Manual reorder (Foundation-only file — Array.move(fromOffsets:) is
// a SwiftUI helper). Reproduces the standard drag semantics: remove
// the source items, then insert at the destination, shifted by the
// number of removed items that were before it.
let moving = source.sorted()
let removed = moving.map { fuelEntries[$0] }
for index in moving.reversed() {
fuelEntries.remove(at: index)
}
var insertion = destination
for index in moving where index < destination {
insertion -= 1
}
fuelEntries.insert(contentsOf: removed, at: min(max(insertion, 0), fuelEntries.count))
let movedIDs = Set(fuelEntries.map(\.id))
var others = favourites.filter { !movedIDs.contains($0.id) }
let blockIndex = favourites.firstIndex { $0.fuel == fuel } ?? others.count
others.insert(contentsOf: fuelEntries, at: min(blockIndex, others.count))
return others
}
// MARK: Alerts
static func loadAlertsEnabled() -> Bool {
loadString(service: alertsEnabledKey) == "1"
}
static func saveAlertsEnabled(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: alertsEnabledKey)
}
/// Alert trigger-radius options (in the user's display unit, mapped to km
/// on selection). Mile-friendly approach distances: 1–2 city, 3 town,
/// 5 default (matches the Stations-tab default), 8 motorway/long approach.
/// The geofence caps here — wider circles register poorly on iOS
/// (kCLErrorRegionMonitoringFailure, entry latency, battery) and the
/// "approach" signal dissolves beyond ~8 miles.
static let alertRadiusOptions = [1, 2, 3, 5, 8]
/// "Follow search" caps the geofence at 8 miles so a 10/15-mile Stations
/// search never creates huge region-monitoring circles.
static let alertFollowCapKM: Double = 8 * FuelSavingsCalculator.kilometresPerMile
/// The effective alert radius: the manual radius, or — when alerts follow
/// the Stations-tab search — that distance (miles, converted via the
/// user's unit) capped at 8 miles.
static func effectiveAlertsRadiusKM(followsSearch: Bool, manualKM: Double) -> Double {
guard followsSearch else { return manualKM }
return min(loadDistanceUnit().toKM(Double(loadStationLimit())), alertFollowCapKM)
}
static func loadAlertsRadius() -> Double {
if let raw = loadString(service: alertsRadiusKey), let value = Double(raw), value >= 1 {
// Clamp legacy values (old options went to 20 km) to the new cap.
return min(value, alertFollowCapKM)
}
return 3.0
}
static func saveAlertsRadius(_ radius: Double) {
saveString(String(radius), service: alertsRadiusKey)
}
/// Whether alerts mirror the Stations-tab search distance instead of the
/// manual radius. Defaults to OFF so existing behaviour is unchanged.
static func loadAlertsFollowsSearch() -> Bool {
loadString(service: alertsFollowSearchKey) == "1"
}
static func saveAlertsFollowsSearch(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: alertsFollowSearchKey)
}
// MARK: Favourite price-drop alerts
static func loadFavouriteDropAlertsEnabled() -> Bool {
loadString(service: favouriteDropAlertsEnabledKey) == "1"
}
static func saveFavouriteDropAlertsEnabled(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: favouriteDropAlertsEnabledKey)
}
static func loadFavouriteDropAlertsFuel() -> FuelType {
if let raw = loadString(service: favouriteDropAlertsFuelKey), let fuel = FuelType(rawValue: raw) {
return fuel
}
return .e10
}
static func saveFavouriteDropAlertsFuel(_ fuel: FuelType) {
saveString(fuel.rawValue, service: favouriteDropAlertsFuelKey)
}
static func loadFavouriteAlertSnapshots() -> [FuelType: FavouriteAlertSnapshot] {
if let data = keychainData(service: favouriteAlertSnapshotsKey),
let stored = try? JSONDecoder().decode([String: FavouriteAlertSnapshot].self, from: data) {
var result: [FuelType: FavouriteAlertSnapshot] = [:]
for (key, value) in stored {
if let fuel = FuelType(rawValue: key) {
result[fuel] = value
}
}
return result
}
return [:]
}
static func saveFavouriteAlertSnapshots(_ snapshots: [FuelType: FavouriteAlertSnapshot]) {
let keyed = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.key.rawValue, $0.value) })
if let data = try? JSONEncoder().encode(keyed) {
writeKeychain(data: data, service: favouriteAlertSnapshotsKey)
}
}
// MARK: Live Activity
static func loadLiveActivityEnabled() -> Bool {
loadString(service: liveActivityKey) == "1"
}
static func saveLiveActivityEnabled(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: liveActivityKey)
}
/// The fuel the Live Activity tracks — independent of the Stations-tab
/// selection so the Lock Screen pill keeps its own target.
static func loadLiveActivityFuel() -> FuelType {
if let raw = loadString(service: liveActivityFuelKey), let fuel = FuelType(rawValue: raw) {
return fuel
}
return .e10
}
static func saveLiveActivityFuel(_ fuel: FuelType) {
saveString(fuel.rawValue, service: liveActivityFuelKey)
}
/// The Live Activity's search radius in miles (5/10/15), converted to the
/// chosen unit at use — same options as the Stations list.
static func loadLiveActivityRadiusMiles() -> Int {
if let raw = loadString(service: liveActivityRadiusKey), let value = Int(raw), stationRadiusOptions.contains(value) {
return value
}
return 5
}
static func saveLiveActivityRadiusMiles(_ miles: Int) {
saveString(String(miles), service: liveActivityRadiusKey)
}
/// Whether the Live Activity mirrors the Stations-tab search distance
/// instead of its own saved radius. Defaults to OFF so existing
/// behaviour is unchanged.
static func loadLiveActivityFollowsSearch() -> Bool {
loadString(service: liveActivityFollowSearchKey) == "1"
}
static func saveLiveActivityFollowsSearch(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
}
// MARK: Refresh policy — Standard is cache-first/twice-daily; Smart keeps
// the same full-dump freshness cap but may probe the tiny mirror pointer
// more often in the background and only refresh the full dump if it changed.
static let refreshInterval: TimeInterval = 12 * 60 * 60
static let smartProbeInterval: TimeInterval = 4 * 60 * 60
static func loadDataRefreshMode() -> DataRefreshMode {
if let raw = loadString(service: dataRefreshModeKey),
let mode = DataRefreshMode(rawValue: raw) {
return mode
}
return .standard
}
static func saveDataRefreshMode(_ mode: DataRefreshMode) {
saveString(mode.rawValue, service: dataRefreshModeKey)
}
static func loadLastRefresh() -> Date? {
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static func saveLastRefresh(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
}
static func loadLastSmartProbe() -> Date? {
if let raw = loadString(service: lastSmartProbeKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static func saveLastSmartProbe(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey)
}
static func requestWatchRefresh(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: watchRefreshRequestKey)
}
static func loadWatchRefreshRequest() -> Date? {
if let raw = loadString(service: watchRefreshRequestKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static func markWatchRefreshHandled(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: watchRefreshHandledKey)
}
static func loadWatchRefreshHandled() -> Date? {
if let raw = loadString(service: watchRefreshHandledKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static var hasPendingWatchRefreshRequest: Bool {
guard let requested = loadWatchRefreshRequest() else { return false }
guard let handled = loadWatchRefreshHandled() else { return true }
return requested > handled
}
// MARK: Relay metadata — shown in Settings → About. Written after each
// successful full fetch so the About section reflects the live source.
static func saveRelayMeta(_ meta: RelayMeta) {
if let source = meta.source {
saveString(source, service: relaySourceKey)
}
if let count = meta.stationCount {
saveString(String(count), service: stationCountKey)
}
if let updated = meta.dataUpdated {
saveString(updated, service: dataUpdatedKey)
}
}
static func loadRelaySource() -> String? {
loadString(service: relaySourceKey)
}
static func loadStationCount() -> Int? {
guard let raw = loadString(service: stationCountKey) else { return nil }
return Int(raw)
}
static func loadDataUpdated() -> String? {
loadString(service: dataUpdatedKey)
}
/// A short label for the offline-data banner: "15 Aug" from a GOV.UK
/// `data_updated` ISO 8601 stamp (with or without fractional seconds).
/// Nil when the stamp is missing or unparseable — callers then hide the
/// banner rather than label data with a wrong date.
static func offlineDataLabel(from stamp: String?) -> String? {
guard let stamp, !stamp.isEmpty else { return nil }
let withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = withFraction.date(from: stamp)
if date == nil {
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
date = plain.date(from: stamp)
}
guard let date else { return nil }
let formatter = DateFormatter()
formatter.dateFormat = "d MMM"
formatter.locale = Locale(identifier: "en_GB")
return formatter.string(from: date)
}
/// True when the cached data is fresh enough that a scheduled auto-refresh
/// should be skipped (twice-a-day policy).
static var isCacheFresh: Bool {
isCacheFresh(now: Date())
}
static func isCacheFresh(now: Date) -> Bool {
guard let last = loadLastRefresh() else { return false }
return now.timeIntervalSince(last) < refreshInterval
}
static func isSmartProbeDue(now: Date = Date()) -> Bool {
guard let last = loadLastSmartProbe() else { return true }
return now.timeIntervalSince(last) >= smartProbeInterval
}
// MARK: Onboarding — the app shows the intro screen on first launch only
// (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST
// (with an app-group mirror) for the same reason as favourites/distance
// unit: free SideStore accounts don't provision the app-group container,
// so an app-group-only flag silently fails to save AND reloads as false,
// making onboarding re-appear on every launch. Keychain survives reinstall
// and is shared with the extension.
static func loadHasCompletedOnboarding() -> Bool {
(loadString(service: onboardingCompletedKey) ?? "0") == "1"
}
static func saveHasCompletedOnboarding(_ completed: Bool) {
saveString(completed ? "1" : "0", service: onboardingCompletedKey)
}
// MARK: Install identity — distinguish a genuinely fresh install (where
// onboarding should replay) from later launches of the same install.
/// Keychain copy of the install id (survives reinstall).
private static let installIDKey = "fuelboard.installID"
/// Local (app-own container) copy — wiped on reinstall, persists across
/// normal launches. Free SideStore accounts have no app-group container,
/// so this app-own defaults domain is the reliable "same install" signal.
private static let localInstallIDKey = "fuelboard.installID.local"
/// True when this is a first-ever install OR the app was just reinstalled
/// (local install id missing/different from the keychain id). Seeds a fresh
/// id into both stores so the next launch within the same install is not a
/// "fresh install" any more.
static func isFreshInstall() -> Bool {
let local = UserDefaults.standard.string(forKey: localInstallIDKey)
let remote = loadString(service: installIDKey)
if let local, let remote, local == remote { return false }
// Fresh / mismatched install (or first launch). Pattern a common id so
// subsequent launches of this install are recognised as the same one.
let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: localInstallIDKey)
saveString(id, service: installIDKey)
return true
}
/// Onboarding should present when it hasn't been completed in this install
/// OR this is a freshly-installed app (so the walkthrough replays on a new
/// install, e.g. after a SideStore reinstall, without re-showing on every
/// ordinary launch). The install check always runs (it seeds the id) rather
/// than short-circuiting, so a brand-new install is recorded before the
/// user ever fills in onboarding.
static func shouldShowOnboarding() -> Bool {
let fresh = isFreshInstall()
return !loadHasCompletedOnboarding() || fresh
}
// MARK: Best Value announcement (existing users who updated into it)
/// Users who finished onboarding before Best Value existed are
/// `completed == true, fresh == false` for good, so the first-run gate above
/// never fires again for them and the feature would stay invisible unless
/// they went looking in Settings. This flag drives a single-page
/// announcement instead of replaying the whole flow (which would re-run
/// permission pages they have already answered).
///
/// Stores the announcement version SEEN, compared against
/// `currentBestValueIntroVersion` below — deliberately not the app version,
/// so a bugfix release never re-announces anything.
static let bestValueIntroVersionKey = "fuelboard.bestValueIntroVersion"
/// Bump this when the Best Value announcement should be shown again to
/// everyone — e.g. the feature changes in a way worth re-explaining. Anyone
/// whose stored value differs (including someone who dismissed an earlier
/// announcement) sees it once more on the next launch.
static let currentBestValueIntroVersion = 1
static func hasSeenBestValueIntro() -> Bool {
(loadString(service: bestValueIntroVersionKey) ?? "")
== String(currentBestValueIntroVersion)
}
static func markBestValueIntroSeen() {
saveString(String(currentBestValueIntroVersion), service: bestValueIntroVersionKey)
}
/// Whether the standalone Best Value announcement should be offered.
///
/// The rule lives here rather than inside the view so it can be unit-tested:
/// a view-embedded gate can only be checked by driving a simulator through
/// every combination, which is exactly how a rule like this rots. The facts
/// it depends on are injectable for the same reason.
///
/// It declines in four cases, each for a different reason:
/// - `isSuppressed` — the first-run onboarding cover is up and must not be
/// fought for the screen;
/// - `onboardingDue` — a new install is about to run the full flow, which
/// already contains this page (`OnboardingView.finish` also marks it seen);
/// - `hasSeen` — this announcement version has already been shown;
/// - the user has Best Value configured — never announce to someone who set
/// it up themselves.
static func shouldAnnounceBestValue(settings: FuelSavingsSettings,
hasSeen: Bool = hasSeenBestValueIntro(),
onboardingDue: Bool = shouldShowOnboarding(),
isSuppressed: Bool = false) -> Bool {
guard !isSuppressed else { return false }
guard !onboardingDue else { return false }
guard !hasSeen else { return false }
return !settings.isConfigured
}
// MARK: Road distances (Apple-Maps-matched, computed by the app)
/// Cached road/routed distances (metres) keyed by station ID, computed by
/// the app via MapKit `MKDirections`. Stored in KEYCHAIN (survives on free
/// SideStore accounts where the app-group container isn't provisioned) so
/// the widget extension can read it too. Widget + Live Activity prefer
/// these over straight-line haversine for the displayed distance.
static let roadDistancesKey = "fuelboard.roadDistances"
/// How far (metres) the cache's source location may be from the current
/// user position before a cached road distance is treated as stale.
static let roadDistanceOriginToleranceMeters: Double = 600
/// How far (degrees) a station's stored coordinate may drift from the pin
/// a road distance was actually routed to before that cached value is
/// treated as belonging to a different station. Guards against one data
/// source (live fetch, bundled offline dump, or a corrected pin) serving a
/// road distance that was computed for a different coordinate under the
/// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but
/// catches any real pin change.
static let roadDistancePinToleranceDegrees: Double = 1e-4
// MARK: Accurate distances (browse-layer toggle)
/// "Accurate distances". OFF (default) = the browse layer never uses a
/// routed road distance: every surface shows the straight-line value marked
/// "Approx.", so two devices agree exactly and the app makes no route
/// requests at all. ON = routed distances are shown, but only once the whole
/// search area has been routed from an anchor we are still standing near, so
/// a list is never a mix of road and straight-line values.
static let accurateDistancesKey = "fuelboard.accurateDistances"
/// Memoised, because this is read from the DISPLAY hot path.
///
/// `UserDefaults(suiteName:)` builds a fresh suite object and forces a
/// synchronous cfprefsd read, and `stationDistanceDisplay` consults this
/// property on every call — which the Closest sort used to make twice per
/// comparison (~200k reads for the whole-country pool), so switching to
/// Closest or launching with it selected stalled the main thread for seconds
/// on XPC alone. Same failure class as the keychain read that once tripped
/// the launch watchdog. Only this app writes the key, and it writes it
/// through `setAccurateDistancesEnabled`, which keeps the memo in step;
/// each PROCESS reads the stored value once (a widget extension has its own).
private static var accurateDistancesMemo: Bool?
static var accurateDistancesEnabled: Bool {
if let memo = accurateDistancesMemo { return memo }
let stored = UserDefaults(suiteName: appGroupSuite)?.bool(forKey: accurateDistancesKey) ?? false
accurateDistancesMemo = stored
return stored
}
static func setAccurateDistancesEnabled(_ enabled: Bool) {
accurateDistancesMemo = enabled
UserDefaults(suiteName: appGroupSuite)?.set(enabled, forKey: accurateDistancesKey)
}
/// Whether routed distances may be shown right now: the toggle is on AND the
/// cached routes cover the whole search area from a location we are still
/// near. Anything else serves straight-line + "Approx." for EVERY row, which
/// is what stops a list from mixing bases (a row reading "14.6 mi" inside a
/// "10 miles" list) and stops membership changing row by row as a fill lands.
static func accurateDistancesReady(userLat: Double, userLng: Double) -> Bool {
guard accurateDistancesEnabled else { return false }
guard let cache = loadRoadDistances(), cache.complete == true else { return false }
return haversineMetres(cache.sourceLat, cache.sourceLng, userLat, userLng)
<= roadDistanceOriginToleranceMeters
}
/// How imprecise a fix must be before its distance from the route cache's
/// anchor stops being read as "the user moved". The widget extension runs its
/// own `requestLocation()` on a `kCLLocationAccuracyHundredMeters` manager and
/// is routinely handed a coarse Wi-Fi/cell fix (or a cached one from earlier
/// in the day) — hundreds of metres from the app's GPS anchor.
static let roadDistanceCoarseFixAccuracyMeters: Double = 150
/// How far a coarse fix may sit from the anchor before it is taken as genuine
/// travel rather than fix noise.
static let roadDistanceCoarseFixRadiusMeters: Double = 3_000
/// Where a surface should read displayed distances FROM, and whether routed
/// distances are usable there.
///
/// Routed distances are anchored to the fix that produced them (the app's GPS
/// fix). A surface in another process — the widget extension — gets its OWN
/// fix, which can sit hundreds of metres from that anchor. Reading that as
/// "the user moved away from the cache" downgraded EVERY row in the widget to
/// straight-line + "Approx." while the app, whose fix IS the anchor, showed
/// routed values: one station read differently on two faces, and refreshing
/// never fixed it because each new widget fix landed outside the ring again.
/// Resolve the origin once, here, so every surface labels from the same place.
struct DistanceDisplayOrigin: Equatable {
let lat: Double
let lng: Double
/// True when the caller may serve routed distances from this origin.
let routed: Bool
/// Which fix decided it — diagnostics only.
let source: String
}
static func distanceDisplayOrigin(candidateLat: Double, candidateLng: Double,
candidateAccuracyMeters: Double? = nil) -> DistanceDisplayOrigin {
let straightLine = DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng,
routed: false, source: "straightLine")
guard accurateDistancesEnabled,
let cache = loadRoadDistances(), cache.complete == true else { return straightLine }
let candidateOffset = haversineMetres(cache.sourceLat, cache.sourceLng, candidateLat, candidateLng)
if candidateOffset <= roadDistanceOriginToleranceMeters {
return DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng,
routed: true, source: "candidate")
}
// The candidate fix is outside the ring. A precise fix there means the
// user really has moved, and the honest answer is the straight line from
// where they are. A COARSE fix (or one that reported no accuracy) is fix
// noise, as is any disagreement under a kilometre — neither is good
// evidence that the routes are stale, so fall back to the app's own fix,
// which is the one the cached routes were built around.
let accuracy = candidateAccuracyMeters ?? -1
let fixIsCoarse = accuracy <= 0 || accuracy > roadDistanceCoarseFixAccuracyMeters
guard (fixIsCoarse || candidateOffset <= 1_000),
candidateOffset <= roadDistanceCoarseFixRadiusMeters,
let appFix = loadLocationWithDate(),
haversineMetres(cache.sourceLat, cache.sourceLng,
appFix.coordinate.lat, appFix.coordinate.lng) <= roadDistanceOriginToleranceMeters,
// The stored fix must belong to this cache, not predate it by hours.
appFix.date.timeIntervalSince1970 >= cache.updatedAt - 30 * 60 else { return straightLine }
return DistanceDisplayOrigin(lat: appFix.coordinate.lat, lng: appFix.coordinate.lng,
routed: true, source: "appFix")
}
/// Metres between two coordinates — the same great-circle measure the
/// origin/anchor tests use, exposed so a surface can report how far its fix
/// sits from the anchor (diagnostics only).
static func distanceMetersBetween(lat1: Double, lng1: Double,
lat2: Double, lng2: Double) -> Double {
haversineMetres(lat1, lng1, lat2, lng2)
}
private static func haversineMetres(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
let r = 6371000.0
let dLat = (lat2 - lat1) * .pi / 180
let dLng = (lng2 - lng1) * .pi / 180
let a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
sin(dLng / 2) * sin(dLng / 2)
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
/// In-memory copy of the road-distance cache.
///
/// `loadRoadDistances()` reads the KEYCHAIN — a synchronous `securityd` XPC
/// round-trip plus a full base64+JSON decode — and `displayDistanceKM` is
/// called PER STATION inside comparisons (`min(by:)`, `sorted`). Re-reading
/// it on every call meant tens of thousands of synchronous XPC round-trips
/// on the main thread during scene creation, which stalled past the 20 s iOS
/// launch watchdog and got the app SIGKILLed (0x8BADF00D). Decode once per
/// process, reuse, and refresh on save.
private static var roadDistanceMemo: RoadDistanceCache?
/// Distinguishes "not loaded yet" from "loaded, and there is no cache".
private static var roadDistanceMemoLoaded = false
static func saveRoadDistances(sourceLat: Double, sourceLng: Double,
entries: [String: CachedRoadDistance],
complete: Bool = true) {
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
updatedAt: Date().timeIntervalSince1970, entries: entries,
complete: complete)
if let data = try? JSONEncoder().encode(cache) {
saveString(data.base64EncodedString(), service: roadDistancesKey)
}
roadDistanceMemo = cache
roadDistanceMemoLoaded = true
}
static func loadRoadDistances() -> RoadDistanceCache? {
if roadDistanceMemoLoaded { return roadDistanceMemo }
let decoded: RoadDistanceCache?
if let raw = loadString(service: roadDistancesKey),
let data = Data(base64Encoded: raw),
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data) {
decoded = cache
} else {
decoded = nil
}
roadDistanceMemo = decoded
roadDistanceMemoLoaded = true
return decoded
}
/// Cached road distance (metres) to a station from the user's location, or
/// nil when not cached / the cache was built too far from where the user
/// is now / the station's own pin doesn't match the coordinate that was
/// routed.
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)?.meters
}
/// Complete out-and-back routed journey distance (metres). New caches store
/// the return route separately because Apple Maps may choose a different
/// return path; legacy one-way caches safely fall back to doubling outward.
static func roadJourneyMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
guard let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) else { return nil }
return entry.meters + (entry.returnMeters ?? entry.meters)
}
/// Whether a usable routed distance is already cached for this station from
/// (near enough) this origin. The routing pass uses it to spend its per-pass
/// budget on stations that still need a route instead of re-routing the
/// same nearest handful forever.
static func hasRoadDistance(for station: FuelStation, userLat: Double, userLng: Double) -> Bool {
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) != nil
}
/// True only for current-format route cache entries with a separately
/// measured return route. Legacy one-way entries are still usable for journey
/// arithmetic (`outward × 2`) but Best Value must describe them as estimated.
static func hasMeasuredReturnRoadDistance(for station: FuelStation, userLat: Double, userLng: Double) -> Bool {
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)?.returnMeters != nil
}
static func roadDistanceDiagnostics(for station: FuelStation, userLat: Double, userLng: Double) -> RoadDistanceDiagnostics {
let displayKM = displayDistanceKM(station: station, userLat: userLat, userLng: userLng)
let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)
let outward = entry?.meters
let returnMeters = entry?.returnMeters
return RoadDistanceDiagnostics(
displayDistanceKM: displayKM,
displaySource: outward == nil ? .straightLine : .roadRoute,
outwardMeters: outward,
returnMeters: returnMeters,
totalJourneyMeters: outward.map { $0 + (returnMeters ?? $0) },
hasMeasuredReturn: returnMeters != nil,
hasValidCacheEntry: entry != nil
)
}
private static func roadDistanceEntry(for station: FuelStation, userLat: Double, userLng: Double) -> CachedRoadDistance? {
guard let cache = loadRoadDistances(),
let entry = cache.entries[station.id] else { return nil }
// The cache is only valid near the location it was built from.
let dLat = (userLat - cache.sourceLat) * .pi / 180
let dLng = (userLng - cache.sourceLng) * .pi / 180
let r = 6371000.0
let a = sin(dLat / 2) * sin(dLat / 2) +
cos(cache.sourceLat * .pi / 180) * cos(userLat * .pi / 180) *
sin(dLng / 2) * sin(dLng / 2)
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
// Pin fingerprint: never serve a routed value for a coordinate we
// didn't actually route to. This is the guard that keeps embedded /
// live / cached station sets from injecting each other's road metres.
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
return entry
}
/// Distance (km) to display for a station: cached ROAD distance when
/// available (matches Apple Maps), else straight-line haversine.
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
stationDistanceDisplay(station: station, userLat: userLat, userLng: userLng).km
}
/// Distance to show for a station AND whether it came from a routed road
/// distance or the straight-line fallback. Both come out of ONE cache read so
/// a surface can never mark a figure "Approx." while showing a routed value
/// (or the reverse) — the failure mode when the number and its label are
/// computed separately.
static func stationDistanceDisplay(station: FuelStation, userLat: Double, userLng: Double) -> StationDistanceDisplay {
// Accurate-distances mode: a routed value is served ONLY when the whole
// search area has been routed from a nearby anchor. Otherwise every row
// falls back to straight-line together, so a list never mixes bases and
// the radius filter (which reads this same function) always agrees with
// what the rows show.
if accurateDistancesEnabled,
accurateDistancesReady(userLat: userLat, userLng: userLng),
let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) {
let returnKM = (entry.meters + (entry.returnMeters ?? entry.meters)) / 1000.0
return StationDistanceDisplay(
km: entry.meters / 1000.0,
isApproximate: false,
returnKM: returnKM,
returnIsApproximate: entry.returnMeters == nil
)
}
let km = station.distanceKM(to: userLat, lng2: userLng)
return StationDistanceDisplay(
km: km,
isApproximate: true,
returnKM: km * 2,
returnIsApproximate: true
)
}
/// Radius test on the SAME distance the UI shows. Filtering straight-line
/// while rows display a cached ROAD distance let a station read "14.6 mi"
/// inside a list set to "within 10 miles" — both numbers were individually
/// right (9.8 mi straight-line, 14.6 mi by road) but the list looked broken.
/// Uncached stations still test straight-line, i.e. their displayed value, so
/// nothing changes for them.
static func isWithinDisplayRadius(station: FuelStation, radiusKM: Double, userLat: Double, userLng: Double) -> Bool {
stationDistanceDisplay(station: station, userLat: userLat, userLng: userLng).km <= radiusKM
}
// MARK: Low-level keychain helpers
private static func keychainData(service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return data
}
private static func writeKeychain(data: Data, service: String) {
// Update in place when the item already exists. Delete-then-add cost two
// round trips to securityd and briefly left the key holding no value at
// all, which is half of why a setting could disagree between launches.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
let status = SecItemUpdate(query as CFDictionary,
[kSecValueData as String: data] as CFDictionary)
guard status == errSecItemNotFound else { return }
// Nothing stored under this service yet — the first write after the app
// was deleted, or a key added since. Add it.
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecValueData as String: data,
]
SecItemAdd(addQuery as CFDictionary, nil)
}
private static func loadString(service: String) -> String? {
if let data = keychainData(service: service) {
return String(data: data, encoding: .utf8)
}
if let defaults = UserDefaults(suiteName: appGroupSuite) {
return defaults.string(forKey: service)
}
return nil
}
private static func saveString(_ value: String, service: String) {
UserDefaults(suiteName: appGroupSuite)?.set(value, forKey: service)
writeKeychain(data: Data(value.utf8), service: service)
}
// Widget diagnostics beacons — the widget extension writes its last
// makeEntry state PER INTENT TYPE (keychain survives on free SideStore
// accounts where the app-group container isn't provisioned); the app's
// Debug section reads them to see whether each widget kind's timeline
// actually ran and what it produced. Service key embeds the intent type
// so small and medium widgets never overwrite each other.
static func saveWidgetDiag(_ json: String, intentType: String) {
saveString(json, service: "widget.diag.\(intentType)")
}
static func loadWidgetDiag(intentType: String) -> String? {
loadString(service: "widget.diag.\(intentType)")
}
}
/// Lightweight description of the distance value shown for a station and any
/// usable road-route cache behind it. No user coordinates are included.
struct RoadDistanceDiagnostics: Equatable {
let displayDistanceKM: Double
let displaySource: FuelSavingsDistanceBasis
let outwardMeters: Double?
let returnMeters: Double?
let totalJourneyMeters: Double?
let hasMeasuredReturn: Bool
let hasValidCacheEntry: Bool
}
/// The station distance a surface should show, plus whether it came from a routed
/// road distance or the straight-line fallback. The confidence travels WITH the
/// value, so a label can never claim a confidence the figure doesn't have.
struct StationDistanceDisplay: Equatable {
let km: Double
let isApproximate: Bool
let returnKM: Double
let returnIsApproximate: Bool
init(km: Double,
isApproximate: Bool,
returnKM: Double? = nil,
returnIsApproximate: Bool? = nil) {
self.km = km
self.isApproximate = isApproximate
self.returnKM = returnKM ?? km * 2
self.returnIsApproximate = returnIsApproximate ?? isApproximate
}
/// Row/sheet label: a straight-line fallback is marked "Approx." so a figure
/// that is NOT a routed road distance can never be read as one, and a cached
/// road distance stays a bare figure. It updates by itself once routing lands,
/// because `FuelStore.stationDistanceDisplay` re-reads the cache every call.
func label(unit: DistanceUnit) -> String {
let text = unit.format(km)
return isApproximate ? "Approx. \(text)" : text
}
/// Row/sheet label that keeps the one-way station distance primary, then
/// adds the independently measured or estimated out-and-back distance.
func labelWithReturn(unit: DistanceUnit) -> String {
"\(label(unit: unit)) (\(returnLabel(unit: unit)))"
}
/// The out-and-back figure on its own: "5.0 mi return" / "Approx. 5.0 mi return".
///
/// Split out of `labelWithReturn` for surfaces that print the one-way and the
/// return figure on SEPARATE lines (the More info sheet's distance column). Both
/// figures still come from this one type, so a routed value can never be marked
/// "Approx." while its return figure is not — or the other way round.
func returnLabel(unit: DistanceUnit) -> String {
let text = unit.format(returnKM)
return returnIsApproximate ? "Approx. \(text) return" : "\(text) return"
}
/// VoiceOver form of `returnLabel(unit:)`.
func spokenReturnLabel(unit: DistanceUnit) -> String {
let text = unit.spoken(returnKM)
return returnIsApproximate ? "Approximately \(text) return" : "\(text) return"
}
/// VoiceOver form of `label(unit:)`.
func spokenLabel(unit: DistanceUnit) -> String {
let text = unit.spoken(km)
return isApproximate ? "Approximately \(text)" : text
}
/// VoiceOver form of `labelWithReturn(unit:)`.
func spokenLabelWithReturn(unit: DistanceUnit) -> String {
let returnText = unit.spoken(returnKM)
let returnLabel = returnIsApproximate ? "Approximately \(returnText)" : returnText
return "\(spokenLabel(unit: unit)), \(returnLabel) return"
}
}
/// Cached Apple-Maps road distances for nearby stations (see
/// A single cached road distance plus the station pin it was routed to. Keeping
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
/// *different* coordinate under the same ID — the guard that stops embedded /
/// live / cached station sets cross-contaminating the distance display.
struct CachedRoadDistance: Codable {
let meters: Double
let lat: Double
let lng: Double
let returnMeters: Double?
init(meters: Double, lat: Double, lng: Double, returnMeters: Double? = nil) {
self.meters = meters
self.lat = lat
self.lng = lng
self.returnMeters = returnMeters
}
}
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
/// the routed pin.
struct RoadDistanceCache: Codable {
let sourceLat: Double
let sourceLng: Double
let updatedAt: TimeInterval
let entries: [String: CachedRoadDistance]
/// True when `entries` covers EVERY station within
/// `RoadDistanceService.maxRadiusKM` of the source — only then may the browse
/// layer serve routed distances (see `FuelStore.accurateDistancesReady`).
/// Absent in caches written before the accurate-distances toggle existed,
/// which were 12-per-pass sets and never complete: absent = not complete.
let complete: Bool?
init(sourceLat: Double, sourceLng: Double, updatedAt: TimeInterval,
entries: [String: CachedRoadDistance], complete: Bool? = nil) {
self.sourceLat = sourceLat
self.sourceLng = sourceLng
self.updatedAt = updatedAt
self.entries = entries
self.complete = complete
}
}