// 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" } } /// 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) * 1.60934).rounded()) } /// 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 = ["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 alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance 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 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) } } /// 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 * 1.60934 /// 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: 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 — 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) } // 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)") } }