Move FuelType.tintColor (green=unleaded #30D158, yellow=premium #FFD60A, cyan=diesel #64D2FF) into Shared/FuelStore.swift so the app, widget, and Live Activity all read one definition (was app-only in StationsView, so the widget couldn't see it). Tint the Live Activity rich card pump icon and the Dynamic Island compactLeading pump by context.state.fuel.tintColor.
1007 lines
40 KiB
Swift
1007 lines
40 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
|
||
|
||
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: 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 * 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: 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)")
|
||
}
|
||
}
|