// 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 "Unleaded (E10)" case .e5: return "Premium (E5)" 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 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 = ["BP", "MFG", "ASDA", "MOTO", "SPAR", "UK", "NI", "SS"] let keepLowercase: Set = ["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 favouritesKey = "fuelboard.favourites" // [FuelStation] JSON static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) // 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). 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]) } 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: 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) } }