- Settings → Live Activity now has Fuel and Distance pickers (5/10/15, unit-aware) stored under fuelboard.liveActivityFuel / liveActivityRadiusMiles - LiveActivityManager uses these instead of the Stations-tab selectedFuel / stationLimit; Stations changes no longer re-target the pill - Fuel moved from Activity attributes into ContentState so changing fuel in Settings updates the RUNNING activity in place (no end+restart needed) - Stations tab stays a pure data-interrogation + favourites surface
640 lines
24 KiB
Swift
640 lines
24 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
|
|
}
|
|
|
|
/// A favourite = a station pinned for ONE fuel type. Starring a row while
|
|
/// viewing Unleaded only creates an Unleaded favourite, so the same station
|
|
/// can be favourite for Diesel independently (or not at all).
|
|
struct FavouriteEntry: Identifiable, Codable, Equatable {
|
|
var station: FuelStation
|
|
let fuel: FuelType
|
|
|
|
var id: String { "\(fuel.rawValue)|\(station.id)" }
|
|
}
|
|
|
|
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 (E10)
|
|
case e5 // Premium (E5)
|
|
case diesel // B7 diesel
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .e10: return "Unleaded"
|
|
case .e5: return "Premium"
|
|
case .diesel: return "Diesel"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Display unit for all distances in the app + widget. Internally distances
|
|
/// are always stored/computed in km; conversion happens at the display and
|
|
/// filter boundary so nothing else needs to know the unit.
|
|
enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
|
|
case miles
|
|
case kilometers
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .miles: return "Miles"
|
|
case .kilometers: return "Kilometres"
|
|
}
|
|
}
|
|
|
|
/// Short suffix for values ("5 mi", "3.2 km").
|
|
var shortName: String {
|
|
switch self {
|
|
case .miles: return "mi"
|
|
case .kilometers: return "km"
|
|
}
|
|
}
|
|
|
|
/// Full word for narrative text ("within 5 miles", "within 8 km").
|
|
var label: String {
|
|
switch self {
|
|
case .miles: return "miles"
|
|
case .kilometers: return "km"
|
|
}
|
|
}
|
|
|
|
/// Convert a value expressed in this unit to km.
|
|
func toKM(_ value: Double) -> Double {
|
|
switch self {
|
|
case .miles: return value * 1.60934
|
|
case .kilometers: return value
|
|
}
|
|
}
|
|
|
|
/// Convert a km value to this unit.
|
|
func fromKM(_ km: Double) -> Double {
|
|
switch self {
|
|
case .miles: return km * 0.621371
|
|
case .kilometers: return km
|
|
}
|
|
}
|
|
|
|
/// Format a km distance in this unit ("1.2 mi", "3.4 km").
|
|
func format(_ km: Double) -> String {
|
|
String(format: "%.1f %@", fromKM(km), shortName)
|
|
}
|
|
}
|
|
|
|
// 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 app's rows/notifications, which
|
|
/// open it directly via `UIApplication.shared.open`.
|
|
var mapsDirectionsURL: URL? {
|
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
|
|
}
|
|
|
|
/// Widget tap URL — the native Maps scheme (`maps://`). From the iOS Home
|
|
/// Screen the system may either (a) open Maps directly, or (b) deliver the
|
|
/// URL to the containing app, where FuelBoardApp.onOpenURL forwards it to
|
|
/// Maps. In CarPlay there is no containing app, but because `maps://`
|
|
/// targets Apple Maps — an app that IS in CarPlay — the system may route
|
|
/// the tap straight to the car display, bypassing the app entirely. This
|
|
/// is the only scheme with a chance of working in CarPlay; the old
|
|
/// `fuelboard://` relay is kept as a handled fallback in onOpenURL.
|
|
var widgetDirectionsURL: URL? {
|
|
URL(string: "maps://?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<String> = ["BP", "MFG", "ASDA", "MOTO", "SPAR", "UK", "NI", "SS"]
|
|
let keepLowercase: Set<String> = ["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 distanceUnitKey = "fuelboard.distanceUnit" // DistanceUnit raw value
|
|
static let favouritesKey = "fuelboard.favourites" // [FavouriteEntry] JSON
|
|
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
|
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
|
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
|
|
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
|
static let liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle
|
|
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
|
|
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
|
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
|
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
|
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
|
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
|
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
|
|
|
|
// 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).
|
|
|
|
/// Stations from `stations` tied with `price` for `fuel` (within 0.01p),
|
|
/// sorted nearest-first from `lat`/`lng`. Used to offer multiple
|
|
/// cheapest-station choices in an alert notification.
|
|
static func tiedStations(
|
|
in stations: [FuelStation],
|
|
fuel: FuelType,
|
|
price: Double,
|
|
fromLat lat: Double, lng: Double
|
|
) -> [FuelStation] {
|
|
stations
|
|
.filter { abs(($0.prices[fuel] ?? .infinity) - price) < 0.01 }
|
|
.sorted { lhs, rhs in
|
|
lhs.distanceKM(to: lat, lng2: lng) < rhs.distanceKM(to: lat, lng2: lng)
|
|
}
|
|
}
|
|
|
|
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])
|
|
}
|
|
|
|
/// Location + the timestamp it was saved, for debug display (fix age).
|
|
static func loadLocationWithDate() -> (coordinate: Coordinate, 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 (Coordinate(lat: parts[0], lng: 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: Alerts fuel
|
|
|
|
/// The fuel proximity alerts monitor — independent of the Stations-tab
|
|
/// selection so users can browse any fuel without re-targeting alerts.
|
|
static func loadAlertsFuel() -> FuelType {
|
|
if let raw = loadString(service: alertsFuelKey), let fuel = FuelType(rawValue: raw) {
|
|
return fuel
|
|
}
|
|
return .e10
|
|
}
|
|
|
|
static func saveAlertsFuel(_ fuel: FuelType) {
|
|
saveString(fuel.rawValue, service: alertsFuelKey)
|
|
}
|
|
|
|
// 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: Distance unit — miles or kilometres. Stored raw value; default
|
|
// miles for backward compatibility with pre-toggle installs.
|
|
|
|
static func loadDistanceUnit() -> DistanceUnit {
|
|
if let raw = loadString(service: distanceUnitKey), let unit = DistanceUnit(rawValue: raw) {
|
|
return unit
|
|
}
|
|
return .miles
|
|
}
|
|
|
|
static func saveDistanceUnit(_ unit: DistanceUnit) {
|
|
saveString(unit.rawValue, service: distanceUnitKey)
|
|
}
|
|
|
|
// MARK: Debug mode
|
|
|
|
/// Hidden developer flag. NOT exposed in the UI: toggled by tapping the
|
|
/// Settings → About → Version row five times. When on, the Settings tab
|
|
/// shows the Debug section (test notification buttons); when off, that
|
|
/// section is completely hidden from the UI. Stored keychain-first like
|
|
/// every other small setting so it survives app deletion during the
|
|
/// delete → reinstall test loop.
|
|
static func loadDebugMode() -> Bool {
|
|
loadString(service: debugModeKey) == "1"
|
|
}
|
|
|
|
static func saveDebugMode(_ enabled: Bool) {
|
|
saveString(enabled ? "1" : "0", service: debugModeKey)
|
|
}
|
|
|
|
// MARK: Favourites
|
|
|
|
/// A favourite pins a station FOR ONE fuel type. Starring a row while
|
|
/// viewing Unleaded only creates an Unleaded favourite — the station is
|
|
/// not automatically favourited for Premium or Diesel.
|
|
static func loadFavourites() -> [FavouriteEntry] {
|
|
if let data = keychainData(service: favouritesKey),
|
|
let entries = try? JSONDecoder().decode([FavouriteEntry].self, from: data) {
|
|
return entries.map { entry in
|
|
var e = entry
|
|
e.station.name = entry.station.name.sanitizedStationTitle
|
|
return e
|
|
}
|
|
}
|
|
if let defaults = UserDefaults(suiteName: appGroupSuite),
|
|
let data = defaults.data(forKey: favouritesKey),
|
|
let entries = try? JSONDecoder().decode([FavouriteEntry].self, from: data) {
|
|
return entries
|
|
}
|
|
return []
|
|
}
|
|
|
|
static func saveFavourites(_ favourites: [FavouriteEntry]) {
|
|
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).
|
|
/// The fuel scoping is preserved per entry.
|
|
static func refreshedFavourites(_ favourites: [FavouriteEntry], from stations: [FuelStation]) -> [FavouriteEntry] {
|
|
favourites.map { entry in
|
|
guard let fresh = stations.first(where: { $0.id == entry.station.id }) else { return entry }
|
|
return FavouriteEntry(station: fresh, fuel: entry.fuel)
|
|
}
|
|
}
|
|
|
|
// 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: Live Activity
|
|
|
|
static func loadLiveActivityEnabled() -> Bool {
|
|
loadString(service: liveActivityKey) == "1"
|
|
}
|
|
|
|
static func saveLiveActivityEnabled(_ enabled: Bool) {
|
|
saveString(enabled ? "1" : "0", service: liveActivityKey)
|
|
}
|
|
|
|
/// The fuel the Live Activity tracks — independent of the Stations-tab
|
|
/// selection so the Lock Screen pill keeps its own target.
|
|
static func loadLiveActivityFuel() -> FuelType {
|
|
if let raw = loadString(service: liveActivityFuelKey), let fuel = FuelType(rawValue: raw) {
|
|
return fuel
|
|
}
|
|
return .e10
|
|
}
|
|
|
|
static func saveLiveActivityFuel(_ fuel: FuelType) {
|
|
saveString(fuel.rawValue, service: liveActivityFuelKey)
|
|
}
|
|
|
|
/// The Live Activity's search radius in miles (5/10/15), converted to the
|
|
/// chosen unit at use — same options as the Stations list.
|
|
static func loadLiveActivityRadiusMiles() -> Int {
|
|
if let raw = loadString(service: liveActivityRadiusKey), let value = Int(raw), stationRadiusOptions.contains(value) {
|
|
return value
|
|
}
|
|
return 5
|
|
}
|
|
|
|
static func saveLiveActivityRadiusMiles(_ miles: Int) {
|
|
saveString(String(miles), service: liveActivityRadiusKey)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// MARK: Relay metadata — shown in Settings → About. Written after each
|
|
// successful full fetch so the About section reflects the live source.
|
|
|
|
static func saveRelayMeta(_ meta: RelayMeta) {
|
|
if let source = meta.source {
|
|
saveString(source, service: relaySourceKey)
|
|
}
|
|
if let count = meta.stationCount {
|
|
saveString(String(count), service: stationCountKey)
|
|
}
|
|
if let updated = meta.dataUpdated {
|
|
saveString(updated, service: dataUpdatedKey)
|
|
}
|
|
}
|
|
|
|
static func loadRelaySource() -> String? {
|
|
loadString(service: relaySourceKey)
|
|
}
|
|
|
|
static func loadStationCount() -> Int? {
|
|
guard let raw = loadString(service: stationCountKey) else { return nil }
|
|
return Int(raw)
|
|
}
|
|
|
|
static func loadDataUpdated() -> String? {
|
|
loadString(service: dataUpdatedKey)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|