Files
fuelboard/Shared/FuelStore.swift
T

356 lines
12 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 Security
// MARK: - Fuel types
struct Coordinate: Equatable, Codable {
let lat: Double
let lng: 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 {
case e10 // Unleaded 95 (E10)
case e5 // Premium 97/98 (E5)
case diesel // B7 diesel
var id: String { rawValue }
var displayName: String {
switch self {
case .e10: return "E10 Unleaded"
case .e5: return "E5 Premium"
case .diesel: return "Diesel"
}
}
}
// 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 widget tap and app rows.
var mapsDirectionsURL: URL? {
URL(string: "http://maps.apple.com/?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 ALL-CAPS titles ("SHELL SALTERHEBBLE" → "Shell Salterhebble"),
/// preserving known acronyms (BP, MFG, ASDA). Mixed-case names pass through untouched.
var sanitizedStationTitle: String {
guard self == self.uppercased(), self.rangeOfCharacter(from: .letters) != nil else { return self }
let keepUppercase: Set<String> = ["BP", "MFG", "ASDA", "MOTO"]
return self.split(separator: " ").map { word in
let w = String(word)
if keepUppercase.contains(w.uppercased()) { return w.uppercased() }
return w.prefix(1).uppercased() + w.dropFirst().lowercased()
}.joined(separator: " ")
}
}
// 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 favouritesKey = "fuelboard.favourites" // [FuelStation] JSON
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
// MARK: Stations
static func loadStations() -> [FuelStation] {
if let data = keychainData(service: stationsKey),
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
!stations.isEmpty {
return stations
}
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
}
return []
}
static func saveStations(_ stations: [FuelStation]) {
if let data = try? JSONEncoder().encode(stations) {
UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: stationsKey)
writeKeychain(data: data, service: stationsKey)
}
}
// 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])
}
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: 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: Favourites
static func loadFavourites() -> [FuelStation] {
if let data = keychainData(service: favouritesKey),
let favs = try? JSONDecoder().decode([FuelStation].self, from: data) {
return favs.map { fav in
var f = fav
f.name = fav.name.sanitizedStationTitle
return f
}
}
if let defaults = UserDefaults(suiteName: appGroupSuite),
let data = defaults.data(forKey: favouritesKey),
let favs = try? JSONDecoder().decode([FuelStation].self, from: data) {
return favs
}
return []
}
static func saveFavourites(_ favourites: [FuelStation]) {
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).
static func refreshedFavourites(_ favourites: [FuelStation], from stations: [FuelStation]) -> [FuelStation] {
var updated = favourites
for (i, fav) in favourites.enumerated() {
if let fresh = stations.first(where: { $0.id == fav.id }) {
updated[i] = fresh
}
}
return updated
}
// MARK: Alerts
static func loadAlertsEnabled() -> Bool {
loadString(service: alertsEnabledKey) == "1"
}
static func saveAlertsEnabled(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: alertsEnabledKey)
}
static func loadAlertsRadius() -> Double {
if let raw = loadString(service: alertsRadiusKey), let value = Double(raw), value >= 1, value <= 10 {
return value
}
return 3.0
}
static func saveAlertsRadius(_ radius: Double) {
saveString(String(radius), service: alertsRadiusKey)
}
// 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)
}
/// 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: 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)
}
}