Files
fuelboard/Shared/FuelStore.swift
T
FuelBoard Contributor 64ef260959 widget + live activity: prefer Apple-Maps road distance, computed by the app
The widget/Live Activity showed straight-line haversine distance (0.8 mi)
while Apple Maps routes 1.8 mi. Road routing is too heavy for the widget
execution + ~40-70/day refresh budget, so the APP now computes it:

- New RoadDistanceService (app target): for the nearest 12 stations each
  pass, calls MapKit MKDirections (free, no API key, matches Apple Maps)
  and caches metres keyed by station ID.
- Cache stored in KEYCHAIN (fuelboard.roadDistances) so the widget reads it
  even on free SideStore accounts with no app-group container; only valid
  within 600 m of the location it was built from.
- Throttled: recompute max every 10 min, or when the user moves > 400 m;
  wired into the location-update hook + location onChange.
- Widget face, app station rows + Live Activity show the cached road
  distance, falling back to straight-line when absent.
- 4 new cache tests (105 total).
2026-08-20 11:33:47 +01:00

1072 lines
43 KiB
Swift
Raw 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
var id: String { rawValue }
var displayName: String {
switch self {
case .cheapest: return "Cheapest"
case .closest: return "Closest"
}
}
}
/// 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) * 1.60934).rounded())
}
/// Convert a value expressed in this unit to km.
func toKM(_ value: Double) -> Double {
switch self {
case .miles: return value * 1.60934
case .kilometers: return value
}
}
/// Convert a km value to this unit.
func fromKM(_ km: Double) -> Double {
switch self {
case .miles: return km * 0.621371
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)
}
}
/// 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 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]
/// Updated timestamp (seconds since 1970) — nil for sample data.
let priceUpdated: TimeInterval?
/// 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")
}
/// 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 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 relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback
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 relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
static let stationCountKey = "fuelboard.stationCount" // String — station count
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
// MARK: Stations
// The full-UK dataset (~2.9 MB) lives in app-group UserDefaults only —
// keychain is for small values and cannot hold it. Read order is
// defaults-first for stations (keychain may hold a legacy small set from
// older builds; the full country dump always wins).
/// 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 let defaults = UserDefaults(suiteName: appGroupSuite),
let data = defaults.data(forKey: stationsKey),
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
!stations.isEmpty {
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) {
UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: stationsKey)
// Intentionally NOT written to keychain — 2.9 MB exceeds its limits.
}
}
// 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)
}
// 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)
}
// 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)
}
/// 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: Relay fallback (dev-only)
/// Dev-only switch: re-inserts the LAN relay into the live chain as a
/// fallback for home testing. OFF by default — consumers must never make
/// a local-network attempt (the relay is unreachable off the developer's
/// LAN, and the attempt itself would fire the iOS Local Network prompt).
/// Toggled from Settings → Debug; keychain-first so it survives the
/// delete → reinstall test loop like every other small setting.
static func loadRelayFallbackEnabled() -> Bool {
loadString(service: relayFallbackKey) == "1"
}
static func saveRelayFallbackEnabled(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: relayFallbackKey)
}
// 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: 12 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 * 1.60934
/// 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 — data is cached; the app only auto-refreshes
// twice a day (pull-to-refresh is the manual override).
static let refreshInterval: TimeInterval = 12 * 60 * 60
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)
}
// 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 {
guard let last = loadLastRefresh() else { return false }
return Date().timeIntervalSince(last) < refreshInterval
}
// MARK: Onboarding — the app shows the intro screen on first launch only
// (a test button in the Alerts tab re-opens it). Stored in the app group
// so the widget can see it too if ever needed.
static func loadHasCompletedOnboarding() -> Bool {
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false
}
static func saveHasCompletedOnboarding(_ completed: Bool) {
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
}
// 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
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: Double]) {
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
updatedAt: Date().timeIntervalSince1970, entries: entries)
if let data = try? JSONEncoder().encode(cache) {
saveString(data.base64EncodedString(), service: roadDistancesKey)
}
}
static func loadRoadDistances() -> RoadDistanceCache? {
guard let raw = loadString(service: roadDistancesKey),
let data = Data(base64Encoded: raw),
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
else { return nil }
return cache
}
/// 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.
static func roadDistanceMeters(for stationID: String, userLat: Double, userLng: Double) -> Double? {
guard let cache = loadRoadDistances(),
let meters = cache.entries[stationID] 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 }
return meters
}
/// 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 {
if let meters = roadDistanceMeters(for: station.id, userLat: userLat, userLng: userLng) {
return meters / 1000.0
}
return station.distanceKM(to: userLat, lng2: userLng)
}
// 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) {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
SecItemDelete(deleteQuery as CFDictionary)
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)")
}
}
/// Cached Apple-Maps road distances for nearby stations (see
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres.
struct RoadDistanceCache: Codable {
let sourceLat: Double
let sourceLng: Double
let updatedAt: TimeInterval
let entries: [String: Double]
}