The Settings sliders called persistFuelSavingsSettings() on every value change, which rewrote all four keys — four UserDefaults writes and four keychain round trips per step, on the main thread, up to 90 times per drag. That is what made them stutter. saveFuelSavingsField(_:from:) writes just the field that moved, so a slider gesture touches one key. writeKeychain now uses SecItemUpdate and only falls back to SecItemAdd on errSecItemNotFound. Delete-then-add cost two round trips to securityd and briefly left the key holding no value at all — a window where the keychain, the authoritative mirror on read, could disagree with the app-group defaults. Tests pin the store half: a single-field save leaves the other three keys alone, and the whole-settings save still writes all four.
1912 lines
84 KiB
Swift
1912 lines
84 KiB
Swift
// 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 "Station sign"
|
||
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 "Open map"
|
||
case .showDetails: return "More info"
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|