// 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 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() -> 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: 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) } }