166 lines
5.8 KiB
Swift
166 lines
5.8 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
|
|
|
|
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
|
|
let 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")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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() -> (lat: Double, lng: Double, 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 (parts[0], 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: 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)
|
|
}
|
|
}
|