// 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 = ["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.stationLimit" // Int (10/25/50/75/100) static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km // 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 count limit static func loadStationLimit() -> Int { if let raw = loadString(service: stationLimitKey), let value = Int(raw), [10, 25, 50, 75, 100].contains(value) { return value } return 25 } static func saveStationLimit(_ limit: Int) { saveString(String(limit), 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: 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) } }