Files
fuelboard/Shared/FuelStore.swift
T

700 lines
26 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 Security
// 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)" }
}
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 {
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"
}
}
}
/// 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)
}
}
// 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)] = [
("SHELL", "brand_shell"),
("SAINSBURY", "brand_sainsburys"),
("MORRISONS", "brand_morrisons"),
("APPLEGREEN", "brand_applegreen"),
("TESCO", "brand_tesco"),
("TEXACO", "brand_texaco"),
("ESSO", "brand_esso"),
("ASDA", "brand_asda"),
("GULF", "brand_gulf"),
("BP", "brand_bp"),
("JET", "brand_jet"),
]
for (needle, asset) in known where raw.contains(needle) {
return asset
}
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 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 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: 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)
}
}
// 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: 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)
}
/// 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? {
var 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)
}
}