ExportableSettings.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. // LoopFollow
  2. // ExportableSettings.swift
  3. import Foundation
  4. import HealthKit
  5. // MARK: - Nightscout Settings Export
  6. struct NightscoutSettingsExport: Codable {
  7. let version: String
  8. let url: String
  9. let token: String
  10. let units: String
  11. let glycemicMetricMode: String
  12. let glycemicOutputUnit: String
  13. let timeInRangeMode: String
  14. let lowLine: Double?
  15. let highLine: Double?
  16. let variabilityMetricMode: String
  17. enum CodingKeys: String, CodingKey {
  18. case version
  19. case url
  20. case token
  21. case units
  22. case glycemicMetricMode
  23. case glycemicOutputUnit
  24. case timeInRangeMode
  25. case lowLine
  26. case highLine
  27. case variabilityMetricMode
  28. }
  29. init(
  30. version: String,
  31. url: String,
  32. token: String,
  33. units: String,
  34. glycemicMetricMode: String,
  35. glycemicOutputUnit: String,
  36. timeInRangeMode: String,
  37. lowLine: Double?,
  38. highLine: Double?,
  39. variabilityMetricMode: String
  40. ) {
  41. self.version = version
  42. self.url = url
  43. self.token = token
  44. self.units = units
  45. self.glycemicMetricMode = glycemicMetricMode
  46. self.glycemicOutputUnit = glycemicOutputUnit
  47. self.timeInRangeMode = timeInRangeMode
  48. self.lowLine = lowLine
  49. self.highLine = highLine
  50. self.variabilityMetricMode = variabilityMetricMode
  51. }
  52. init(from decoder: Decoder) throws {
  53. let container = try decoder.container(keyedBy: CodingKeys.self)
  54. version = try container.decode(String.self, forKey: .version)
  55. url = try container.decode(String.self, forKey: .url)
  56. token = try container.decode(String.self, forKey: .token)
  57. units = try container.decodeIfPresent(String.self, forKey: .units) ?? GlucoseDisplayUnit.mgdL.rawValue
  58. glycemicMetricMode = try container.decodeIfPresent(String.self, forKey: .glycemicMetricMode) ?? GlycemicMetricMode.gmi.rawValue
  59. glycemicOutputUnit = try container.decodeIfPresent(String.self, forKey: .glycemicOutputUnit) ?? GlycemicOutputUnit.percent.rawValue
  60. timeInRangeMode = try container.decodeIfPresent(String.self, forKey: .timeInRangeMode) ?? TimeInRangeDisplayMode.tir.rawValue
  61. lowLine = try container.decodeIfPresent(Double.self, forKey: .lowLine)
  62. highLine = try container.decodeIfPresent(Double.self, forKey: .highLine)
  63. variabilityMetricMode = try container.decodeIfPresent(String.self, forKey: .variabilityMetricMode) ?? VariabilityMetricMode.stdDeviation.rawValue
  64. }
  65. static func fromCurrentStorage() -> NightscoutSettingsExport {
  66. let storage = Storage.shared
  67. let unitSettings = UnitSettingsStore.shared
  68. return NightscoutSettingsExport(
  69. version: AppVersionManager().version(),
  70. url: storage.url.value,
  71. token: storage.token.value,
  72. units: storage.units.value,
  73. glycemicMetricMode: unitSettings.glycemicMetricMode.rawValue,
  74. glycemicOutputUnit: unitSettings.glycemicOutputUnit.rawValue,
  75. timeInRangeMode: unitSettings.timeInRangeMode.rawValue,
  76. lowLine: storage.lowLine.value,
  77. highLine: storage.highLine.value,
  78. variabilityMetricMode: unitSettings.variabilityMetricMode.rawValue
  79. )
  80. }
  81. func applyToStorage() {
  82. let storage = Storage.shared
  83. storage.url.value = url
  84. storage.token.value = token
  85. storage.units.value = units
  86. if let glycemicMetricMode = GlycemicMetricMode(rawValue: glycemicMetricMode) {
  87. UnitSettingsStore.shared.glycemicMetricMode = glycemicMetricMode
  88. }
  89. if let glycemicOutputUnit = GlycemicOutputUnit(rawValue: glycemicOutputUnit) {
  90. UnitSettingsStore.shared.glycemicOutputUnit = glycemicOutputUnit
  91. }
  92. if let timeInRangeMode = TimeInRangeDisplayMode(rawValue: timeInRangeMode) {
  93. UnitSettingsStore.shared.timeInRangeMode = timeInRangeMode
  94. }
  95. if let lowLine = lowLine {
  96. storage.lowLine.value = lowLine
  97. }
  98. if let highLine = highLine {
  99. storage.highLine.value = highLine
  100. }
  101. if let variabilityMetricMode = VariabilityMetricMode(rawValue: variabilityMetricMode) {
  102. UnitSettingsStore.shared.variabilityMetricMode = variabilityMetricMode
  103. }
  104. }
  105. func encodeToJSON() -> String? {
  106. do {
  107. let data = try JSONEncoder().encode(self)
  108. return String(data: data, encoding: .utf8)
  109. } catch {
  110. return nil
  111. }
  112. }
  113. func hasValidSettings() -> Bool {
  114. return !url.isEmpty && !token.isEmpty
  115. }
  116. }
  117. // MARK: - Dexcom Settings Export
  118. struct DexcomSettingsExport: Codable {
  119. let version: String
  120. let userName: String
  121. let password: String
  122. let server: String
  123. static func fromCurrentStorage() -> DexcomSettingsExport {
  124. let storage = Storage.shared
  125. return DexcomSettingsExport(
  126. version: AppVersionManager().version(),
  127. userName: storage.shareUserName.value,
  128. password: storage.sharePassword.value,
  129. server: storage.shareServer.value
  130. )
  131. }
  132. func applyToStorage() {
  133. let storage = Storage.shared
  134. storage.shareUserName.value = userName
  135. storage.sharePassword.value = password
  136. storage.shareServer.value = server
  137. }
  138. func encodeToJSON() -> String? {
  139. do {
  140. let data = try JSONEncoder().encode(self)
  141. return String(data: data, encoding: .utf8)
  142. } catch {
  143. return nil
  144. }
  145. }
  146. func hasValidSettings() -> Bool {
  147. return !userName.isEmpty && !password.isEmpty
  148. }
  149. }
  150. // MARK: - Alarm Settings Export
  151. struct AlarmSettingsExport: Codable {
  152. let version: String
  153. let alarms: [Alarm]
  154. let alarmConfiguration: AlarmConfiguration
  155. static func fromCurrentStorage() -> AlarmSettingsExport {
  156. let storage = Storage.shared
  157. return AlarmSettingsExport(
  158. version: AppVersionManager().version(),
  159. alarms: storage.alarms.value,
  160. alarmConfiguration: storage.alarmConfiguration.value
  161. )
  162. }
  163. static func fromSelectedAlarms(_ selectedAlarms: [Alarm]) -> AlarmSettingsExport {
  164. let storage = Storage.shared
  165. return AlarmSettingsExport(
  166. version: AppVersionManager().version(),
  167. alarms: selectedAlarms,
  168. alarmConfiguration: storage.alarmConfiguration.value
  169. )
  170. }
  171. func applyToStorage() {
  172. let storage = Storage.shared
  173. // When importing, merge with existing alarms instead of replacing
  174. var existingAlarms = storage.alarms.value
  175. var updatedAlarms: [Alarm] = []
  176. // Keep existing alarms that aren't being imported
  177. for existingAlarm in existingAlarms {
  178. if !alarms.contains(where: { $0.id == existingAlarm.id }) {
  179. updatedAlarms.append(existingAlarm)
  180. }
  181. }
  182. // Add imported alarms
  183. updatedAlarms.append(contentsOf: alarms)
  184. storage.alarms.value = updatedAlarms
  185. storage.alarmConfiguration.value = alarmConfiguration
  186. }
  187. func encodeToJSON() -> String? {
  188. do {
  189. let data = try JSONEncoder().encode(self)
  190. return String(data: data, encoding: .utf8)
  191. } catch {
  192. return nil
  193. }
  194. }
  195. func hasValidSettings() -> Bool {
  196. return !alarms.isEmpty
  197. }
  198. }
  199. // MARK: - Remote Settings Export
  200. struct RemoteSettingsExport: Codable {
  201. let version: String
  202. let remoteType: RemoteType
  203. let user: String
  204. let sharedSecret: String
  205. let remoteApnsKey: String
  206. let remoteKeyId: String
  207. let teamId: String?
  208. let maxBolus: Double
  209. let maxCarbs: Double
  210. let maxProtein: Double
  211. let maxFat: Double
  212. let mealWithBolus: Bool
  213. let mealWithFatProtein: Bool
  214. let productionEnvironment: Bool
  215. let loopAPNSQrCodeURL: String
  216. let device: String
  217. static func fromCurrentStorage() -> RemoteSettingsExport {
  218. let storage = Storage.shared
  219. return RemoteSettingsExport(
  220. version: AppVersionManager().version(),
  221. remoteType: storage.remoteType.value,
  222. user: storage.user.value,
  223. sharedSecret: storage.sharedSecret.value,
  224. remoteApnsKey: storage.remoteApnsKey.value,
  225. remoteKeyId: storage.remoteKeyId.value,
  226. teamId: storage.teamId.value,
  227. maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()),
  228. maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()),
  229. maxProtein: storage.maxProtein.value.doubleValue(for: .gram()),
  230. maxFat: storage.maxFat.value.doubleValue(for: .gram()),
  231. mealWithBolus: storage.mealWithBolus.value,
  232. mealWithFatProtein: storage.mealWithFatProtein.value,
  233. productionEnvironment: storage.productionEnvironment.value,
  234. loopAPNSQrCodeURL: storage.loopAPNSQrCodeURL.value,
  235. device: storage.device.value
  236. )
  237. }
  238. func applyToStorage() {
  239. let storage = Storage.shared
  240. storage.remoteType.value = remoteType
  241. storage.user.value = user
  242. storage.sharedSecret.value = sharedSecret
  243. storage.remoteApnsKey.value = remoteApnsKey
  244. storage.remoteKeyId.value = remoteKeyId
  245. storage.teamId.value = teamId
  246. storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus)
  247. storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs)
  248. storage.maxProtein.value = HKQuantity(unit: .gram(), doubleValue: maxProtein)
  249. storage.maxFat.value = HKQuantity(unit: .gram(), doubleValue: maxFat)
  250. storage.mealWithBolus.value = mealWithBolus
  251. storage.mealWithFatProtein.value = mealWithFatProtein
  252. storage.productionEnvironment.value = productionEnvironment
  253. storage.loopAPNSQrCodeURL.value = loopAPNSQrCodeURL
  254. // Set device temporarily from import (will be overridden by Nightscout connection)
  255. if !device.isEmpty {
  256. storage.device.value = device
  257. } else {
  258. // Fallback to automatic device type based on remote type
  259. switch remoteType {
  260. case .loopAPNS:
  261. storage.device.value = "Loop"
  262. case .trc:
  263. storage.device.value = "Trio"
  264. case .nightscout:
  265. // For Nightscout, we don't automatically set device type
  266. // as it should be determined by the actual connection
  267. break
  268. case .none:
  269. break
  270. }
  271. }
  272. }
  273. func encodeToJSON() -> String? {
  274. do {
  275. let data = try JSONEncoder().encode(self)
  276. return String(data: data, encoding: .utf8)
  277. } catch {
  278. return nil
  279. }
  280. }
  281. func hasValidSettings() -> Bool {
  282. switch remoteType {
  283. case .none:
  284. return true
  285. case .nightscout:
  286. return !user.isEmpty
  287. case .trc:
  288. return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty
  289. case .loopAPNS:
  290. return !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty
  291. }
  292. }
  293. /// Validates compatibility with current storage settings
  294. func validateCompatibilityWithCurrentStorage() -> (isCompatible: Bool, shouldPromptForURL: Bool, shouldPromptForToken: Bool, message: String) {
  295. let storage = Storage.shared
  296. var message = ""
  297. var shouldPromptForURL = false
  298. var shouldPromptForToken = false
  299. // Check if there are existing remote settings
  300. let currentRemoteType = storage.remoteType.value
  301. let currentUser = storage.user.value
  302. // If remote type is changing, warn user
  303. if currentRemoteType != .none && currentRemoteType != remoteType {
  304. message += "Remote type is changing from \(currentRemoteType.rawValue) to \(remoteType.rawValue). This may affect your remote commands.\n"
  305. }
  306. // If user is changing, warn user
  307. if !currentUser.isEmpty && currentUser != user {
  308. message += "Remote user is changing from '\(currentUser)' to '\(user)'. This may affect your remote commands.\n"
  309. }
  310. // For TRC and LoopAPNS, check if key details are changing
  311. if remoteType == .trc || remoteType == .loopAPNS {
  312. let currentKeyId = storage.remoteKeyId.value
  313. let currentApnsKey = storage.remoteApnsKey.value
  314. if !currentKeyId.isEmpty, currentKeyId != remoteKeyId {
  315. message += "APNS Key ID is changing. This may affect your remote commands.\n"
  316. }
  317. if !currentApnsKey.isEmpty, currentApnsKey != remoteApnsKey {
  318. message += "APNS Key is changing. This may affect your remote commands.\n"
  319. }
  320. }
  321. // For TRC, check shared secret
  322. if remoteType == .trc {
  323. let currentSharedSecret = storage.sharedSecret.value
  324. if !currentSharedSecret.isEmpty, currentSharedSecret != sharedSecret {
  325. message += "Shared secret is changing. This may affect your remote commands.\n"
  326. }
  327. }
  328. // For LoopAPNS, check team ID and QR code URL
  329. if remoteType == .loopAPNS {
  330. let currentTeamId = storage.teamId.value
  331. let currentQrCodeURL = storage.loopAPNSQrCodeURL.value
  332. if let teamId = teamId, let currentTeamId = currentTeamId, teamId != currentTeamId {
  333. message += "Team ID is changing. This may affect your remote commands.\n"
  334. }
  335. if !currentQrCodeURL.isEmpty, currentQrCodeURL != loopAPNSQrCodeURL {
  336. message += "Loop APNS QR Code URL is changing. This may affect your remote commands.\n"
  337. }
  338. }
  339. // If both have tokens but they don't match, show warning
  340. let hasCurrentToken = !storage.token.value.isEmpty
  341. if hasCurrentToken {
  342. message += "Note: This import does not include Nightscout token settings. Your current Nightscout token will be preserved.\n"
  343. }
  344. let isCompatible = !shouldPromptForURL && !shouldPromptForToken
  345. return (isCompatible, shouldPromptForURL, shouldPromptForToken, message)
  346. }
  347. }
  348. // MARK: - Combined Settings Export
  349. struct CombinedSettingsExport: Codable {
  350. let version: String
  351. let appVersion: String
  352. let nightscout: NightscoutSettingsExport?
  353. let dexcom: DexcomSettingsExport?
  354. let remote: RemoteSettingsExport?
  355. let alarms: AlarmSettingsExport?
  356. let exportType: String
  357. let timestamp: Date
  358. init(nightscout: NightscoutSettingsExport? = nil,
  359. dexcom: DexcomSettingsExport? = nil,
  360. remote: RemoteSettingsExport? = nil,
  361. alarms: AlarmSettingsExport? = nil,
  362. exportType: String)
  363. {
  364. version = "1.0"
  365. appVersion = AppVersionManager().version()
  366. self.nightscout = nightscout
  367. self.dexcom = dexcom
  368. self.remote = remote
  369. self.alarms = alarms
  370. self.exportType = exportType
  371. timestamp = Date()
  372. }
  373. func encodeToJSON() -> String? {
  374. do {
  375. let data = try JSONEncoder().encode(self)
  376. return String(data: data, encoding: .utf8)
  377. } catch {
  378. return nil
  379. }
  380. }
  381. static func decodeFromJSON(_ jsonString: String) -> CombinedSettingsExport? {
  382. guard let data = jsonString.data(using: .utf8) else { return nil }
  383. do {
  384. return try JSONDecoder().decode(CombinedSettingsExport.self, from: data)
  385. } catch {
  386. return nil
  387. }
  388. }
  389. }