ImportExportSettingsViewModel.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. // LoopFollow
  2. // ImportExportSettingsViewModel.swift
  3. import Foundation
  4. import SwiftUI
  5. struct ImportPreview {
  6. let nightscoutURL: String?
  7. let dexcomUsername: String?
  8. let remoteType: String?
  9. let alarmCount: Int
  10. let alarmNames: [String]
  11. }
  12. class ImportExportSettingsViewModel: ObservableObject {
  13. // MARK: - Published Properties
  14. @Published var isShowingQRCodeScanner = false
  15. @Published var isShowingQRCodeDisplay = false
  16. @Published var isShowingAlarmSelection = false
  17. @Published var qrCodeErrorMessage = ""
  18. @Published var qrCodeString = ""
  19. @Published var exportType: ExportType = .nightscout
  20. @Published var exportedAlarmIds: Set<UUID> = []
  21. @Published var importPreview: ImportPreview?
  22. @Published var showImportConfirmation = false
  23. @Published var pendingImportSettings: CombinedSettingsExport?
  24. @Published var pendingImportSource: String = ""
  25. @Published var showExportSuccessAlert = false
  26. @Published var exportSuccessMessage = ""
  27. @Published var exportSuccessDetails: [String] = []
  28. @Published var showImportNotFoundAlert = false
  29. @Published var importNotFoundMessage = ""
  30. // MARK: - Export Types
  31. enum ExportType: String, CaseIterable {
  32. case nightscout = "Nightscout Settings"
  33. case remote = "Remote Settings"
  34. case alarms = "Alarm Settings"
  35. var icon: String {
  36. switch self {
  37. case .nightscout: return "network"
  38. case .remote: return "antenna.radiowaves.left.and.right"
  39. case .alarms: return "bell"
  40. }
  41. }
  42. }
  43. // MARK: - QR Code Methods
  44. func handleQRCodeScanResult(_ result: Result<String, Error>) {
  45. DispatchQueue.main.async {
  46. switch result {
  47. case let .success(jsonString):
  48. self.processImportedSettings(jsonString)
  49. case let .failure(error):
  50. self.qrCodeErrorMessage = "Scanning failed: \(error.localizedDescription)"
  51. }
  52. self.isShowingQRCodeScanner = false
  53. }
  54. }
  55. private func processImportedSettings(_ jsonString: String) {
  56. do {
  57. LogManager.shared.log(category: .general, message: "Processing QR code data: \(jsonString.prefix(200))...")
  58. guard let data = jsonString.data(using: .utf8) else {
  59. qrCodeErrorMessage = "Invalid QR code data"
  60. return
  61. }
  62. LogManager.shared.log(category: .general, message: "QR code data converted to Data, size: \(data.count) bytes")
  63. // Try to decode as JSON first to see what we get
  64. do {
  65. let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
  66. LogManager.shared.log(category: .general, message: "JSON parsing successful: \(jsonObject)")
  67. } catch {
  68. LogManager.shared.log(category: .general, message: "JSON parsing failed: \(error.localizedDescription)")
  69. }
  70. // Use migration manager to handle version compatibility
  71. guard let settings = SettingsMigrationManager.migrateSettings(data) else {
  72. LogManager.shared.log(category: .general, message: "SettingsMigrationManager.migrateSettings returned nil")
  73. qrCodeErrorMessage = "Failed to decode or migrate settings from QR code"
  74. return
  75. }
  76. LogManager.shared.log(category: .general, message: "QR code decoded successfully. Components: nightscout=\(settings.nightscout != nil), remote=\(settings.remote != nil), alarms=\(settings.alarms != nil)")
  77. // Check version compatibility
  78. let currentVersion = AppVersionManager().version()
  79. if !SettingsMigrationManager.isCompatibleVersion(settings.appVersion) {
  80. qrCodeErrorMessage = SettingsMigrationManager.getCompatibilityMessage(for: settings.appVersion)
  81. // Still try to apply settings, but warn user
  82. }
  83. // Store settings and create preview for confirmation
  84. pendingImportSettings = settings
  85. pendingImportSource = "QR code"
  86. createImportPreview(from: settings)
  87. } catch {
  88. let currentVersion = AppVersionManager().version()
  89. qrCodeErrorMessage = "Import failed. This might be due to a version change (current: \(currentVersion)). Please try exporting settings from the source device again."
  90. LogManager.shared.log(category: .general, message: "QR code import failed: \(error.localizedDescription)")
  91. }
  92. }
  93. private func applyImportedSettings(_ settings: CombinedSettingsExport, source: String) throws {
  94. var importedComponents: [String] = []
  95. // Apply settings based on what's available
  96. if let nightscout = settings.nightscout {
  97. // Check if Nightscout settings are already configured
  98. let currentNightscout = NightscoutSettingsExport.fromCurrentStorage()
  99. if currentNightscout.hasValidSettings() {
  100. // Nightscout is already configured, warn user about overwrite
  101. LogManager.shared.log(category: .general, message: "Warning: Nightscout settings are already configured. Import will overwrite existing Nightscout settings.")
  102. }
  103. nightscout.applyToStorage()
  104. importedComponents.append("Nightscout settings")
  105. LogManager.shared.log(category: .general, message: "Nightscout settings imported from \(source) (version: \(nightscout.version))")
  106. }
  107. if let dexcom = settings.dexcom {
  108. // Check if Dexcom settings are already configured
  109. let currentDexcom = DexcomSettingsExport.fromCurrentStorage()
  110. if currentDexcom.hasValidSettings() {
  111. LogManager.shared.log(category: .general, message: "Warning: Dexcom settings are already configured. Import will overwrite existing Dexcom settings.")
  112. }
  113. dexcom.applyToStorage()
  114. importedComponents.append("Dexcom settings")
  115. LogManager.shared.log(category: .general, message: "Dexcom settings imported from \(source) (version: \(dexcom.version))")
  116. }
  117. if let remote = settings.remote {
  118. if remote.hasValidSettings() {
  119. let validation = remote.validateCompatibilityWithCurrentStorage()
  120. if validation.isCompatible {
  121. remote.applyToStorage()
  122. importedComponents.append("Remote settings (\(remote.remoteType.rawValue))")
  123. LogManager.shared.log(category: .general, message: "Remote settings imported from \(source) (version: \(remote.version), type: \(remote.remoteType.rawValue), device: \(remote.device))")
  124. } else {
  125. throw NSError(domain: "SettingsImport", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote settings conflict: \(validation.message)"])
  126. }
  127. } else {
  128. throw NSError(domain: "SettingsImport", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid remote settings in \(source)"])
  129. }
  130. }
  131. if let alarms = settings.alarms {
  132. LogManager.shared.log(category: .general, message: "Attempting to import alarm settings: \(alarms.alarms.count) alarms")
  133. alarms.applyToStorage()
  134. importedComponents.append("Alarm settings (\(alarms.alarms.count) alarms)")
  135. LogManager.shared.log(category: .general, message: "Alarm settings imported from \(source) (version: \(alarms.version), \(alarms.alarms.count) alarms)")
  136. }
  137. // Update the success message with what was imported
  138. if !importedComponents.isEmpty {
  139. let componentsList = importedComponents.joined(separator: ", ")
  140. qrCodeErrorMessage = "Successfully imported: \(componentsList)"
  141. }
  142. }
  143. func generateQRCodeForExport() -> String? {
  144. let settings: CombinedSettingsExport?
  145. switch exportType {
  146. case .nightscout:
  147. let nightscoutSettings = NightscoutSettingsExport.fromCurrentStorage()
  148. if !nightscoutSettings.hasValidSettings() {
  149. qrCodeErrorMessage = "Please configure your Nightscout settings first (URL and Token)"
  150. return nil
  151. }
  152. settings = CombinedSettingsExport(
  153. nightscout: nightscoutSettings,
  154. exportType: exportType.rawValue
  155. )
  156. case .remote:
  157. let remoteSettings = RemoteSettingsExport.fromCurrentStorage()
  158. if !remoteSettings.hasValidSettings() {
  159. let currentRemoteType = Storage.shared.remoteType.value
  160. if currentRemoteType == .none {
  161. qrCodeErrorMessage = "Please configure your Remote settings first (select a remote type and configure required fields)"
  162. } else {
  163. qrCodeErrorMessage = "Please complete your Remote settings configuration (check required fields for \(currentRemoteType.rawValue))"
  164. }
  165. return nil
  166. }
  167. settings = CombinedSettingsExport(
  168. remote: remoteSettings,
  169. exportType: exportType.rawValue
  170. )
  171. case .alarms:
  172. let alarmSettings = AlarmSettingsExport.fromCurrentStorage()
  173. LogManager.shared.log(category: .general, message: "Generating alarm export: \(alarmSettings.alarms.count) alarms")
  174. if !alarmSettings.hasValidSettings() {
  175. qrCodeErrorMessage = "Please configure your Alarm settings first"
  176. return nil
  177. }
  178. settings = CombinedSettingsExport(
  179. alarms: alarmSettings,
  180. exportType: exportType.rawValue
  181. )
  182. }
  183. return settings?.encodeToJSON()
  184. }
  185. func showAlarmSelection() {
  186. exportType = .alarms
  187. isShowingAlarmSelection = true
  188. }
  189. func exportSelectedAlarms(_ selectedAlarms: [Alarm]) {
  190. let settings = AlarmSettingsExport.fromSelectedAlarms(selectedAlarms)
  191. if let qrString = settings.encodeToJSON() {
  192. qrCodeString = qrString
  193. isShowingQRCodeDisplay = true
  194. // Track which alarms were exported
  195. let exportedIds = Set(selectedAlarms.map { $0.id })
  196. exportedAlarmIds.formUnion(exportedIds)
  197. }
  198. isShowingAlarmSelection = false
  199. }
  200. func cancelAlarmSelection() {
  201. isShowingAlarmSelection = false
  202. }
  203. func resetExportedAlarms() {
  204. exportedAlarmIds.removeAll()
  205. }
  206. // MARK: - iCloud Methods
  207. /// Returns the iCloud container URL for persistent storage (survives app deletion)
  208. /// Falls back to local Documents if iCloud is not available
  209. private func getICloudContainerURL() -> URL? {
  210. if let iCloudURL = FileManager.default.url(forUbiquityContainerIdentifier: nil) {
  211. let documentsURL = iCloudURL.appendingPathComponent("Documents")
  212. if !FileManager.default.fileExists(atPath: documentsURL.path) {
  213. try? FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true)
  214. }
  215. return documentsURL
  216. }
  217. return nil
  218. }
  219. func exportToiCloud() {
  220. // Create a comprehensive settings export for iCloud
  221. let nightscoutSettings = NightscoutSettingsExport.fromCurrentStorage()
  222. let dexcomSettings = DexcomSettingsExport.fromCurrentStorage()
  223. let remoteSettings = RemoteSettingsExport.fromCurrentStorage()
  224. let alarmSettings = AlarmSettingsExport.fromCurrentStorage()
  225. let allSettings = CombinedSettingsExport(
  226. nightscout: nightscoutSettings,
  227. dexcom: dexcomSettings,
  228. remote: remoteSettings,
  229. alarms: alarmSettings,
  230. exportType: "All Settings"
  231. )
  232. guard let jsonData = allSettings.encodeToJSON()?.data(using: .utf8) else {
  233. qrCodeErrorMessage = "Failed to prepare settings for iCloud export"
  234. return
  235. }
  236. guard let iCloudDocumentsPath = getICloudContainerURL() else {
  237. qrCodeErrorMessage = "iCloud is not available. Please sign in to iCloud in Settings and enable iCloud Drive."
  238. LogManager.shared.log(category: .general, message: "iCloud ubiquity container not available")
  239. return
  240. }
  241. let fileName = "\(AppConstants.appInstanceId)Settings.json"
  242. let iCloudPath = iCloudDocumentsPath.appendingPathComponent(fileName)
  243. LogManager.shared.log(category: .general, message: "Attempting to export settings to iCloud")
  244. LogManager.shared.log(category: .general, message: "iCloud ubiquity container path: \(iCloudDocumentsPath.path)")
  245. LogManager.shared.log(category: .general, message: "Saving settings file to: \(iCloudPath.path)")
  246. do {
  247. try jsonData.write(to: iCloudPath)
  248. // Build export details for the success alert - show what's being exported
  249. var details: [String] = []
  250. // Nightscout - show if URL is configured
  251. if !nightscoutSettings.url.isEmpty {
  252. details.append("Nightscout: \(nightscoutSettings.url)")
  253. }
  254. // Dexcom - show if username is configured
  255. if !dexcomSettings.userName.isEmpty {
  256. details.append("Dexcom: \(dexcomSettings.userName)")
  257. }
  258. // Remote - only show if a remote type is actually configured (not "none")
  259. if remoteSettings.remoteType != .none {
  260. details.append("Remote: \(remoteSettings.remoteType.rawValue)")
  261. }
  262. // Alarms - show if any alarms exist
  263. if !alarmSettings.alarms.isEmpty {
  264. details.append("Alarms: \(alarmSettings.alarms.count) alarm(s)")
  265. }
  266. exportSuccessDetails = details
  267. exportSuccessMessage = "Settings saved to iCloud"
  268. showExportSuccessAlert = true
  269. qrCodeErrorMessage = "" // Clear any previous error
  270. LogManager.shared.log(category: .general, message: "All settings exported to iCloud successfully")
  271. } catch {
  272. qrCodeErrorMessage = "Failed to export to iCloud: \(error.localizedDescription)"
  273. }
  274. }
  275. func importFromiCloud() {
  276. // Try to read from iCloud ubiquity container (persists after app deletion)
  277. guard let iCloudDocumentsPath = getICloudContainerURL() else {
  278. importNotFoundMessage = "iCloud is not available.\n\nPlease sign in to iCloud in Settings and enable iCloud Drive."
  279. showImportNotFoundAlert = true
  280. LogManager.shared.log(category: .general, message: "iCloud ubiquity container not available for import")
  281. return
  282. }
  283. let fileName = "\(AppConstants.appInstanceId)Settings.json"
  284. let iCloudPath = iCloudDocumentsPath.appendingPathComponent(fileName)
  285. LogManager.shared.log(category: .general, message: "Attempting to import settings from iCloud")
  286. LogManager.shared.log(category: .general, message: "iCloud ubiquity container path: \(iCloudDocumentsPath.path)")
  287. LogManager.shared.log(category: .general, message: "Looking for settings file: \(iCloudPath.path)")
  288. guard FileManager.default.fileExists(atPath: iCloudPath.path) else {
  289. LogManager.shared.log(category: .general, message: "Settings file not found at: \(iCloudPath.path)")
  290. importNotFoundMessage = "No settings file found in iCloud.\n\nMake sure you have previously exported settings to iCloud from this app."
  291. showImportNotFoundAlert = true
  292. return
  293. }
  294. do {
  295. LogManager.shared.log(category: .general, message: "Settings file found, attempting to read data")
  296. let data = try Data(contentsOf: iCloudPath)
  297. guard let settings = SettingsMigrationManager.migrateSettings(data) else {
  298. qrCodeErrorMessage = "Failed to decode settings from iCloud"
  299. return
  300. }
  301. // Check version compatibility
  302. let currentVersion = AppVersionManager().version()
  303. if !SettingsMigrationManager.isCompatibleVersion(settings.appVersion) {
  304. qrCodeErrorMessage = SettingsMigrationManager.getCompatibilityMessage(for: settings.appVersion)
  305. // Still try to apply settings, but warn user
  306. }
  307. // Store settings and create preview for confirmation
  308. pendingImportSettings = settings
  309. pendingImportSource = "iCloud"
  310. createImportPreview(from: settings)
  311. } catch {
  312. let currentVersion = AppVersionManager().version()
  313. qrCodeErrorMessage = "iCloud import failed. This might be due to a version change (current: \(currentVersion)). Please try exporting settings to iCloud again."
  314. LogManager.shared.log(category: .general, message: "iCloud import failed: \(error.localizedDescription)")
  315. }
  316. }
  317. private func createImportPreview(from settings: CombinedSettingsExport) {
  318. let nightscoutURL = settings.nightscout?.url.isEmpty == false ? settings.nightscout?.url : nil
  319. let dexcomUsername = settings.dexcom?.userName.isEmpty == false ? settings.dexcom?.userName : nil
  320. let remoteType = settings.remote?.remoteType != .none ? settings.remote?.remoteType.rawValue : nil
  321. let alarmCount = settings.alarms?.alarms.count ?? 0
  322. let alarmNames = settings.alarms?.alarms.map { $0.name } ?? []
  323. // Check if any settings are actually present
  324. let hasAnySettings = (nightscoutURL != nil && !nightscoutURL!.isEmpty) ||
  325. (dexcomUsername != nil && !dexcomUsername!.isEmpty) ||
  326. (remoteType != nil && !remoteType!.isEmpty && remoteType != "None") ||
  327. alarmCount > 0
  328. LogManager.shared.log(category: .general, message: "Import preview check - nightscoutURL: \(nightscoutURL ?? "nil"), dexcomUsername: \(dexcomUsername ?? "nil"), remoteType: \(remoteType ?? "nil"), alarmCount: \(alarmCount), hasAnySettings: \(hasAnySettings)")
  329. if hasAnySettings {
  330. LogManager.shared.log(category: .general, message: "Creating import preview with settings")
  331. importPreview = ImportPreview(
  332. nightscoutURL: nightscoutURL,
  333. dexcomUsername: dexcomUsername,
  334. remoteType: remoteType,
  335. alarmCount: alarmCount,
  336. alarmNames: alarmNames
  337. )
  338. LogManager.shared.log(category: .general, message: "Created importPreview - nightscoutURL: \(importPreview?.nightscoutURL ?? "nil"), remoteType: \(importPreview?.remoteType ?? "nil"), alarmCount: \(importPreview?.alarmCount ?? 0)")
  339. showImportConfirmation = true
  340. LogManager.shared.log(category: .general, message: "Set showImportConfirmation = true")
  341. } else {
  342. LogManager.shared.log(category: .general, message: "No settings found, clearing import data")
  343. // No settings found, show alert and clear any pending data
  344. importNotFoundMessage = "The settings file exists but contains no valid settings to import."
  345. showImportNotFoundAlert = true
  346. showImportConfirmation = false
  347. importPreview = nil
  348. pendingImportSettings = nil
  349. pendingImportSource = ""
  350. LogManager.shared.log(category: .general, message: "Set showImportConfirmation = false")
  351. }
  352. }
  353. func confirmImport() {
  354. guard let settings = pendingImportSettings else { return }
  355. do {
  356. try applyImportedSettings(settings, source: pendingImportSource)
  357. } catch {
  358. qrCodeErrorMessage = "Import failed: \(error.localizedDescription)"
  359. }
  360. // Reset confirmation state
  361. showImportConfirmation = false
  362. importPreview = nil
  363. pendingImportSettings = nil
  364. pendingImportSource = ""
  365. }
  366. func cancelImport() {
  367. showImportConfirmation = false
  368. importPreview = nil
  369. pendingImportSettings = nil
  370. pendingImportSource = ""
  371. }
  372. }