// 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 SwiftUI import Security #if canImport(AppIntents) import AppIntents #endif import SwiftUI // 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)" } } /// Persisted baseline for the per-fuel cheapest-favourite price-drop alert. /// Stored separately from the favourites list so refresh-time comparison can /// survive app relaunches without immediately alerting on first fetch. struct FavouriteAlertSnapshot: Codable, Equatable { let fuel: FuelType let stationID: String let stationName: String let price: Double } enum SortMode: String, Codable, CaseIterable, Identifiable { case cheapest case closest case worthTheDrive var id: String { rawValue } var displayName: String { switch self { case .cheapest: return "Cheapest" case .closest: return "Closest" // "Best Value" is the user-facing name of this sort — the segmented // control, the Stations caption, the Settings toggle and narrative, and // the sheet's section header all read it. The raw value stays // `worthTheDrive` so stored preferences and widget configs keep decoding. case .worthTheDrive: return "Best Value" } } } enum AlertMode: String, Codable, CaseIterable, Identifiable { case cheapest case bestValue var id: String { rawValue } var displayName: String { switch self { case .cheapest: return "Cheapest station" case .bestValue: return "Best Value" } } } /// 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 { #if canImport(AppIntents) typealias DisplayRepresentation = AppIntents.DisplayRepresentation typealias TypeDisplayRepresentation = AppIntents.TypeDisplayRepresentation static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel" static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [ .e10: "Unleaded", .e5: "Premium", .diesel: "Diesel", ] #endif 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" } } } /// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158), /// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Lives here in Shared /// so the app, widget, and Live Activity all tint the pump/fuel glyphs from one /// definition. extension FuelType { var tintColor: Color { switch self { case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158 case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF } } } #if canImport(AppIntents) extension FuelType: AppEnum {} #endif /// 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" } } /// Full word for a specific value — pluralizes miles ("1 mile" vs /// "5 miles"); metric is always "km". func label(for value: Double) -> String { switch self { case .miles: return value == 1 ? "mile" : "miles" case .kilometers: return "km" } } /// A whole-mile distance (as stored/used by search + Live Activity) /// shown in this unit, rounded to a whole number for picker labels: /// 5 miles -> "5" (miles unit) or "8" (km unit). func displayMiles(_ miles: Int) -> Int { Int(fromKM(Double(miles) * FuelSavingsCalculator.kilometresPerMile).rounded()) } /// Convert a value expressed in this unit to km. func toKM(_ value: Double) -> Double { switch self { case .miles: return value * FuelSavingsCalculator.kilometresPerMile case .kilometers: return value } } /// Convert a km value to this unit. func fromKM(_ km: Double) -> Double { switch self { case .miles: return km / FuelSavingsCalculator.kilometresPerMile 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) } /// Spoken form of a KM distance ("6.4 miles", "10.3 kilometres"), for /// VoiceOver. `format(_:)` returns the compact "6.4 mi"/"1.2 km" suffix that a /// synthesizer may read as letters or skip, and "Approx." needs a word too, so /// every spoken distance goes through here. func spoken(_ km: Double) -> String { switch self { case .miles: let miles = fromKM(km) return String(format: "%.1f %@", miles, label(for: miles)) case .kilometers: return String(format: "%.1f kilometres", km) } } } /// Display style for fuel prices. Internally prices are always stored and /// computed in pence-per-litre (GOV.UK's unit, e.g. 129.9); the style only /// affects RENDERING: the station-sign convention shows the bare pence /// figure ("129.9", no £ — exactly what a UK forecourt sign shows), pounds & /// pence shows the converted value in the forecourt's superscript style /// ("£1.29⁹/L"). Calculations never see this. enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable { case stationSign // 129.9 — bare pence figure, like the roadside sign case poundsPence // £1.29⁹/L — small superscript third digit, small /L var id: String { rawValue } var displayName: String { switch self { case .stationSign: return "Pence per litre" case .poundsPence: return "Pounds & pence" } } } // MARK: - Station tap action /// What happens when a station row is tapped in the Stations/Favourites tabs: /// open Apple Maps directions immediately, or show a detail sheet (name, /// address, distance, Directions button) — the default. enum StationTapAction: String, Codable, CaseIterable, Identifiable { case openMap // tap → Apple Maps directions straight away case showDetails // tap → detail sheet, Directions button inside var id: String { rawValue } var displayName: String { switch self { case .openMap: return "Directions" case .showDetails: return "Station details" } } } // 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] /// Station-level price timestamp (seconds since 1970): the relay's /// `price_updated`, i.e. the newest price submission across the grades it /// maps — never a per-grade time. nil when the source omits it (sample data). let priceUpdated: TimeInterval? init(id: String, name: String, brand: String, address: String, postcode: String, lat: Double, lng: Double, prices: [FuelType: Double], priceUpdated: TimeInterval?) { self.id = id self.name = name self.brand = brand self.address = address self.postcode = postcode self.lat = lat self.lng = lng self.prices = prices self.priceUpdated = priceUpdated } private enum CodingKeys: String, CodingKey { case id, name, brand, address, postcode, lat, lng, prices case priceUpdated = "priceUpdated" case priceUpdatedSnake = "price_updated" } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) name = try values.decode(String.self, forKey: .name) brand = try values.decode(String.self, forKey: .brand) address = try values.decode(String.self, forKey: .address) postcode = try values.decode(String.self, forKey: .postcode) lat = try values.decode(Double.self, forKey: .lat) lng = try values.decode(Double.self, forKey: .lng) prices = try values.decode([FuelType: Double].self, forKey: .prices) priceUpdated = try values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdated) ?? values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdatedSnake) } func encode(to encoder: Encoder) throws { var values = encoder.container(keyedBy: CodingKeys.self) try values.encode(id, forKey: .id) try values.encode(name, forKey: .name) try values.encode(brand, forKey: .brand) try values.encode(address, forKey: .address) try values.encode(postcode, forKey: .postcode) try values.encode(lat, forKey: .lat) try values.encode(lng, forKey: .lng) try values.encode(prices, forKey: .prices) try values.encodeIfPresent(priceUpdated, forKey: .priceUpdated) } /// 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") } /// In-app deep link to this station's More info sheet. Carries the /// stationID plus coordinate/name fallback so the app can still show the /// map sheet when the station has left the cache. var detailDeepLinkURL: URL? { var components = URLComponents() components.scheme = "fuelboard" components.host = "station" components.queryItems = [ URLQueryItem(name: "stationID", value: id), URLQueryItem(name: "lat", value: String(lat)), URLQueryItem(name: "lng", value: String(lng)), URLQueryItem(name: "name", value: name), ] return components.url } /// Widget/Live Activity tap destination honouring Settings → Tap station: /// Open map → Apple Maps directions; More info → in-app detail deep link. var tapDestinationURL: URL? { FuelStore.loadStationTapAction() == .showDetails ? detailDeepLinkURL : widgetDirectionsURL } /// 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)] = [ ("CIRCLE K", "brand_circle_k"), ("MAXOL", "brand_maxol"), ("SPAR", "brand_spar"), ("SHELL", "brand_shell"), ("SAINSBURY", "brand_sainsburys"), ("MORRISONS", "brand_morrisons"), ("APPLEGREEN", "brand_applegreen"), ("TESCO", "brand_tesco"), ("TEXACO", "brand_texaco"), ("ESSAR", "brand_essar"), ("ESSO", "brand_esso"), ("ASDA", "brand_asda"), ("GULF", "brand_gulf"), ("VALERO", "brand_valero"), ("WELCOME BREAK", "brand_welcome_break"), ("THE CO-OPERATIVE", "brand_the_co_operative"), ("CO-OPERATIVE", "brand_the_co_operative"), ("CO OPERATIVE", "brand_the_co_operative"), ("CO-OP", "brand_the_co_operative"), ("CO OP", "brand_the_co_operative"), ("MURCO", "brand_murco"), ("GLEANER", "brand_gleaner"), ("HIGHLAND FUELS", "brand_highland_fuels"), ("BP", "brand_bp"), ("JET", "brand_jet"), ] for (needle, asset) in known where raw.contains(needle) { return asset } if raw.contains("EG ON THE MOVE") { return "brand_esso" } if raw == "GO" || raw.contains("GO FORECOURT") || raw.contains("GO FUEL") { return "brand_gulf" } 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 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 alertsModeKey = "fuelboard.alertsMode" // AlertMode raw value static let alertsBestValueThresholdKey = "fuelboard.alertsBestValueThresholdGBP" // Double £ static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance static let favouriteDropAlertsEnabledKey = "fuelboard.favouriteDropAlertsEnabled" // Bool static let favouriteDropAlertsFuelKey = "fuelboard.favouriteDropAlertsFuel" // FuelType raw value static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance 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 dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // TimeInterval (seconds since 1970) static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" // TimeInterval (seconds since 1970) static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // 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" // govUK dataset update time static let fuelSavingsEnabledKey = "fuelboard.fuelSavings.enabled" static let fuelSavingsMPGKey = "fuelboard.fuelSavings.mpg" static let fuelSavingsAmountKey = "fuelboard.fuelSavings.amount" static let fuelSavingsUnitKey = "fuelboard.fuelSavings.unit" // Large shared payloads belong in the App Group container, not // UserDefaults/CFPreferences (which has a roughly 4 MB limit). static let stationsFileName = "fuelboard.stations.json" /// Where a shared payload is written: the App Group container when the /// provisioning profile grants it, otherwise the app's OWN container. /// /// This fallback is essential in the field. Free/personal-team and SideStore /// profiles do NOT include the `com.apple.security.application-groups` /// entitlement, so `containerURL(forSecurityApplicationGroupIdentifier:)` /// returns nil at runtime. Without this fallback NOTHING persisted on those /// installs: `loadStations()` was permanently empty (so the freshness gate /// could skip the fetch for ever and leave a blank list) and the ~2.8 MB /// dump was re-downloaded on every refresh. The app's own container is not /// shared with the widget/watch extension, but the app itself must keep a /// working cache. static func appGroupFileURL(_ name: String) -> URL? { let fm = FileManager.default if let group = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite), fm.fileExists(atPath: group.path) { // Only use the App Group container when the system actually // provisioned it at install time (i.e. the profile carries the // `com.apple.security.application-groups` entitlement). Never try to // create it: when the entitlement is missing the path either does // not exist or is not writable, and probing it that way failed every // write instead of falling back. return group.appendingPathComponent(name, isDirectory: false) } guard let dir = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } if !fm.fileExists(atPath: dir.path), (try? fm.createDirectory(at: dir, withIntermediateDirectories: true)) == nil { return nil } return dir.appendingPathComponent(name, isDirectory: false) } static func loadAppGroupFile(_ name: String) -> Data? { guard let url = appGroupFileURL(name) else { return nil } return try? Data(contentsOf: url) } @discardableResult static func saveAppGroupFile(_ data: Data, name: String) -> Bool { guard let url = appGroupFileURL(name) else { return false } do { try data.write(to: url, options: .atomic) return true } catch { return false } } // MARK: Stations // The full-UK dataset is stored as an App Group file. UserDefaults is // reserved for small preferences and has a roughly 4 MB total limit. /// 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 DEBUG let probe = appGroupFileURL(stationsFileName) print("FB-STORAGE load \(stationsFileName) url=\(probe?.path ?? "nil") present=\(probe.map { FileManager.default.fileExists(atPath: $0.path) } ?? false)") #endif if let data = loadAppGroupFile(stationsFileName), let stations = try? JSONDecoder().decode([FuelStation].self, from: data), !stations.isEmpty { return stations.map(sanitized) } // Migrate the legacy App Group UserDefaults value once. if let defaults = UserDefaults(suiteName: appGroupSuite), let data = defaults.data(forKey: stationsKey), let stations = try? JSONDecoder().decode([FuelStation].self, from: data), !stations.isEmpty { if saveAppGroupFile(data, name: stationsFileName) { defaults.removeObject(forKey: stationsKey) } 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) { let ok = saveAppGroupFile(data, name: stationsFileName) #if DEBUG print("FB-STORAGE save \(stationsFileName) bytes=\(data.count) ok=\(ok) url=\(appGroupFileURL(stationsFileName)?.path ?? "nil")") #endif } } // 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) } /// A fix the WIDGET EXTENSION obtained on its own, kept apart from /// `locationKey`. /// /// The extension's fix comes from its own `requestLocation()` and is often /// coarse; writing it over `locationKey` replaced the app's GPS fix with a /// Wi-Fi-grade one that could sit hundreds of metres away. That stored fix is /// what the app seeds itself with on launch and what /// `distanceDisplayOrigin` trusts when a coarse fix disagrees with the route /// cache — so a widget fix must never be mistaken for the app's position. static let widgetLocationKey = "fuelboard.widgetLocation" static func saveWidgetLocation(lat: Double, lng: Double, date: Date = Date()) { saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: widgetLocationKey) } static func loadWidgetLocation() -> (coordinate: Coordinate, date: Date)? { let raw = loadString(service: widgetLocationKey) guard let parts = raw?.split(separator: ",").compactMap({ Double($0) }), parts.count == 3 else { return nil } return (Coordinate(lat: parts[0], lng: parts[1]), Date(timeIntervalSince1970: parts[2])) } // 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) } static func loadAlertsMode() -> AlertMode { guard let raw = loadString(service: alertsModeKey), let mode = AlertMode(rawValue: raw), mode == .cheapest else { return .cheapest } return mode } static func saveAlertsMode(_ mode: AlertMode) { saveString(mode.rawValue, service: alertsModeKey) } /// The Best Value gate's options, in 50p steps from 50p to £5. The gate is /// the same figure for the listing's BEST VALUE badges and for the alert, so /// its granularity decides how finely the two can be tuned: the old four /// values (50p, £1, £2, £5) made £2.50 and £3 unreachable, and a whole extra /// detour's worth of saving sat between £1 and £2. Multiples of 0.5 are /// exactly representable, so the stored value and the picker's tags cannot /// drift apart. static let bestValueAlertThresholdOptionsGBP: [Double] = [ 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0 ] static func loadAlertsBestValueThresholdGBP() -> Double { if let raw = loadString(service: alertsBestValueThresholdKey), let value = Double(raw), bestValueAlertThresholdOptionsGBP.contains(value) { return value } return 1.0 } static func saveAlertsBestValueThresholdGBP(_ threshold: Double) { let snapped = bestValueAlertThresholdOptionsGBP .min(by: { abs($0 - threshold) < abs($1 - threshold) }) ?? 1.0 saveString(String(snapped), service: alertsBestValueThresholdKey) } // 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: Price display — station sign (129.9) vs pounds & pence (£1.29⁹/L). // Prices are always stored/computed in pence-per-litre; this style only // changes how they are RENDERED, so it can never affect calculations. // Default station sign = the forecourt convention. static let priceDisplayStyleKey = "fuelboard.priceDisplayStyle" static func loadPriceDisplayStyle() -> PriceDisplayStyle { if let raw = loadString(service: priceDisplayStyleKey), let style = PriceDisplayStyle(rawValue: raw) { return style } return .stationSign } static func savePriceDisplayStyle(_ style: PriceDisplayStyle) { saveString(style.rawValue, service: priceDisplayStyleKey) } // MARK: Station tap action — detail sheet vs open map. Stored raw value; // the default is the detail sheet, so a tap always lands somewhere the app // can come back from. Directions still one tap away, inside the sheet. static let stationTapActionKey = "fuelboard.stationTapAction" static func loadStationTapAction() -> StationTapAction { if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) { return action } return .showDetails } static func saveStationTapAction(_ action: StationTapAction) { saveString(action.rawValue, service: stationTapActionKey) } // MARK: Fuel savings — Worth the Drive? static func loadFuelSavingsSettings() -> FuelSavingsSettings { let enabled = loadString(service: fuelSavingsEnabledKey) == "1" let mpg = loadString(service: fuelSavingsMPGKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultMPG let amount = loadString(service: fuelSavingsAmountKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultPreferredAmount.value let unit = loadString(service: fuelSavingsUnitKey).flatMap(FuelSavingsFillUnit.init(rawValue:)) ?? FuelSavingsSettings.defaultPreferredAmount.unit #if DEBUG // The two stores are mirrors and can DISAGREE (the keychain survives // app deletion, the app-group defaults do not). Print both raw copies // plus which one won, so a setting that flips between launches is // diagnosable instead of guesswork. func raw(_ service: String) -> (keychain: String, appGroup: String) { let keychain = keychainData(service: service).flatMap { String(data: $0, encoding: .utf8) } ?? "-" let appGroup = UserDefaults(suiteName: appGroupSuite)?.string(forKey: service) ?? "-" return (keychain, appGroup) } let enabledRaw = raw(fuelSavingsEnabledKey) let mpgRaw = raw(fuelSavingsMPGKey) print("FB-SAVINGS read enabled=\(enabled ? 1 : 0) [kc=\(enabledRaw.keychain) ag=\(enabledRaw.appGroup)] mpg=\(mpg) [kc=\(mpgRaw.keychain) ag=\(mpgRaw.appGroup)]") #endif return FuelSavingsSettings( enabled: enabled, mpg: mpg, preferredAmount: FuelSavingsFillAmount(value: amount, unit: unit) ) } static func saveFuelSavingsSettings(_ settings: FuelSavingsSettings) { saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey) saveString(String(settings.mpg), service: fuelSavingsMPGKey) saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey) saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey) } /// One field of the Fuel Savings settings. A control that edits a single /// value saves just that field, so moving the MPG slider rewrites the MPG key /// instead of all four — four UserDefaults writes and four keychain round /// trips per gesture is what made those sliders stutter. enum FuelSavingsField { case enabled case mpg case amount case unit } static func saveFuelSavingsField(_ field: FuelSavingsField, from settings: FuelSavingsSettings) { switch field { case .enabled: saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey) case .mpg: saveString(String(settings.mpg), service: fuelSavingsMPGKey) case .amount: saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey) case .unit: saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey) } } /// Superscript digit glyphs for the pounds & pence format's small raised /// third digit (the forecourt style: "£1.29⁹"). private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"] /// Render a pence-per-litre price per the saved style: /// stationSign -> "129.9" (bare pence figure, no £ — the roadside sign), /// poundsPence -> "£1.29⁹/L" (pounds with a superscript third digit and a /// small /L unit, matching UK garage displays). static func priceText(_ pence: Double, style: PriceDisplayStyle? = nil) -> String { switch style ?? loadPriceDisplayStyle() { case .stationSign: return String(format: "%.1f", pence) case .poundsPence: let tenths = Int((pence * 10).rounded()) let whole = tenths / 1000 let major = (tenths % 1000) / 10 let minor = tenths % 10 return String(format: "£%d.%02d", whole, major) + String(superscriptDigits[minor]) + "/L" } } /// Rich, forecourt-styled price text for SwiftUI surfaces. Same formats as /// `priceText`, but the pounds & pence mode renders the third digit small /// and superscripted and /L small and muted — proper text styling instead /// of Unicode glyphs. Pass the surface's size/weight/color; the returned /// Text carries its own fonts, so do NOT apply `.font(...)` on top. static func priceTextAttributed(_ pence: Double, style: PriceDisplayStyle? = nil, size: CGFloat = 17, weight: Font.Weight = .semibold, color: Color = .primary) -> Text { let s = style ?? loadPriceDisplayStyle() let base = Font.system(size: size, weight: weight).monospaced() switch s { case .stationSign: return Text(priceText(pence, style: s)).font(base).foregroundColor(color) case .poundsPence: let tenths = Int((pence * 10).rounded()) let whole = tenths / 1000 let major = (tenths % 1000) / 10 let minor = tenths % 10 let amount = Text(String(format: "£%d.%02d", whole, major)) .font(base) .foregroundColor(color) let sup = Text(String(superscriptDigits[minor])) .font(.system(size: size * 0.6, weight: weight).monospaced()) .baselineOffset(size * 0.35) .foregroundColor(color) let perL = Text("/L") .font(.system(size: size * 0.5, weight: .regular).monospaced()) .foregroundColor(color.opacity(0.55)) return Text("\(amount)\(sup)\(perL)") } } /// Speech-safe pounds form for Siri dialogs — Siri would read "£129.9" /// aloud as "one hundred and twenty-nine pounds", so the SPOKEN answer /// always uses pounds regardless of the display style. static func priceTextSpoken(_ pence: Double) -> String { String(format: "£%.3f", pence / 100) } // 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) } } /// The cheapest favourite for one fuel, preserving the user's stored order /// on price ties. Used by the favourite price-drop alert so a tie does not /// flap between equally-priced favourites. static func cheapestFavourite(in favourites: [FavouriteEntry], fuel: FuelType) -> FavouriteEntry? { var best: FavouriteEntry? for entry in favourites where entry.fuel == fuel { guard let price = entry.station.prices[fuel] else { continue } guard let current = best, let currentPrice = current.station.prices[fuel] else { best = entry continue } if price < currentPrice { best = entry } } return best } /// Reorders ONE fuel's favourites within the global array (drag-and-drop in /// the Favourites tab). The moved fuel's block stays at its original /// position in the array; other fuels keep their relative order. The array /// order IS the widget order — the first favourite of a fuel is the /// "single widget" favourite. static func reorderedFavourites(_ favourites: [FavouriteEntry], fuel: FuelType, fromOffsets source: IndexSet, toOffset destination: Int) -> [FavouriteEntry] { var fuelEntries = favourites.filter { $0.fuel == fuel } // Manual reorder (Foundation-only file — Array.move(fromOffsets:) is // a SwiftUI helper). Reproduces the standard drag semantics: remove // the source items, then insert at the destination, shifted by the // number of removed items that were before it. let moving = source.sorted() let removed = moving.map { fuelEntries[$0] } for index in moving.reversed() { fuelEntries.remove(at: index) } var insertion = destination for index in moving where index < destination { insertion -= 1 } fuelEntries.insert(contentsOf: removed, at: min(max(insertion, 0), fuelEntries.count)) let movedIDs = Set(fuelEntries.map(\.id)) var others = favourites.filter { !movedIDs.contains($0.id) } let blockIndex = favourites.firstIndex { $0.fuel == fuel } ?? others.count others.insert(contentsOf: fuelEntries, at: min(blockIndex, others.count)) return others } // MARK: Alerts static func loadAlertsEnabled() -> Bool { loadString(service: alertsEnabledKey) == "1" } static func saveAlertsEnabled(_ enabled: Bool) { saveString(enabled ? "1" : "0", service: alertsEnabledKey) } /// Alert trigger-radius options (in the user's display unit, mapped to km /// on selection). Mile-friendly approach distances: 1–2 city, 3 town, /// 5 default (matches the Stations-tab default), 8 motorway/long approach. /// The geofence caps here — wider circles register poorly on iOS /// (kCLErrorRegionMonitoringFailure, entry latency, battery) and the /// "approach" signal dissolves beyond ~8 miles. static let alertRadiusOptions = [1, 2, 3, 5, 8] /// "Follow search" caps the geofence at 8 miles so a 10/15-mile Stations /// search never creates huge region-monitoring circles. static let alertFollowCapKM: Double = 8 * FuelSavingsCalculator.kilometresPerMile /// The effective alert radius: the manual radius, or — when alerts follow /// the Stations-tab search — that distance (miles, converted via the /// user's unit) capped at 8 miles. static func effectiveAlertsRadiusKM(followsSearch: Bool, manualKM: Double) -> Double { guard followsSearch else { return manualKM } return min(loadDistanceUnit().toKM(Double(loadStationLimit())), alertFollowCapKM) } static func loadAlertsRadius() -> Double { if let raw = loadString(service: alertsRadiusKey), let value = Double(raw), value >= 1 { // Clamp legacy values (old options went to 20 km) to the new cap. return min(value, alertFollowCapKM) } return 3.0 } static func saveAlertsRadius(_ radius: Double) { saveString(String(radius), service: alertsRadiusKey) } /// Whether alerts mirror the Stations-tab search distance instead of the /// manual radius. Defaults to OFF so existing behaviour is unchanged. static func loadAlertsFollowsSearch() -> Bool { loadString(service: alertsFollowSearchKey) == "1" } static func saveAlertsFollowsSearch(_ enabled: Bool) { saveString(enabled ? "1" : "0", service: alertsFollowSearchKey) } // MARK: Favourite price-drop alerts static func loadFavouriteDropAlertsEnabled() -> Bool { loadString(service: favouriteDropAlertsEnabledKey) == "1" } static func saveFavouriteDropAlertsEnabled(_ enabled: Bool) { saveString(enabled ? "1" : "0", service: favouriteDropAlertsEnabledKey) } static func loadFavouriteDropAlertsFuel() -> FuelType { if let raw = loadString(service: favouriteDropAlertsFuelKey), let fuel = FuelType(rawValue: raw) { return fuel } return .e10 } static func saveFavouriteDropAlertsFuel(_ fuel: FuelType) { saveString(fuel.rawValue, service: favouriteDropAlertsFuelKey) } static func loadFavouriteAlertSnapshots() -> [FuelType: FavouriteAlertSnapshot] { if let data = keychainData(service: favouriteAlertSnapshotsKey), let stored = try? JSONDecoder().decode([String: FavouriteAlertSnapshot].self, from: data) { var result: [FuelType: FavouriteAlertSnapshot] = [:] for (key, value) in stored { if let fuel = FuelType(rawValue: key) { result[fuel] = value } } return result } return [:] } static func saveFavouriteAlertSnapshots(_ snapshots: [FuelType: FavouriteAlertSnapshot]) { let keyed = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.key.rawValue, $0.value) }) if let data = try? JSONEncoder().encode(keyed) { writeKeychain(data: data, service: favouriteAlertSnapshotsKey) } } // 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) } /// Whether the Live Activity mirrors the Stations-tab search distance /// instead of its own saved radius. Defaults to OFF so existing /// behaviour is unchanged. static func loadLiveActivityFollowsSearch() -> Bool { loadString(service: liveActivityFollowSearchKey) == "1" } static func saveLiveActivityFollowsSearch(_ enabled: Bool) { saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey) } // MARK: Refresh policy — Standard is cache-first/twice-daily; Smart keeps // the same full-dump freshness cap but may probe the tiny mirror pointer // more often in the background and only refresh the full dump if it changed. static let refreshInterval: TimeInterval = 12 * 60 * 60 static let smartProbeInterval: TimeInterval = 4 * 60 * 60 static func loadDataRefreshMode() -> DataRefreshMode { if let raw = loadString(service: dataRefreshModeKey), let mode = DataRefreshMode(rawValue: raw) { return mode } return .standard } static func saveDataRefreshMode(_ mode: DataRefreshMode) { saveString(mode.rawValue, service: dataRefreshModeKey) } 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) } static func loadLastSmartProbe() -> Date? { if let raw = loadString(service: lastSmartProbeKey), let ts = TimeInterval(raw) { return Date(timeIntervalSince1970: ts) } return nil } static func saveLastSmartProbe(_ date: Date = Date()) { saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey) } static func requestWatchRefresh(_ date: Date = Date()) { saveString(String(date.timeIntervalSince1970), service: watchRefreshRequestKey) } static func loadWatchRefreshRequest() -> Date? { if let raw = loadString(service: watchRefreshRequestKey), let ts = TimeInterval(raw) { return Date(timeIntervalSince1970: ts) } return nil } static func markWatchRefreshHandled(_ date: Date = Date()) { saveString(String(date.timeIntervalSince1970), service: watchRefreshHandledKey) } static func loadWatchRefreshHandled() -> Date? { if let raw = loadString(service: watchRefreshHandledKey), let ts = TimeInterval(raw) { return Date(timeIntervalSince1970: ts) } return nil } static var hasPendingWatchRefreshRequest: Bool { guard let requested = loadWatchRefreshRequest() else { return false } guard let handled = loadWatchRefreshHandled() else { return true } return requested > handled } // 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) } /// A short label for the offline-data banner: "15 Aug" from a GOV.UK /// `data_updated` ISO 8601 stamp (with or without fractional seconds). /// Nil when the stamp is missing or unparseable — callers then hide the /// banner rather than label data with a wrong date. static func offlineDataLabel(from stamp: String?) -> String? { guard let stamp, !stamp.isEmpty else { return nil } let withFraction = ISO8601DateFormatter() withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] var date = withFraction.date(from: stamp) if date == nil { let plain = ISO8601DateFormatter() plain.formatOptions = [.withInternetDateTime] date = plain.date(from: stamp) } guard let date else { return nil } let formatter = DateFormatter() formatter.dateFormat = "d MMM" formatter.locale = Locale(identifier: "en_GB") return formatter.string(from: date) } /// True when the cached data is fresh enough that a scheduled auto-refresh /// should be skipped (twice-a-day policy). static var isCacheFresh: Bool { isCacheFresh(now: Date()) } static func isCacheFresh(now: Date) -> Bool { guard let last = loadLastRefresh() else { return false } return now.timeIntervalSince(last) < refreshInterval } static func isSmartProbeDue(now: Date = Date()) -> Bool { guard let last = loadLastSmartProbe() else { return true } return now.timeIntervalSince(last) >= smartProbeInterval } // MARK: Onboarding — the app shows the intro screen on first launch only // (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST // (with an app-group mirror) for the same reason as favourites/distance // unit: free SideStore accounts don't provision the app-group container, // so an app-group-only flag silently fails to save AND reloads as false, // making onboarding re-appear on every launch. Keychain survives reinstall // and is shared with the extension. static func loadHasCompletedOnboarding() -> Bool { (loadString(service: onboardingCompletedKey) ?? "0") == "1" } static func saveHasCompletedOnboarding(_ completed: Bool) { saveString(completed ? "1" : "0", service: onboardingCompletedKey) } // MARK: Install identity — distinguish a genuinely fresh install (where // onboarding should replay) from later launches of the same install. /// Keychain copy of the install id (survives reinstall). private static let installIDKey = "fuelboard.installID" /// Local (app-own container) copy — wiped on reinstall, persists across /// normal launches. Free SideStore accounts have no app-group container, /// so this app-own defaults domain is the reliable "same install" signal. private static let localInstallIDKey = "fuelboard.installID.local" /// True when this is a first-ever install OR the app was just reinstalled /// (local install id missing/different from the keychain id). Seeds a fresh /// id into both stores so the next launch within the same install is not a /// "fresh install" any more. static func isFreshInstall() -> Bool { let local = UserDefaults.standard.string(forKey: localInstallIDKey) let remote = loadString(service: installIDKey) if let local, let remote, local == remote { return false } // Fresh / mismatched install (or first launch). Pattern a common id so // subsequent launches of this install are recognised as the same one. let id = UUID().uuidString UserDefaults.standard.set(id, forKey: localInstallIDKey) saveString(id, service: installIDKey) return true } /// Onboarding should present when it hasn't been completed in this install /// OR this is a freshly-installed app (so the walkthrough replays on a new /// install, e.g. after a SideStore reinstall, without re-showing on every /// ordinary launch). The install check always runs (it seeds the id) rather /// than short-circuiting, so a brand-new install is recorded before the /// user ever fills in onboarding. static func shouldShowOnboarding() -> Bool { let fresh = isFreshInstall() return !loadHasCompletedOnboarding() || fresh } // MARK: Best Value announcement (existing users who updated into it) /// Users who finished onboarding before Best Value existed are /// `completed == true, fresh == false` for good, so the first-run gate above /// never fires again for them and the feature would stay invisible unless /// they went looking in Settings. This flag drives a single-page /// announcement instead of replaying the whole flow (which would re-run /// permission pages they have already answered). /// /// Stores the announcement version SEEN, compared against /// `currentBestValueIntroVersion` below — deliberately not the app version, /// so a bugfix release never re-announces anything. static let bestValueIntroVersionKey = "fuelboard.bestValueIntroVersion" /// Bump this when the Best Value announcement should be shown again to /// everyone — e.g. the feature changes in a way worth re-explaining. Anyone /// whose stored value differs (including someone who dismissed an earlier /// announcement) sees it once more on the next launch. static let currentBestValueIntroVersion = 1 static func hasSeenBestValueIntro() -> Bool { (loadString(service: bestValueIntroVersionKey) ?? "") == String(currentBestValueIntroVersion) } static func markBestValueIntroSeen() { saveString(String(currentBestValueIntroVersion), service: bestValueIntroVersionKey) } /// Whether the standalone Best Value announcement should be offered. /// /// The rule lives here rather than inside the view so it can be unit-tested: /// a view-embedded gate can only be checked by driving a simulator through /// every combination, which is exactly how a rule like this rots. The facts /// it depends on are injectable for the same reason. /// /// It declines in four cases, each for a different reason: /// - `isSuppressed` — the first-run onboarding cover is up and must not be /// fought for the screen; /// - `onboardingDue` — a new install is about to run the full flow, which /// already contains this page (`OnboardingView.finish` also marks it seen); /// - `hasSeen` — this announcement version has already been shown; /// - the user has Best Value configured — never announce to someone who set /// it up themselves. static func shouldAnnounceBestValue(settings: FuelSavingsSettings, hasSeen: Bool = hasSeenBestValueIntro(), onboardingDue: Bool = shouldShowOnboarding(), isSuppressed: Bool = false) -> Bool { guard !isSuppressed else { return false } guard !onboardingDue else { return false } guard !hasSeen else { return false } return !settings.isConfigured } // MARK: Road distances (Apple-Maps-matched, computed by the app) /// Cached road/routed distances (metres) keyed by station ID, computed by /// the app via MapKit `MKDirections`. Stored in KEYCHAIN (survives on free /// SideStore accounts where the app-group container isn't provisioned) so /// the widget extension can read it too. Widget + Live Activity prefer /// these over straight-line haversine for the displayed distance. static let roadDistancesKey = "fuelboard.roadDistances" /// How far (metres) the cache's source location may be from the current /// user position before a cached road distance is treated as stale. static let roadDistanceOriginToleranceMeters: Double = 600 /// How far (degrees) a station's stored coordinate may drift from the pin /// a road distance was actually routed to before that cached value is /// treated as belonging to a different station. Guards against one data /// source (live fetch, bundled offline dump, or a corrected pin) serving a /// road distance that was computed for a different coordinate under the /// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but /// catches any real pin change. static let roadDistancePinToleranceDegrees: Double = 1e-4 // MARK: Accurate distances (browse-layer toggle) /// "Accurate distances". OFF (default) = the browse layer never uses a /// routed road distance: every surface shows the straight-line value marked /// "Approx.", so two devices agree exactly and the app makes no route /// requests at all. ON = routed distances are shown, but only once the whole /// search area has been routed from an anchor we are still standing near, so /// a list is never a mix of road and straight-line values. static let accurateDistancesKey = "fuelboard.accurateDistances" /// Memoised, because this is read from the DISPLAY hot path. /// /// `UserDefaults(suiteName:)` builds a fresh suite object and forces a /// synchronous cfprefsd read, and `stationDistanceDisplay` consults this /// property on every call — which the Closest sort used to make twice per /// comparison (~200k reads for the whole-country pool), so switching to /// Closest or launching with it selected stalled the main thread for seconds /// on XPC alone. Same failure class as the keychain read that once tripped /// the launch watchdog. Only this app writes the key, and it writes it /// through `setAccurateDistancesEnabled`, which keeps the memo in step; /// each PROCESS reads the stored value once (a widget extension has its own). private static var accurateDistancesMemo: Bool? static var accurateDistancesEnabled: Bool { if let memo = accurateDistancesMemo { return memo } let stored = UserDefaults(suiteName: appGroupSuite)?.bool(forKey: accurateDistancesKey) ?? false accurateDistancesMemo = stored return stored } static func setAccurateDistancesEnabled(_ enabled: Bool) { accurateDistancesMemo = enabled UserDefaults(suiteName: appGroupSuite)?.set(enabled, forKey: accurateDistancesKey) } /// Whether routed distances may be shown right now: the toggle is on AND the /// cached routes cover the whole search area from a location we are still /// near. Anything else serves straight-line + "Approx." for EVERY row, which /// is what stops a list from mixing bases (a row reading "14.6 mi" inside a /// "10 miles" list) and stops membership changing row by row as a fill lands. static func accurateDistancesReady(userLat: Double, userLng: Double) -> Bool { guard accurateDistancesEnabled else { return false } guard let cache = loadRoadDistances(), cache.complete == true else { return false } return haversineMetres(cache.sourceLat, cache.sourceLng, userLat, userLng) <= roadDistanceOriginToleranceMeters } /// How imprecise a fix must be before its distance from the route cache's /// anchor stops being read as "the user moved". The widget extension runs its /// own `requestLocation()` on a `kCLLocationAccuracyHundredMeters` manager and /// is routinely handed a coarse Wi-Fi/cell fix (or a cached one from earlier /// in the day) — hundreds of metres from the app's GPS anchor. static let roadDistanceCoarseFixAccuracyMeters: Double = 150 /// How far a coarse fix may sit from the anchor before it is taken as genuine /// travel rather than fix noise. static let roadDistanceCoarseFixRadiusMeters: Double = 3_000 /// Where a surface should read displayed distances FROM, and whether routed /// distances are usable there. /// /// Routed distances are anchored to the fix that produced them (the app's GPS /// fix). A surface in another process — the widget extension — gets its OWN /// fix, which can sit hundreds of metres from that anchor. Reading that as /// "the user moved away from the cache" downgraded EVERY row in the widget to /// straight-line + "Approx." while the app, whose fix IS the anchor, showed /// routed values: one station read differently on two faces, and refreshing /// never fixed it because each new widget fix landed outside the ring again. /// Resolve the origin once, here, so every surface labels from the same place. struct DistanceDisplayOrigin: Equatable { let lat: Double let lng: Double /// True when the caller may serve routed distances from this origin. let routed: Bool /// Which fix decided it — diagnostics only. let source: String } static func distanceDisplayOrigin(candidateLat: Double, candidateLng: Double, candidateAccuracyMeters: Double? = nil) -> DistanceDisplayOrigin { let straightLine = DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng, routed: false, source: "straightLine") guard accurateDistancesEnabled, let cache = loadRoadDistances(), cache.complete == true else { return straightLine } let candidateOffset = haversineMetres(cache.sourceLat, cache.sourceLng, candidateLat, candidateLng) if candidateOffset <= roadDistanceOriginToleranceMeters { return DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng, routed: true, source: "candidate") } // The candidate fix is outside the ring. A precise fix there means the // user really has moved, and the honest answer is the straight line from // where they are. A COARSE fix (or one that reported no accuracy) is fix // noise, as is any disagreement under a kilometre — neither is good // evidence that the routes are stale, so fall back to the app's own fix, // which is the one the cached routes were built around. let accuracy = candidateAccuracyMeters ?? -1 let fixIsCoarse = accuracy <= 0 || accuracy > roadDistanceCoarseFixAccuracyMeters guard (fixIsCoarse || candidateOffset <= 1_000), candidateOffset <= roadDistanceCoarseFixRadiusMeters, let appFix = loadLocationWithDate(), haversineMetres(cache.sourceLat, cache.sourceLng, appFix.coordinate.lat, appFix.coordinate.lng) <= roadDistanceOriginToleranceMeters, // The stored fix must belong to this cache, not predate it by hours. appFix.date.timeIntervalSince1970 >= cache.updatedAt - 30 * 60 else { return straightLine } return DistanceDisplayOrigin(lat: appFix.coordinate.lat, lng: appFix.coordinate.lng, routed: true, source: "appFix") } /// Metres between two coordinates — the same great-circle measure the /// origin/anchor tests use, exposed so a surface can report how far its fix /// sits from the anchor (diagnostics only). static func distanceMetersBetween(lat1: Double, lng1: Double, lat2: Double, lng2: Double) -> Double { haversineMetres(lat1, lng1, lat2, lng2) } private static func haversineMetres(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double { let r = 6371000.0 let dLat = (lat2 - lat1) * .pi / 180 let dLng = (lng2 - lng1) * .pi / 180 let a = sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) * sin(dLng / 2) * sin(dLng / 2) return r * 2 * atan2(sqrt(a), sqrt(1 - a)) } /// In-memory copy of the road-distance cache. /// /// `loadRoadDistances()` reads the KEYCHAIN — a synchronous `securityd` XPC /// round-trip plus a full base64+JSON decode — and `displayDistanceKM` is /// called PER STATION inside comparisons (`min(by:)`, `sorted`). Re-reading /// it on every call meant tens of thousands of synchronous XPC round-trips /// on the main thread during scene creation, which stalled past the 20 s iOS /// launch watchdog and got the app SIGKILLed (0x8BADF00D). Decode once per /// process, reuse, and refresh on save. private static var roadDistanceMemo: RoadDistanceCache? /// Distinguishes "not loaded yet" from "loaded, and there is no cache". private static var roadDistanceMemoLoaded = false static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance], complete: Bool = true) { let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng, updatedAt: Date().timeIntervalSince1970, entries: entries, complete: complete) if let data = try? JSONEncoder().encode(cache) { saveString(data.base64EncodedString(), service: roadDistancesKey) } roadDistanceMemo = cache roadDistanceMemoLoaded = true } static func loadRoadDistances() -> RoadDistanceCache? { if roadDistanceMemoLoaded { return roadDistanceMemo } let decoded: RoadDistanceCache? if let raw = loadString(service: roadDistancesKey), let data = Data(base64Encoded: raw), let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data) { decoded = cache } else { decoded = nil } roadDistanceMemo = decoded roadDistanceMemoLoaded = true return decoded } /// Cached road distance (metres) to a station from the user's location, or /// nil when not cached / the cache was built too far from where the user /// is now / the station's own pin doesn't match the coordinate that was /// routed. static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? { roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)?.meters } /// Complete out-and-back routed journey distance (metres). New caches store /// the return route separately because Apple Maps may choose a different /// return path; legacy one-way caches safely fall back to doubling outward. static func roadJourneyMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? { guard let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) else { return nil } return entry.meters + (entry.returnMeters ?? entry.meters) } /// Whether a usable routed distance is already cached for this station from /// (near enough) this origin. The routing pass uses it to spend its per-pass /// budget on stations that still need a route instead of re-routing the /// same nearest handful forever. static func hasRoadDistance(for station: FuelStation, userLat: Double, userLng: Double) -> Bool { roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) != nil } /// True only for current-format route cache entries with a separately /// measured return route. Legacy one-way entries are still usable for journey /// arithmetic (`outward × 2`) but Best Value must describe them as estimated. static func hasMeasuredReturnRoadDistance(for station: FuelStation, userLat: Double, userLng: Double) -> Bool { roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)?.returnMeters != nil } static func roadDistanceDiagnostics(for station: FuelStation, userLat: Double, userLng: Double) -> RoadDistanceDiagnostics { let displayKM = displayDistanceKM(station: station, userLat: userLat, userLng: userLng) let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) let outward = entry?.meters let returnMeters = entry?.returnMeters return RoadDistanceDiagnostics( displayDistanceKM: displayKM, displaySource: outward == nil ? .straightLine : .roadRoute, outwardMeters: outward, returnMeters: returnMeters, totalJourneyMeters: outward.map { $0 + (returnMeters ?? $0) }, hasMeasuredReturn: returnMeters != nil, hasValidCacheEntry: entry != nil ) } private static func roadDistanceEntry(for station: FuelStation, userLat: Double, userLng: Double) -> CachedRoadDistance? { guard let cache = loadRoadDistances(), let entry = cache.entries[station.id] else { return nil } // The cache is only valid near the location it was built from. let dLat = (userLat - cache.sourceLat) * .pi / 180 let dLng = (userLng - cache.sourceLng) * .pi / 180 let r = 6371000.0 let a = sin(dLat / 2) * sin(dLat / 2) + cos(cache.sourceLat * .pi / 180) * cos(userLat * .pi / 180) * sin(dLng / 2) * sin(dLng / 2) let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a)) guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil } // Pin fingerprint: never serve a routed value for a coordinate we // didn't actually route to. This is the guard that keeps embedded / // live / cached station sets from injecting each other's road metres. guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees, abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil } return entry } /// Distance (km) to display for a station: cached ROAD distance when /// available (matches Apple Maps), else straight-line haversine. static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double { stationDistanceDisplay(station: station, userLat: userLat, userLng: userLng).km } /// Distance to show for a station AND whether it came from a routed road /// distance or the straight-line fallback. Both come out of ONE cache read so /// a surface can never mark a figure "Approx." while showing a routed value /// (or the reverse) — the failure mode when the number and its label are /// computed separately. static func stationDistanceDisplay(station: FuelStation, userLat: Double, userLng: Double) -> StationDistanceDisplay { // Accurate-distances mode: a routed value is served ONLY when the whole // search area has been routed from a nearby anchor. Otherwise every row // falls back to straight-line together, so a list never mixes bases and // the radius filter (which reads this same function) always agrees with // what the rows show. if accurateDistancesEnabled, accurateDistancesReady(userLat: userLat, userLng: userLng), let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) { let returnKM = (entry.meters + (entry.returnMeters ?? entry.meters)) / 1000.0 return StationDistanceDisplay( km: entry.meters / 1000.0, isApproximate: false, returnKM: returnKM, returnIsApproximate: entry.returnMeters == nil ) } let km = station.distanceKM(to: userLat, lng2: userLng) return StationDistanceDisplay( km: km, isApproximate: true, returnKM: km * 2, returnIsApproximate: true ) } /// Radius test on the SAME distance the UI shows. Filtering straight-line /// while rows display a cached ROAD distance let a station read "14.6 mi" /// inside a list set to "within 10 miles" — both numbers were individually /// right (9.8 mi straight-line, 14.6 mi by road) but the list looked broken. /// Uncached stations still test straight-line, i.e. their displayed value, so /// nothing changes for them. static func isWithinDisplayRadius(station: FuelStation, radiusKM: Double, userLat: Double, userLng: Double) -> Bool { stationDistanceDisplay(station: station, userLat: userLat, userLng: userLng).km <= radiusKM } // MARK: Low-level keychain helpers private static func keychainData(service: String) -> Data? { let 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) { // Update in place when the item already exists. Delete-then-add cost two // round trips to securityd and briefly left the key holding no value at // all, which is half of why a setting could disagree between launches. let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, ] let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) guard status == errSecItemNotFound else { return } // Nothing stored under this service yet — the first write after the app // was deleted, or a key added since. Add it. 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) } // Widget diagnostics beacons — the widget extension writes its last // makeEntry state PER INTENT TYPE (keychain survives on free SideStore // accounts where the app-group container isn't provisioned); the app's // Debug section reads them to see whether each widget kind's timeline // actually ran and what it produced. Service key embeds the intent type // so small and medium widgets never overwrite each other. static func saveWidgetDiag(_ json: String, intentType: String) { saveString(json, service: "widget.diag.\(intentType)") } static func loadWidgetDiag(intentType: String) -> String? { loadString(service: "widget.diag.\(intentType)") } } /// Lightweight description of the distance value shown for a station and any /// usable road-route cache behind it. No user coordinates are included. struct RoadDistanceDiagnostics: Equatable { let displayDistanceKM: Double let displaySource: FuelSavingsDistanceBasis let outwardMeters: Double? let returnMeters: Double? let totalJourneyMeters: Double? let hasMeasuredReturn: Bool let hasValidCacheEntry: Bool } /// The station distance a surface should show, plus whether it came from a routed /// road distance or the straight-line fallback. The confidence travels WITH the /// value, so a label can never claim a confidence the figure doesn't have. struct StationDistanceDisplay: Equatable { let km: Double let isApproximate: Bool let returnKM: Double let returnIsApproximate: Bool init(km: Double, isApproximate: Bool, returnKM: Double? = nil, returnIsApproximate: Bool? = nil) { self.km = km self.isApproximate = isApproximate self.returnKM = returnKM ?? km * 2 self.returnIsApproximate = returnIsApproximate ?? isApproximate } /// Row/sheet label: a straight-line fallback is marked "Approx." so a figure /// that is NOT a routed road distance can never be read as one, and a cached /// road distance stays a bare figure. It updates by itself once routing lands, /// because `FuelStore.stationDistanceDisplay` re-reads the cache every call. func label(unit: DistanceUnit) -> String { let text = unit.format(km) return isApproximate ? "Approx. \(text)" : text } /// Row/sheet label that keeps the one-way station distance primary, then /// adds the independently measured or estimated out-and-back distance. func labelWithReturn(unit: DistanceUnit) -> String { "\(label(unit: unit)) (\(returnLabel(unit: unit)))" } /// The out-and-back figure on its own: "5.0 mi return" / "Approx. 5.0 mi return". /// /// Split out of `labelWithReturn` for surfaces that print the one-way and the /// return figure on SEPARATE lines (the More info sheet's distance column). Both /// figures still come from this one type, so a routed value can never be marked /// "Approx." while its return figure is not — or the other way round. func returnLabel(unit: DistanceUnit) -> String { let text = unit.format(returnKM) return returnIsApproximate ? "Approx. \(text) return" : "\(text) return" } /// VoiceOver form of `returnLabel(unit:)`. func spokenReturnLabel(unit: DistanceUnit) -> String { let text = unit.spoken(returnKM) return returnIsApproximate ? "Approximately \(text) return" : "\(text) return" } /// VoiceOver form of `label(unit:)`. func spokenLabel(unit: DistanceUnit) -> String { let text = unit.spoken(km) return isApproximate ? "Approximately \(text)" : text } /// VoiceOver form of `labelWithReturn(unit:)`. func spokenLabelWithReturn(unit: DistanceUnit) -> String { let returnText = unit.spoken(returnKM) let returnLabel = returnIsApproximate ? "Approximately \(returnText)" : returnText return "\(spokenLabel(unit: unit)), \(returnLabel) return" } } /// Cached Apple-Maps road distances for nearby stations (see /// A single cached road distance plus the station pin it was routed to. Keeping /// the pin lets `roadDistanceMeters` refuse to serve a route computed for a /// *different* coordinate under the same ID — the guard that stops embedded / /// live / cached station sets cross-contaminating the distance display. struct CachedRoadDistance: Codable { let meters: Double let lat: Double let lng: Double let returnMeters: Double? init(meters: Double, lat: Double, lng: Double, returnMeters: Double? = nil) { self.meters = meters self.lat = lat self.lng = lng self.returnMeters = returnMeters } } /// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres + /// the routed pin. struct RoadDistanceCache: Codable { let sourceLat: Double let sourceLng: Double let updatedAt: TimeInterval let entries: [String: CachedRoadDistance] /// True when `entries` covers EVERY station within /// `RoadDistanceService.maxRadiusKM` of the source — only then may the browse /// layer serve routed distances (see `FuelStore.accurateDistancesReady`). /// Absent in caches written before the accurate-distances toggle existed, /// which were 12-per-pass sets and never complete: absent = not complete. let complete: Bool? init(sourceLat: Double, sourceLng: Double, updatedAt: TimeInterval, entries: [String: CachedRoadDistance], complete: Bool? = nil) { self.sourceLat = sourceLat self.sourceLng = sourceLng self.updatedAt = updatedAt self.entries = entries self.complete = complete } }