SettingsExportStateModel.swift 67 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323
  1. import CoreData
  2. import Foundation
  3. import LoopKit
  4. import SwiftUI
  5. import Swinject
  6. extension SettingsExport {
  7. final class StateModel: BaseStateModel<Provider> {
  8. @Injected() private var broadcaster: Broadcaster!
  9. @Injected() private var fileManager: FileManager!
  10. @Injected() private var storage: FileStorage!
  11. @Injected() var overrideStorage: OverrideStorage!
  12. @Injected() var tempTargetsStorage: TempTargetsStorage!
  13. // Help Sheet
  14. var isHelpSheetPresented: Bool = false
  15. var helpSheetDetent = PresentationDetent.large
  16. // Version information
  17. private var versionNumber: String = ""
  18. private var buildNumber: String = ""
  19. private var branch: String = ""
  20. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  21. override func subscribe() {
  22. versionNumber = Bundle.main.appDevVersion ?? "Unknown"
  23. buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
  24. branch = BuildDetails.shared.branchAndSha
  25. }
  26. // Export categories for selective export
  27. enum ExportCategory: String, CaseIterable, Identifiable {
  28. var id: String { rawValue }
  29. case metadata = "Metadata"
  30. case devices = "Devices"
  31. case therapy = "Therapy"
  32. case algorithm = "Algorithm"
  33. case features = "Features"
  34. case notifications = "Notifications"
  35. case services = "Services"
  36. case tempTargetPresets = "Temp Target Presets"
  37. case overridePresets = "Override Presets"
  38. case mealPresets = "Meal Presets"
  39. }
  40. // Published state for UI binding
  41. @Published var selectedCategories: Set<ExportCategory> = Set(ExportCategory.allCases)
  42. @Published var isExporting: Bool = false
  43. enum ExportError: LocalizedError {
  44. case documentsDirectoryNotFound
  45. case fileWriteError(Error)
  46. case unknown(String)
  47. var errorDescription: String? {
  48. switch self {
  49. case .documentsDirectoryNotFound:
  50. return String(localized: "Could not access documents directory")
  51. case let .fileWriteError(error):
  52. return String(localized: "Failed to write export file: \(error.localizedDescription)")
  53. case let .unknown(message):
  54. return String(localized: "Export failed: \(message)")
  55. }
  56. }
  57. }
  58. /// Exports selected Trio settings to a CSV file
  59. ///
  60. /// This function creates an export of the user's selected Trio configuration categories including:
  61. /// - Export metadata (date, app version, build) [optional]
  62. /// - Device settings (CGM, pump information) [optional]
  63. /// - Therapy profiles (basal rates, ISF, carb ratios, targets) [optional]
  64. /// - Algorithm settings (SMB, autosens, dynamic settings, etc.) [optional]
  65. /// - Features and UI preferences [optional]
  66. /// - Notification settings [optional]
  67. /// - Service configurations [optional]
  68. /// - Preset data [optional]
  69. ///
  70. /// - Parameter categories: Set of categories to include in export. If nil, exports all categories.
  71. /// - Parameter format: Export format to use. If nil, uses currently selected format.
  72. /// - Returns: A Result containing either the file URL on success or an ExportError on failure
  73. func exportSettings(
  74. categories: Set<ExportCategory>? = nil
  75. ) async -> Result<URL, ExportError> {
  76. debug(.default, "🔄 EXPORT: Starting settings export...")
  77. await MainActor.run { isExporting = true }
  78. defer { Task { @MainActor in isExporting = false } }
  79. let categoriesToExport = categories ?? selectedCategories
  80. debug(
  81. .default,
  82. "🔄 EXPORT: Exporting categories: \(categoriesToExport.map(\.rawValue).joined(separator: ", ")) in .CSV format"
  83. )
  84. let formatter = DateFormatter()
  85. formatter.dateFormat = "yyyyMMdd_HHmmss"
  86. let timestamp = formatter.string(from: Date())
  87. let fileName = "TrioSettings_\(timestamp).csv"
  88. // Use the Documents directory for better sharing compatibility
  89. guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
  90. return .failure(.documentsDirectoryNotFound)
  91. }
  92. let fileURL = documentsDirectory.appendingPathComponent(fileName)
  93. debug(.default, "Export file path: \(fileURL.path)")
  94. var exportSettings: [ExportSetting] = []
  95. let trioSettings = settingsManager.settings
  96. let preferences = settingsManager.preferences
  97. debug(.default, "🔄 EXPORT: Settings managers initialized")
  98. // Helper function to add a setting
  99. func addSetting(category: String, subcategory: String = "", name: String, value: String, unit: String = "") {
  100. exportSettings.append(ExportSetting(
  101. category: category,
  102. subcategory: subcategory,
  103. name: name,
  104. value: value,
  105. unit: unit
  106. ))
  107. }
  108. // Export metadata - always include basic export info
  109. if categoriesToExport.contains(.metadata) {
  110. let exportCategory = String(localized: "Metadata")
  111. addSetting(
  112. category: exportCategory,
  113. name: String(localized: "Export Date"),
  114. value: DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .medium)
  115. )
  116. addSetting(category: exportCategory, name: String(localized: "App Version"), value: versionNumber)
  117. addSetting(category: exportCategory, name: String(localized: "Build Number"), value: buildNumber)
  118. addSetting(category: exportCategory, name: String(localized: "Branch"), value: branch)
  119. }
  120. // Devices
  121. if categoriesToExport.contains(.devices) {
  122. let devicesCategory = String(localized: "Devices", comment: "Devices menu item in the Settings main view.")
  123. addSetting(category: devicesCategory, name: String(localized: "CGM"), value: trioSettings.cgm.rawValue)
  124. addSetting(
  125. category: devicesCategory,
  126. name: String(localized: "Smooth Glucose Value"),
  127. value: trioSettings.smoothGlucose ? String(localized: "Enabled") : String(localized: "Disabled")
  128. )
  129. // Pump Information
  130. if let pumpManager = provider.deviceManager.pumpManager {
  131. addSetting(category: devicesCategory, name: String(localized: "Pump Type"), value: pumpManager.localizedTitle)
  132. // Get insulin type from pump manager if available, otherwise from preferences
  133. let insulinTypeValue: String
  134. if let pumpManager = provider.deviceManager.pumpManager,
  135. let insulinType = pumpManager.status.insulinType
  136. {
  137. insulinTypeValue = insulinType.title
  138. } else {
  139. insulinTypeValue = preferences.curve.rawValue
  140. // technically, this gets set only when a pump is onboared
  141. // leaving this here as a backup, because you theoretically could
  142. // have removed your PM instance, but are just within pumps and
  143. // insulin type stays the same.
  144. // however, this theoretically could be a stale type.
  145. }
  146. addSetting(
  147. category: devicesCategory,
  148. name: String(localized: "Insulin Type"),
  149. value: insulinTypeValue
  150. )
  151. } else {
  152. addSetting(
  153. category: devicesCategory,
  154. name: String(localized: "Pump Type"),
  155. value: String(localized: "Not Connected")
  156. )
  157. }
  158. }
  159. // Therapy Settings
  160. if categoriesToExport.contains(.therapy) {
  161. let therapyCategory = String(localized: "Therapy", comment: "Therapy menu item in the Settings main view.")
  162. // Units and Limits subcategory
  163. let unitsLimitsSubcategory = String(localized: "Units and Limits")
  164. addSetting(
  165. category: therapyCategory,
  166. subcategory: unitsLimitsSubcategory,
  167. name: String(localized: "Glucose Units"),
  168. value: trioSettings.units.rawValue
  169. )
  170. addSetting(
  171. category: therapyCategory,
  172. subcategory: unitsLimitsSubcategory,
  173. name: String(localized: "Maximum Insulin on Board (IOB)"),
  174. value: String(describing: preferences.maxIOB),
  175. unit: "U"
  176. )
  177. // Add missing pump settings from PumpSettings
  178. let pumpSettings = settingsManager.pumpSettings
  179. addSetting(
  180. category: therapyCategory,
  181. subcategory: unitsLimitsSubcategory,
  182. name: String(localized: "Maximum Bolus"),
  183. value: String(describing: pumpSettings.maxBolus),
  184. unit: "U"
  185. )
  186. addSetting(
  187. category: therapyCategory,
  188. subcategory: unitsLimitsSubcategory,
  189. name: String(localized: "Maximum Basal Rate"),
  190. value: String(describing: pumpSettings.maxBasal),
  191. unit: String(localized: "U/hr", comment: "Insulin unit per hour abbreviation")
  192. )
  193. addSetting(
  194. category: therapyCategory,
  195. subcategory: unitsLimitsSubcategory,
  196. name: String(localized: "Maximum Carbs on Board (COB)"),
  197. value: String(describing: preferences.maxCOB),
  198. unit: String(localized: "g", comment: "Units for carbs")
  199. )
  200. addSetting(
  201. category: therapyCategory,
  202. subcategory: unitsLimitsSubcategory,
  203. name: String(localized: "Minimum Safety Threshold"),
  204. value: trioSettings
  205. .units == .mgdL ? String(describing: preferences.threshold_setting) :
  206. String(describing: preferences.threshold_setting.asMmolL),
  207. unit: trioSettings.units.rawValue
  208. )
  209. // Get therapy profiles from storage
  210. let basalProfile = storage.retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self) ?? []
  211. let isfProfileContainer = storage.retrieve(OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self)
  212. let crProfileContainer = storage.retrieve(OpenAPS.Settings.carbRatios, as: CarbRatios.self)
  213. let targetProfileContainer = storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self)
  214. // Glucose Targets subcategory
  215. let glucoseTargetsSubcategory = String(localized: "Glucose Targets")
  216. if let targetContainer = targetProfileContainer {
  217. for entry in targetContainer.targets {
  218. // Export single target value since high==low in Trio
  219. let targetValue = trioSettings.units == .mgdL ? entry.low : entry.low.asMmolL
  220. addSetting(
  221. category: therapyCategory,
  222. subcategory: glucoseTargetsSubcategory,
  223. name: String(localized: "Target (\(entry.start.formattedHourMinuteFromTimeString()))"),
  224. value: String(describing: targetValue),
  225. unit: trioSettings.units.rawValue
  226. )
  227. }
  228. }
  229. // Basal Rates subcategory
  230. let basalRatesSubcategory = String(localized: "Basal Rates")
  231. for entry in basalProfile {
  232. addSetting(
  233. category: therapyCategory,
  234. subcategory: basalRatesSubcategory,
  235. name: String(localized: "Basal Rate (\(entry.start.formattedHourMinuteFromTimeString()))"),
  236. value: String(describing: entry.rate),
  237. unit: String(localized: "U/hr", comment: "Insulin unit per hour abbreviation")
  238. )
  239. }
  240. // Carb Ratios subcategory
  241. let carbRatiosSubcategory = String(localized: "Carb Ratios")
  242. if let crContainer = crProfileContainer {
  243. for entry in crContainer.schedule {
  244. addSetting(
  245. category: therapyCategory,
  246. subcategory: carbRatiosSubcategory,
  247. name: String(localized: "Carb Ratio (\(entry.start.formattedHourMinuteFromTimeString()))"),
  248. value: String(describing: entry.ratio),
  249. unit: String(localized: "g/U")
  250. )
  251. }
  252. }
  253. // Insulin Sensitivities subcategory
  254. let insulinSensitivitiesSubcategory = String(localized: "Insulin Sensitivities")
  255. if let isfContainer = isfProfileContainer {
  256. for entry in isfContainer.sensitivities {
  257. let isfValue = trioSettings.units == .mgdL ? entry.sensitivity : entry.sensitivity.asMmolL
  258. addSetting(
  259. category: therapyCategory,
  260. subcategory: insulinSensitivitiesSubcategory,
  261. name: String(localized: "ISF (\(entry.start.formattedHourMinuteFromTimeString()))"),
  262. value: String(describing: isfValue),
  263. unit: trioSettings.units.rawValue
  264. )
  265. }
  266. }
  267. }
  268. // Algorithm Settings
  269. if categoriesToExport.contains(.algorithm) {
  270. let algorithmCategory = String(localized: "Algorithm", comment: "Algorithm menu item in the Settings main view.")
  271. let pumpSettings = settingsManager.pumpSettings
  272. // Autosens Settings
  273. let autosensSubcategory = String(localized: "Autosens")
  274. addSetting(
  275. category: algorithmCategory,
  276. subcategory: autosensSubcategory,
  277. name: String(localized: "Autosens Max"),
  278. value: String(format: "%.0f", (preferences.autosensMax as NSDecimalNumber).doubleValue * 100),
  279. unit: "%"
  280. )
  281. addSetting(
  282. category: algorithmCategory,
  283. subcategory: autosensSubcategory,
  284. name: String(localized: "Autosens Min"),
  285. value: String(format: "%.0f", (preferences.autosensMin as NSDecimalNumber).doubleValue * 100),
  286. unit: "%"
  287. )
  288. addSetting(
  289. category: algorithmCategory,
  290. subcategory: autosensSubcategory,
  291. name: String(localized: "Rewind Resets Autosens"),
  292. value: preferences.rewindResetsAutosens ? String(localized: "Enabled") : String(localized: "Disabled")
  293. )
  294. // SMB Settings
  295. let smbSubcategory = String(localized: "SMB")
  296. addSetting(
  297. category: algorithmCategory,
  298. subcategory: smbSubcategory,
  299. name: String(localized: "Enable SMB Always"),
  300. value: preferences.enableSMBAlways ? String(localized: "Enabled") : String(localized: "Disabled")
  301. )
  302. addSetting(
  303. category: algorithmCategory,
  304. subcategory: smbSubcategory,
  305. name: String(localized: "Enable SMB With COB"),
  306. value: preferences.enableSMBWithCOB ? String(localized: "Enabled") : String(localized: "Disabled")
  307. )
  308. addSetting(
  309. category: algorithmCategory,
  310. subcategory: smbSubcategory,
  311. name: String(localized: "Enable SMB With Temptarget"),
  312. value: preferences.enableSMBWithTemptarget ? String(localized: "Enabled") : String(localized: "Disabled")
  313. )
  314. addSetting(
  315. category: algorithmCategory,
  316. subcategory: smbSubcategory,
  317. name: String(localized: "Enable SMB After Carbs"),
  318. value: preferences.enableSMBAfterCarbs ? String(localized: "Enabled") : String(localized: "Disabled")
  319. )
  320. addSetting(
  321. category: algorithmCategory,
  322. subcategory: smbSubcategory,
  323. name: String(localized: "Enable SMB With High Glucose"),
  324. value: preferences.enableSMB_high_bg ? String(localized: "Enabled") : String(localized: "Disabled")
  325. )
  326. if preferences.enableSMB_high_bg {
  327. addSetting(
  328. category: algorithmCategory,
  329. subcategory: smbSubcategory,
  330. name: String(localized: "High Glucose Target"),
  331. value: trioSettings
  332. .units == .mgdL ? String(describing: preferences.enableSMB_high_bg_target) :
  333. String(describing: preferences.enableSMB_high_bg_target.asMmolL),
  334. unit: trioSettings.units.rawValue
  335. )
  336. }
  337. addSetting(
  338. category: algorithmCategory,
  339. subcategory: smbSubcategory,
  340. name: String(localized: "Allow SMB With High Temptarget"),
  341. value: preferences.allowSMBWithHighTemptarget ? String(localized: "Enabled") : String(localized: "Disabled")
  342. )
  343. addSetting(
  344. category: algorithmCategory,
  345. subcategory: smbSubcategory,
  346. name: String(localized: "Enable UAM"),
  347. value: preferences.enableUAM ? String(localized: "Enabled") : String(localized: "Disabled")
  348. )
  349. addSetting(
  350. category: algorithmCategory,
  351. subcategory: smbSubcategory,
  352. name: String(localized: "Max SMB Basal Minutes"),
  353. value: String(describing: preferences.maxSMBBasalMinutes),
  354. unit: String(localized: "minutes")
  355. )
  356. addSetting(
  357. category: algorithmCategory,
  358. subcategory: smbSubcategory,
  359. name: String(localized: "Max UAM Basal Minutes"),
  360. value: String(describing: preferences.maxUAMSMBBasalMinutes),
  361. unit: String(localized: "minutes")
  362. )
  363. addSetting(
  364. category: algorithmCategory,
  365. subcategory: smbSubcategory,
  366. name: String(localized: "Max Allowed Glucose Rise for SMB"),
  367. value: String(format: "%.0f", (preferences.maxDeltaBGthreshold as NSDecimalNumber).doubleValue * 100),
  368. unit: "%"
  369. )
  370. // Dynamic Settings
  371. let dynamicSubcategory = String(localized: "Dynamic Settings")
  372. // Proper Dynamic ISF handling using the current enum logic
  373. let dynamicISFValue: String
  374. if !preferences.useNewFormula {
  375. dynamicISFValue = String(localized: "Disabled")
  376. } else if preferences.sigmoid {
  377. dynamicISFValue = String(localized: "Sigmoid")
  378. } else {
  379. dynamicISFValue = String(localized: "Logarithmic")
  380. }
  381. addSetting(
  382. category: algorithmCategory,
  383. subcategory: dynamicSubcategory,
  384. name: String(localized: "Dynamic ISF"),
  385. value: dynamicISFValue
  386. )
  387. // Show adjustment factors as percentages with proper labels
  388. if preferences.useNewFormula {
  389. if !preferences.sigmoid {
  390. addSetting(
  391. category: algorithmCategory,
  392. subcategory: dynamicSubcategory,
  393. name: String(localized: "Adjustment Factor (AF)"),
  394. value: String(format: "%.0f", (preferences.adjustmentFactor as NSDecimalNumber).doubleValue * 100),
  395. unit: "%"
  396. )
  397. } else {
  398. addSetting(
  399. category: algorithmCategory,
  400. subcategory: dynamicSubcategory,
  401. name: String(localized: "Sigmoid Adjustment Factor"),
  402. value: String(
  403. format: "%.0f",
  404. (preferences.adjustmentFactorSigmoid as NSDecimalNumber).doubleValue * 100
  405. ),
  406. unit: "%"
  407. )
  408. }
  409. }
  410. // Weighted Average of TDD is shown for both logarithmic and sigmoid when Dynamic ISF is enabled
  411. addSetting(
  412. category: algorithmCategory,
  413. subcategory: dynamicSubcategory,
  414. name: String(localized: "Weighted Average of TDD"),
  415. value: String(format: "%.0f", (preferences.weightPercentage as NSDecimalNumber).doubleValue * 100),
  416. unit: "%"
  417. )
  418. addSetting(
  419. category: algorithmCategory,
  420. subcategory: dynamicSubcategory,
  421. name: String(localized: "Adjust Basal"),
  422. value: preferences.tddAdjBasal ? String(localized: "Enabled") : String(localized: "Disabled")
  423. )
  424. // Target Behavior
  425. let targetBehaviorSubcategory = String(localized: "Target Behavior")
  426. addSetting(
  427. category: algorithmCategory,
  428. subcategory: targetBehaviorSubcategory,
  429. name: String(localized: "High Temptarget Raises Sensitivity"),
  430. value: preferences
  431. .highTemptargetRaisesSensitivity ? String(localized: "Enabled") : String(localized: "Disabled")
  432. )
  433. addSetting(
  434. category: algorithmCategory,
  435. subcategory: targetBehaviorSubcategory,
  436. name: String(localized: "Low Temptarget Lowers Sensitivity"),
  437. value: preferences
  438. .lowTemptargetLowersSensitivity ? String(localized: "Enabled") : String(localized: "Disabled")
  439. )
  440. addSetting(
  441. category: algorithmCategory,
  442. subcategory: targetBehaviorSubcategory,
  443. name: String(localized: "Sensitivity Raises Target"),
  444. value: preferences.sensitivityRaisesTarget ? String(localized: "Enabled") : String(localized: "Disabled")
  445. )
  446. addSetting(
  447. category: algorithmCategory,
  448. subcategory: targetBehaviorSubcategory,
  449. name: String(localized: "Resistance Lowers Target"),
  450. value: preferences.resistanceLowersTarget ? String(localized: "Enabled") : String(localized: "Disabled")
  451. )
  452. addSetting(
  453. category: algorithmCategory,
  454. subcategory: targetBehaviorSubcategory,
  455. name: String(localized: "Half Basal Exercise Target"),
  456. value: trioSettings
  457. .units == .mgdL ? String(describing: preferences.halfBasalExerciseTarget) :
  458. String(describing: preferences.halfBasalExerciseTarget.asMmolL),
  459. unit: trioSettings.units.rawValue
  460. )
  461. // Additional Algorithm Settings
  462. let additionalsSubcategory = String(localized: "Additionals")
  463. addSetting(
  464. category: algorithmCategory,
  465. subcategory: additionalsSubcategory,
  466. name: String(localized: "Max Daily Safety Multiplier"),
  467. value: String(format: "%.0f", (preferences.maxDailySafetyMultiplier as NSDecimalNumber).doubleValue * 100),
  468. unit: "%"
  469. )
  470. addSetting(
  471. category: algorithmCategory,
  472. subcategory: additionalsSubcategory,
  473. name: String(localized: "Current Basal Safety Multiplier"),
  474. value: String(
  475. format: "%.0f",
  476. (preferences.currentBasalSafetyMultiplier as NSDecimalNumber).doubleValue * 100
  477. ),
  478. unit: "%"
  479. )
  480. addSetting(
  481. category: algorithmCategory,
  482. subcategory: additionalsSubcategory,
  483. name: String(localized: "Use Custom Peak Time"),
  484. value: preferences.useCustomPeakTime ? String(localized: "Enabled") : String(localized: "Disabled")
  485. )
  486. addSetting(
  487. category: algorithmCategory,
  488. subcategory: additionalsSubcategory,
  489. name: String(localized: "Duration of Insulin Action"),
  490. value: String(describing: pumpSettings.insulinActionCurve),
  491. unit: String(localized: "hours")
  492. )
  493. addSetting(
  494. category: algorithmCategory,
  495. subcategory: additionalsSubcategory,
  496. name: String(localized: "Insulin Peak Time"),
  497. value: String(describing: preferences.insulinPeakTime),
  498. unit: String(localized: "minutes")
  499. )
  500. addSetting(
  501. category: algorithmCategory,
  502. subcategory: additionalsSubcategory,
  503. name: String(localized: "Skip Neutral Temps"),
  504. value: preferences.skipNeutralTemps ? String(localized: "Enabled") : String(localized: "Disabled")
  505. )
  506. addSetting(
  507. category: algorithmCategory,
  508. subcategory: additionalsSubcategory,
  509. name: String(localized: "Unsuspend If No Temp"),
  510. value: preferences.unsuspendIfNoTemp ? String(localized: "Enabled") : String(localized: "Disabled")
  511. )
  512. addSetting(
  513. category: algorithmCategory,
  514. subcategory: additionalsSubcategory,
  515. name: String(localized: "Suspend Zeros IOB"),
  516. value: preferences.suspendZerosIOB ? String(localized: "Enabled") : String(localized: "Disabled")
  517. )
  518. // SMB settings that belong in Additionals (correct order based on UI)
  519. addSetting(
  520. category: algorithmCategory,
  521. subcategory: additionalsSubcategory,
  522. name: String(localized: "SMB Delivery Ratio"),
  523. value: String(format: "%.0f", (preferences.smbDeliveryRatio as NSDecimalNumber).doubleValue * 100),
  524. unit: "%"
  525. )
  526. addSetting(
  527. category: algorithmCategory,
  528. subcategory: additionalsSubcategory,
  529. name: String(localized: "SMB Interval"),
  530. value: String(describing: preferences.smbInterval),
  531. unit: String(localized: "minutes")
  532. )
  533. addSetting(
  534. category: algorithmCategory,
  535. subcategory: additionalsSubcategory,
  536. name: String(localized: "Min 5m Carb Impact"),
  537. value: trioSettings
  538. .units == .mgdL ? String(describing: preferences.min5mCarbimpact) :
  539. String(describing: preferences.min5mCarbimpact.asMmolL),
  540. unit: trioSettings.units.rawValue
  541. )
  542. addSetting(
  543. category: algorithmCategory,
  544. subcategory: additionalsSubcategory,
  545. name: String(localized: "Remaining Carbs Percentage"),
  546. value: String(format: "%.0f", (preferences.remainingCarbsFraction as NSDecimalNumber).doubleValue * 100),
  547. unit: "%"
  548. )
  549. addSetting(
  550. category: algorithmCategory,
  551. subcategory: additionalsSubcategory,
  552. name: String(localized: "Remaining Carbs Cap"),
  553. value: String(describing: preferences.remainingCarbsCap),
  554. unit: String(localized: "g", comment: "Units for carbs")
  555. )
  556. addSetting(
  557. category: algorithmCategory,
  558. subcategory: additionalsSubcategory,
  559. name: String(localized: "Noisy CGM Target Increase"),
  560. value: String(format: "%.0f", (preferences.noisyCGMTargetMultiplier as NSDecimalNumber).doubleValue * 100),
  561. unit: "%"
  562. )
  563. }
  564. // Features
  565. if categoriesToExport.contains(.features) {
  566. let featuresCategory = String(localized: "Features", comment: "Features menu item in the Settings main view.")
  567. // Trio Features subcategory - Bolus Calculator
  568. let bolusCalculatorSubcategory = String(localized: "Bolus Calculator")
  569. addSetting(
  570. category: featuresCategory,
  571. subcategory: bolusCalculatorSubcategory,
  572. name: String(localized: "Display Meal Presets"),
  573. value: trioSettings.displayPresets ? String(localized: "Enabled") : String(localized: "Disabled")
  574. )
  575. addSetting(
  576. category: featuresCategory,
  577. subcategory: bolusCalculatorSubcategory,
  578. name: String(localized: "Recommended Bolus Percentage"),
  579. value: String(format: "%.0f", (trioSettings.overrideFactor as NSDecimalNumber).doubleValue * 100),
  580. unit: "%"
  581. )
  582. addSetting(
  583. category: featuresCategory,
  584. subcategory: bolusCalculatorSubcategory,
  585. name: String(localized: "Enable Reduced Bolus Option"),
  586. value: trioSettings.fattyMeals ? String(localized: "Enabled") : String(localized: "Disabled")
  587. )
  588. if trioSettings.fattyMeals {
  589. addSetting(
  590. category: featuresCategory,
  591. subcategory: bolusCalculatorSubcategory,
  592. name: String(localized: "Reduced Bolus Percentage"),
  593. value: String(format: "%.0f", (trioSettings.fattyMealFactor as NSDecimalNumber).doubleValue * 100),
  594. unit: "%"
  595. )
  596. }
  597. addSetting(
  598. category: featuresCategory,
  599. subcategory: bolusCalculatorSubcategory,
  600. name: String(localized: "Enable Super Bolus Option"),
  601. value: trioSettings.sweetMeals ? String(localized: "Enabled") : String(localized: "Disabled")
  602. )
  603. if trioSettings.sweetMeals {
  604. addSetting(
  605. category: featuresCategory,
  606. subcategory: bolusCalculatorSubcategory,
  607. name: String(localized: "Super Bolus Percentage"),
  608. value: String(format: "%.0f", (trioSettings.sweetMealFactor as NSDecimalNumber).doubleValue * 100),
  609. unit: "%"
  610. )
  611. }
  612. addSetting(
  613. category: featuresCategory,
  614. subcategory: bolusCalculatorSubcategory,
  615. name: String(localized: "Very Low Glucose Warning"),
  616. value: trioSettings.confirmBolus ? String(localized: "Enabled") : String(localized: "Disabled")
  617. )
  618. // Trio Features subcategory - Meal Settings
  619. let mealSettingsSubcategory = String(localized: "Meal Settings")
  620. addSetting(
  621. category: featuresCategory,
  622. subcategory: mealSettingsSubcategory,
  623. name: String(localized: "Max Carbs"),
  624. value: String(describing: trioSettings.maxCarbs),
  625. unit: String(localized: "g", comment: "Units for carbs")
  626. )
  627. addSetting(
  628. category: featuresCategory,
  629. subcategory: mealSettingsSubcategory,
  630. name: String(localized: "Max Fat"),
  631. value: String(describing: trioSettings.maxFat),
  632. unit: String(localized: "g", comment: "Units for carbs")
  633. )
  634. addSetting(
  635. category: featuresCategory,
  636. subcategory: mealSettingsSubcategory,
  637. name: String(localized: "Max Protein"),
  638. value: String(describing: trioSettings.maxProtein),
  639. unit: String(localized: "g", comment: "Units for carbs")
  640. )
  641. addSetting(
  642. category: featuresCategory,
  643. subcategory: mealSettingsSubcategory,
  644. name: String(localized: "Max Meal Absorption Time"),
  645. value: String(describing: preferences.maxMealAbsorptionTime),
  646. unit: String(localized: "hours")
  647. )
  648. addSetting(
  649. category: featuresCategory,
  650. subcategory: mealSettingsSubcategory,
  651. name: String(localized: "Enable Fat and Protein Entries"),
  652. value: trioSettings.useFPUconversion ? String(localized: "Enabled") : String(localized: "Disabled")
  653. )
  654. addSetting(
  655. category: featuresCategory,
  656. subcategory: mealSettingsSubcategory,
  657. name: String(localized: "Fat and Protein Delay"),
  658. value: String(describing: trioSettings.delay),
  659. unit: String(localized: "minutes")
  660. )
  661. addSetting(
  662. category: featuresCategory,
  663. subcategory: mealSettingsSubcategory,
  664. name: String(localized: "Spread Interval"),
  665. value: String(describing: trioSettings.minuteInterval),
  666. unit: String(localized: "minutes")
  667. )
  668. addSetting(
  669. category: featuresCategory,
  670. subcategory: mealSettingsSubcategory,
  671. name: String(localized: "Fat and Protein Percentage"),
  672. value: String(format: "%.0f", (trioSettings.individualAdjustmentFactor as NSDecimalNumber).doubleValue * 100),
  673. unit: "%"
  674. )
  675. // Trio Features subcategory - Shortcuts
  676. let shortcutsSubcategory = String(localized: "Shortcuts")
  677. addSetting(
  678. category: featuresCategory,
  679. subcategory: shortcutsSubcategory,
  680. name: String(localized: "Allow Bolusing with Shortcuts"),
  681. value: trioSettings
  682. .bolusShortcut != .notAllowed ? String(localized: "Enabled") : String(localized: "Disabled")
  683. )
  684. // Trio Features subcategory - Remote Control
  685. let remoteControlSubcategory = String(localized: "Remote Control")
  686. let isRemoteControlEnabled = UserDefaults.standard.bool(forKey: "isTrioRemoteControlEnabled")
  687. addSetting(
  688. category: featuresCategory,
  689. subcategory: remoteControlSubcategory,
  690. name: String(localized: "Enable Remote Control"),
  691. value: isRemoteControlEnabled ? String(localized: "Enabled") : String(localized: "Disabled")
  692. )
  693. // Trio Personalization subcategory - User Interface
  694. let userInterfaceSubcategory = String(localized: "User Interface")
  695. addSetting(
  696. category: featuresCategory,
  697. subcategory: userInterfaceSubcategory,
  698. name: String(localized: "Show X-Axis Grid Lines"),
  699. value: trioSettings.xGridLines ? String(localized: "Enabled") : String(localized: "Disabled")
  700. )
  701. addSetting(
  702. category: featuresCategory,
  703. subcategory: userInterfaceSubcategory,
  704. name: String(localized: "Show Y-Axis Grid Lines"),
  705. value: trioSettings.yGridLines ? String(localized: "Enabled") : String(localized: "Disabled")
  706. )
  707. addSetting(
  708. category: featuresCategory,
  709. subcategory: userInterfaceSubcategory,
  710. name: String(localized: "Show Low and High Thresholds"),
  711. value: trioSettings.rulerMarks ? String(localized: "Enabled") : String(localized: "Disabled")
  712. )
  713. addSetting(
  714. category: featuresCategory,
  715. subcategory: userInterfaceSubcategory,
  716. name: String(localized: "Low Threshold"),
  717. value: trioSettings
  718. .units == .mgdL ? String(describing: trioSettings.low) : String(describing: trioSettings.low.asMmolL),
  719. unit: trioSettings.units.rawValue
  720. )
  721. addSetting(
  722. category: featuresCategory,
  723. subcategory: userInterfaceSubcategory,
  724. name: String(localized: "High Threshold"),
  725. value: trioSettings
  726. .units == .mgdL ? String(describing: trioSettings.high) : String(describing: trioSettings.high.asMmolL),
  727. unit: trioSettings.units.rawValue
  728. )
  729. addSetting(
  730. category: featuresCategory,
  731. subcategory: userInterfaceSubcategory,
  732. name: String(localized: "eA1c/GMI Display Unit"),
  733. value: trioSettings.eA1cDisplayUnit.rawValue
  734. )
  735. addSetting(
  736. category: featuresCategory,
  737. subcategory: userInterfaceSubcategory,
  738. name: String(localized: "Show Carbs Required Badge"),
  739. value: trioSettings.showCarbsRequiredBadge ? String(localized: "Enabled") : String(localized: "Disabled")
  740. )
  741. addSetting(
  742. category: featuresCategory,
  743. subcategory: userInterfaceSubcategory,
  744. name: String(localized: "Carbs Required Threshold"),
  745. value: String(describing: trioSettings.carbsRequiredThreshold),
  746. unit: String(localized: "g", comment: "Units for carbs")
  747. )
  748. addSetting(
  749. category: featuresCategory,
  750. subcategory: userInterfaceSubcategory,
  751. name: String(localized: "Forecast Display Type"),
  752. value: trioSettings.forecastDisplayType.rawValue
  753. )
  754. addSetting(
  755. category: featuresCategory,
  756. subcategory: userInterfaceSubcategory,
  757. name: String(localized: "Glucose Color Scheme"),
  758. value: trioSettings.glucoseColorScheme.rawValue
  759. )
  760. addSetting(
  761. category: featuresCategory,
  762. subcategory: userInterfaceSubcategory,
  763. name: String(localized: "Time in Range Type"),
  764. value: trioSettings.timeInRangeType.rawValue
  765. )
  766. // Appearance setting from UserDefaults
  767. let colorSchemePreference = UserDefaults.standard.string(forKey: "colorSchemePreference") ?? "systemDefault"
  768. let appearanceValue: String
  769. switch colorSchemePreference {
  770. case "systemDefault":
  771. appearanceValue = String(localized: "System Default")
  772. case "light":
  773. appearanceValue = String(localized: "Light")
  774. case "dark":
  775. appearanceValue = String(localized: "Dark")
  776. default:
  777. appearanceValue = String(localized: "System Default")
  778. }
  779. addSetting(
  780. category: featuresCategory,
  781. subcategory: userInterfaceSubcategory,
  782. name: String(localized: "Appearance"),
  783. value: appearanceValue
  784. )
  785. }
  786. // Notifications
  787. if categoriesToExport.contains(.notifications) {
  788. let notificationsCategory = String(
  789. localized: "Notifications",
  790. comment: "Notifications menu item in the Settings main view."
  791. )
  792. // Trio Notifications subcategory
  793. let trioNotificationsSubcategory = String(localized: "Trio Notifications")
  794. addSetting(
  795. category: notificationsCategory,
  796. subcategory: trioNotificationsSubcategory,
  797. name: String(localized: "Always Notify Pump"),
  798. value: trioSettings.notificationsPump ? String(localized: "Enabled") : String(localized: "Disabled")
  799. )
  800. addSetting(
  801. category: notificationsCategory,
  802. subcategory: trioNotificationsSubcategory,
  803. name: String(localized: "Always Notify CGM"),
  804. value: trioSettings.notificationsCgm ? String(localized: "Enabled") : String(localized: "Disabled")
  805. )
  806. addSetting(
  807. category: notificationsCategory,
  808. subcategory: trioNotificationsSubcategory,
  809. name: String(localized: "Always Notify Carb"),
  810. value: trioSettings.notificationsCarb ? String(localized: "Enabled") : String(localized: "Disabled")
  811. )
  812. addSetting(
  813. category: notificationsCategory,
  814. subcategory: trioNotificationsSubcategory,
  815. name: String(localized: "Always Notify Algorithm"),
  816. value: trioSettings.notificationsAlgorithm ? String(localized: "Enabled") : String(localized: "Disabled")
  817. )
  818. addSetting(
  819. category: notificationsCategory,
  820. subcategory: trioNotificationsSubcategory,
  821. name: String(localized: "Show Glucose App Badge"),
  822. value: trioSettings.glucoseBadge ? String(localized: "Enabled") : String(localized: "Disabled")
  823. )
  824. addSetting(
  825. category: notificationsCategory,
  826. subcategory: trioNotificationsSubcategory,
  827. name: String(localized: "Glucose Notifications"),
  828. value: trioSettings.glucoseNotificationsOption.rawValue
  829. )
  830. addSetting(
  831. category: notificationsCategory,
  832. subcategory: trioNotificationsSubcategory,
  833. name: String(localized: "Add Glucose Source to Alarm"),
  834. value: trioSettings
  835. .addSourceInfoToGlucoseNotifications ? String(localized: "Enabled") : String(localized: "Disabled")
  836. )
  837. addSetting(
  838. category: notificationsCategory,
  839. subcategory: trioNotificationsSubcategory,
  840. name: String(localized: "Low Glucose Alarm Limit"),
  841. value: trioSettings
  842. .units == .mgdL ? String(describing: trioSettings.lowGlucose) :
  843. String(describing: trioSettings.lowGlucose.asMmolL),
  844. unit: trioSettings.units.rawValue
  845. )
  846. addSetting(
  847. category: notificationsCategory,
  848. subcategory: trioNotificationsSubcategory,
  849. name: String(localized: "High Glucose Alarm Limit"),
  850. value: trioSettings
  851. .units == .mgdL ? String(describing: trioSettings.highGlucose) :
  852. String(describing: trioSettings.highGlucose.asMmolL),
  853. unit: trioSettings.units.rawValue
  854. )
  855. // Live Activity subcategory
  856. let liveActivitySubcategory = String(localized: "Live Activity")
  857. addSetting(
  858. category: notificationsCategory,
  859. subcategory: liveActivitySubcategory,
  860. name: String(localized: "Enable Live Activity"),
  861. value: trioSettings.useLiveActivity ? String(localized: "Enabled") : String(localized: "Disabled")
  862. )
  863. addSetting(
  864. category: notificationsCategory,
  865. subcategory: liveActivitySubcategory,
  866. name: String(localized: "Lock Screen Widget Style"),
  867. value: trioSettings.lockScreenView.rawValue
  868. )
  869. }
  870. // Services
  871. if categoriesToExport.contains(.services) {
  872. let servicesCategory = String(localized: "Services", comment: "Services menu item in the Settings main view.")
  873. // Nightscout subcategory
  874. let nightscoutSubcategory = String(localized: "Nightscout")
  875. addSetting(
  876. category: servicesCategory,
  877. subcategory: nightscoutSubcategory,
  878. name: String(localized: "Allow Uploading to Nightscout"),
  879. value: trioSettings.isUploadEnabled ? String(localized: "Enabled") : String(localized: "Disabled")
  880. )
  881. addSetting(
  882. category: servicesCategory,
  883. subcategory: nightscoutSubcategory,
  884. name: String(localized: "Upload Glucose"),
  885. value: trioSettings.uploadGlucose ? String(localized: "Enabled") : String(localized: "Disabled")
  886. )
  887. addSetting(
  888. category: servicesCategory,
  889. subcategory: nightscoutSubcategory,
  890. name: String(localized: "Allow Fetching From Nightscout"),
  891. value: trioSettings.isDownloadEnabled ? String(localized: "Enabled") : String(localized: "Disabled")
  892. )
  893. // Apple Health subcategory
  894. let appleHealthSubcategory = String(localized: "Apple Health")
  895. addSetting(
  896. category: servicesCategory,
  897. subcategory: appleHealthSubcategory,
  898. name: String(localized: "Apple Health"),
  899. value: trioSettings.useAppleHealth ? String(localized: "Enabled") : String(localized: "Disabled")
  900. )
  901. }
  902. // Temp Target Presets
  903. if categoriesToExport.contains(.tempTargetPresets) {
  904. let category = String(localized: "Temp Target Presets")
  905. debug(.default, "🔄 EXPORT: Fetching temp target presets...")
  906. let tempTargetPresetIDs = (try? await tempTargetsStorage.fetchForTempTargetPresets()) ?? []
  907. debug(.default, "🔄 EXPORT: Found \(tempTargetPresetIDs.count) temp target preset IDs")
  908. if !tempTargetPresetIDs.isEmpty {
  909. do {
  910. let tempTargetPresets: [ExportSetting] = try await viewContext.perform {
  911. let fetchedTempTargetPresets: [TempTargetStored] = try tempTargetPresetIDs.map {
  912. guard let obj = try self.viewContext.existingObject(with: $0) as? TempTargetStored else {
  913. throw ExportError.unknown("TempTargetStored type mismatch for objectID \($0)")
  914. }
  915. return obj
  916. }
  917. var processedTempTargetPresets: [ExportSetting] = []
  918. processedTempTargetPresets.reserveCapacity(fetchedTempTargetPresets.count * 10)
  919. for preset in fetchedTempTargetPresets {
  920. let presetName = preset.name ?? "Unknown Temp Target"
  921. if let target = preset.target {
  922. let targetValue = trioSettings.units == .mgdL
  923. ? target.description
  924. : target.decimalValue.formattedAsMmolL
  925. processedTempTargetPresets.append(.init(
  926. category: category,
  927. subcategory: presetName,
  928. name: "Target",
  929. value: targetValue,
  930. unit: trioSettings.units.rawValue
  931. ))
  932. }
  933. if let duration = preset.duration {
  934. processedTempTargetPresets.append(.init(
  935. category: category,
  936. subcategory: presetName,
  937. name: "Duration",
  938. value: String(describing: duration),
  939. unit: String(localized: "minutes")
  940. ))
  941. }
  942. if let halfBasalTarget = preset.halfBasalTarget {
  943. let halfBasalValue = trioSettings.units == .mgdL
  944. ? halfBasalTarget.description
  945. : halfBasalTarget.decimalValue.formattedAsMmolL
  946. processedTempTargetPresets.append(.init(
  947. category: category,
  948. subcategory: presetName,
  949. name: "Half Basal Target",
  950. value: halfBasalValue,
  951. unit: trioSettings.units.rawValue
  952. ))
  953. }
  954. }
  955. return processedTempTargetPresets
  956. }
  957. exportSettings.append(contentsOf: tempTargetPresets)
  958. debug(.default, "✅ EXPORT: Added \(tempTargetPresets.count) temp target preset rows")
  959. } catch {
  960. // STRICT: surface the real issue
  961. return .failure(.unknown("Failed to extract Temp Targets: \(error.localizedDescription)"))
  962. }
  963. }
  964. }
  965. // Override Presets
  966. if categoriesToExport.contains(.overridePresets) {
  967. let category = String(localized: "Override Presets")
  968. debug(.default, "🔄 EXPORT: Fetching override presets...")
  969. do {
  970. let overridePresetIDs = try await overrideStorage.fetchForOverridePresets()
  971. debug(.default, "🔄 EXPORT: Found \(overridePresetIDs.count) override preset IDs")
  972. if !overridePresetIDs.isEmpty {
  973. let overridePresets: [ExportSetting] = try await viewContext.perform {
  974. let fetchedOverridePresets: [OverrideStored] = try overridePresetIDs.map {
  975. guard let obj = try self.viewContext.existingObject(with: $0) as? OverrideStored else {
  976. throw ExportError.unknown("OverrideStored type mismatch for objectID \($0)")
  977. }
  978. return obj
  979. }
  980. var processedOverridePresets: [ExportSetting] = []
  981. processedOverridePresets.reserveCapacity(fetchedOverridePresets.count * 10)
  982. for preset in fetchedOverridePresets {
  983. let presetName = preset.name ?? "Unknown Override"
  984. processedOverridePresets.append(.init(
  985. category: category,
  986. subcategory: presetName,
  987. name: String(localized: "Basal Rate Adjustment"),
  988. value: String(format: "%.0f%%", preset.percentage),
  989. unit: ""
  990. ))
  991. processedOverridePresets.append(.init(
  992. category: category,
  993. subcategory: presetName,
  994. name: "Duration",
  995. value: preset.indefinite
  996. ? String(localized: "Indefinite")
  997. : String(describing: preset.duration ?? 0),
  998. unit: preset.indefinite ? "" : String(localized: "minutes")
  999. ))
  1000. if let target = preset.target, target != 0 {
  1001. processedOverridePresets.append(.init(
  1002. category: category,
  1003. subcategory: presetName,
  1004. name: "Target",
  1005. value: trioSettings.units == .mgdL
  1006. ? target.description
  1007. : target.decimalValue.formattedAsMmolL,
  1008. unit: trioSettings.units.rawValue
  1009. ))
  1010. }
  1011. if preset.advancedSettings {
  1012. processedOverridePresets.append(.init(
  1013. category: category,
  1014. subcategory: presetName,
  1015. name: "Advanced Settings",
  1016. value: String(localized: "Enabled"),
  1017. unit: ""
  1018. ))
  1019. if let smbMinutes = preset.smbMinutes {
  1020. processedOverridePresets.append(.init(
  1021. category: category,
  1022. subcategory: presetName,
  1023. name: "SMB Minutes",
  1024. value: String(describing: smbMinutes),
  1025. unit: String(localized: "minutes")
  1026. ))
  1027. }
  1028. if let uamMinutes = preset.uamMinutes {
  1029. processedOverridePresets.append(.init(
  1030. category: category,
  1031. subcategory: presetName,
  1032. name: "UAM Minutes",
  1033. value: String(describing: uamMinutes),
  1034. unit: String(localized: "minutes")
  1035. ))
  1036. }
  1037. }
  1038. if preset.smbIsOff {
  1039. processedOverridePresets.append(.init(
  1040. category: category,
  1041. subcategory: presetName,
  1042. name: "SMB",
  1043. value: String(localized: "Disabled"),
  1044. unit: ""
  1045. ))
  1046. }
  1047. if preset.smbIsScheduledOff {
  1048. processedOverridePresets.append(.init(
  1049. category: category,
  1050. subcategory: presetName,
  1051. name: "SMB Scheduled",
  1052. value: String(localized: "Disabled"),
  1053. unit: ""
  1054. ))
  1055. if let start = preset.start {
  1056. processedOverridePresets.append(.init(
  1057. category: category,
  1058. subcategory: presetName,
  1059. name: "SMB Schedule Start",
  1060. value: String(describing: start),
  1061. unit: String(localized: "hours")
  1062. ))
  1063. }
  1064. if let end = preset.end {
  1065. processedOverridePresets.append(.init(
  1066. category: category,
  1067. subcategory: presetName,
  1068. name: "SMB Schedule End",
  1069. value: String(describing: end),
  1070. unit: String(localized: "hours")
  1071. ))
  1072. }
  1073. }
  1074. // affects logic...
  1075. let affects: String? = {
  1076. if preset.isfAndCr { return String(localized: "ISF and CR") }
  1077. if preset.isf, preset.cr { return String(localized: "ISF and CR") }
  1078. if preset.isf { return String(localized: "ISF") }
  1079. if preset.cr { return String(localized: "CR") }
  1080. return nil
  1081. }()
  1082. if let affects {
  1083. processedOverridePresets.append(.init(
  1084. category: category,
  1085. subcategory: presetName,
  1086. name: "Affects",
  1087. value: affects,
  1088. unit: ""
  1089. ))
  1090. }
  1091. }
  1092. return processedOverridePresets
  1093. }
  1094. exportSettings.append(contentsOf: overridePresets)
  1095. debug(.default, "✅ EXPORT: Added \(overridePresets.count) override preset rows")
  1096. }
  1097. } catch {
  1098. return .failure(.unknown("Failed to fetch override presets: \(error.localizedDescription)"))
  1099. }
  1100. }
  1101. // Meal Presets
  1102. if categoriesToExport.contains(.mealPresets) {
  1103. let presetsCategory = String(localized: "Meal Presets")
  1104. // Meal Presets (from Core Data)
  1105. do {
  1106. debug(.default, "Fetching meal presets...")
  1107. let mealPresetData = try await viewContext.perform {
  1108. let request: NSFetchRequest<MealPresetStored> = MealPresetStored.fetchRequest()
  1109. let mealPresets = try self.viewContext.fetch(request)
  1110. return mealPresets.map { preset -> (dish: String, carbs: Decimal?, fat: Decimal?, protein: Decimal?) in
  1111. (
  1112. dish: preset.dish ?? "Unknown Meal",
  1113. carbs: preset.carbs?.decimalValue,
  1114. fat: preset.fat?.decimalValue,
  1115. protein: preset.protein?.decimalValue
  1116. )
  1117. }
  1118. }
  1119. debug(.default, "Found \(mealPresetData.count) meal presets")
  1120. if !mealPresetData.isEmpty {
  1121. for mealPreset in mealPresetData {
  1122. if let carbs = mealPreset.carbs, carbs > 0 {
  1123. addSetting(
  1124. category: presetsCategory,
  1125. subcategory: mealPreset.dish,
  1126. name: "Carbs",
  1127. value: String(describing: carbs),
  1128. unit: String(localized: "g", comment: "Units for carbs")
  1129. )
  1130. }
  1131. if let fat = mealPreset.fat, fat > 0 {
  1132. addSetting(
  1133. category: presetsCategory,
  1134. subcategory: mealPreset.dish,
  1135. name: "Fat",
  1136. value: String(describing: fat),
  1137. unit: String(localized: "g", comment: "Units for carbs")
  1138. )
  1139. }
  1140. if let protein = mealPreset.protein, protein > 0 {
  1141. addSetting(
  1142. category: presetsCategory,
  1143. subcategory: mealPreset.dish,
  1144. name: "Protein",
  1145. value: String(describing: protein),
  1146. unit: String(localized: "g", comment: "Units for carbs")
  1147. )
  1148. }
  1149. }
  1150. }
  1151. } catch {
  1152. debug(.default, "Failed to fetch meal presets: \(error)")
  1153. }
  1154. }
  1155. // Convert data to the selected format and write to file
  1156. do {
  1157. let content: String
  1158. // Helper function to escape CSV values
  1159. func csvEscape(_ value: String) -> String {
  1160. if value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r") {
  1161. return "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\""
  1162. }
  1163. return value
  1164. }
  1165. var csvContent = "\u{FEFF}Setting Category,Subcategory,Setting Name,Value,Unit\n"
  1166. for setting in exportSettings {
  1167. csvContent +=
  1168. "\(csvEscape(setting.category)),\(csvEscape(setting.subcategory)),\(csvEscape(setting.name)),\(csvEscape(setting.value)),\(csvEscape(setting.unit))\n"
  1169. }
  1170. content = csvContent
  1171. debug(
  1172. .default,
  1173. "📝 EXPORT: Writing .CSV content (\(content.count) characters) to file: \(fileURL.path)"
  1174. )
  1175. debug(.default, "📝 EXPORT: Temporary directory: \(FileManager.default.temporaryDirectory.path)")
  1176. debug(.default, "📝 EXPORT: File URL: \(fileURL)")
  1177. try content.write(to: fileURL, atomically: true, encoding: .utf8)
  1178. debug(.default, "✅ EXPORT: Content written to file successfully")
  1179. // Set file attributes for better sharing compatibility
  1180. try fileManager.setAttributes([
  1181. .posixPermissions: 0o644,
  1182. .extensionHidden: false
  1183. ], ofItemAtPath: fileURL.path)
  1184. debug(.default, "✅ EXPORT: File attributes set successfully")
  1185. // Verify file was written successfully
  1186. let fileExists = fileManager.fileExists(atPath: fileURL.path)
  1187. let fileAttributes = try? fileManager.attributesOfItem(atPath: fileURL.path)
  1188. let fileSize = (fileAttributes?[.size] as? NSNumber)?.intValue ?? 0
  1189. debug(.default, "📊 EXPORT: File verification - Exists: \(fileExists), Size: \(fileSize) bytes")
  1190. if !fileExists {
  1191. debug(.default, "❌ EXPORT: CRITICAL - File does not exist after writing!")
  1192. return .failure(.unknown("File was not created successfully"))
  1193. }
  1194. if fileSize == 0 {
  1195. debug(.default, "❌ EXPORT: CRITICAL - File exists but has 0 bytes!")
  1196. return .failure(.unknown("File was created but is empty"))
  1197. }
  1198. return .success(fileURL)
  1199. } catch {
  1200. debug(.default, "Failed to write settings export file: \(error)")
  1201. return .failure(.fileWriteError(error))
  1202. }
  1203. }
  1204. /// Exports settings using the currently selected categories and format
  1205. func exportSelectedSettings() async -> Result<URL, ExportError> {
  1206. await exportSettings(categories: selectedCategories)
  1207. }
  1208. /// Toggle all categories on or off
  1209. func toggleAllCategories(_ enabled: Bool) {
  1210. if enabled {
  1211. selectedCategories = Set(ExportCategory.allCases)
  1212. } else {
  1213. selectedCategories = []
  1214. }
  1215. }
  1216. /// Check if all categories are selected
  1217. var allCategoriesSelected: Bool {
  1218. selectedCategories.count == ExportCategory.allCases.count
  1219. }
  1220. }
  1221. }