328 lines
11 KiB
Swift
328 lines
11 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.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)
|
|
}
|
|
}
|