diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 2871a59de..8153d4291 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -16,8 +16,24 @@ 656F8C102E49F36F0008DC1D /* QRCodeDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */; }; 656F8C122E49F3780008DC1D /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */; }; 656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */; }; - 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; + 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */; }; + 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */; }; + 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */; }; + 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */; }; + 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */; }; + 6589CC672E9E7D1600BB18FE /* ImportExportSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC542E9E7D1600BB18FE /* ImportExportSettingsViewModel.swift */; }; + 6589CC682E9E7D1600BB18FE /* ExportableSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC522E9E7D1600BB18FE /* ExportableSettings.swift */; }; + 6589CC692E9E7D1600BB18FE /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5A2E9E7D1600BB18FE /* ContactSettingsViewModel.swift */; }; + 6589CC6A2E9E7D1600BB18FE /* DexcomSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5B2E9E7D1600BB18FE /* DexcomSettingsView.swift */; }; + 6589CC6B2E9E7D1600BB18FE /* TabCustomizationModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */; }; + 6589CC6C2E9E7D1600BB18FE /* GraphSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */; }; + 6589CC6D2E9E7D1600BB18FE /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC582E9E7D1600BB18FE /* CalendarSettingsView.swift */; }; + 6589CC6E2E9E7D1600BB18FE /* SettingsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */; }; + 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */; }; + 6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; }; + 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; }; + 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; @@ -50,14 +66,10 @@ DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; }; DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; }; DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF102C997B4600FB655A /* LoadingButtonView.swift */; }; - DD1A97142D4294A5000DDC11 /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1A97132D4294A4000DDC11 /* AdvancedSettingsView.swift */; }; - DD1A97162D4294B3000DDC11 /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1A97152D4294B2000DDC11 /* AdvancedSettingsViewModel.swift */; }; DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52B82E1EB5DC00432050 /* TabPosition.swift */; }; DD1D52BB2E1EB60B00432050 /* MoreMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */; }; DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; }; DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; }; - DD2C2E542D3C37DC006413A5 /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */; }; - DD2C2E562D3C3917006413A5 /* DexcomSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */; }; DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; }; DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DD485F152E46631000CE8CBF /* CryptoSwift */; }; DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; }; @@ -91,8 +103,6 @@ DD4AFB612DB68BBC00BB593F /* AlarmListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB602DB68BBC00BB593F /* AlarmListView.swift */; }; DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */; }; DD4AFB6B2DB6BF2A00BB593F /* Binding+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB6A2DB6BF2A00BB593F /* Binding+Optional.swift */; }; - DD50C7502D0828800057AE6F /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C74F2D0828800057AE6F /* ContactSettingsViewModel.swift */; }; - DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C7522D0828D10057AE6F /* ContactSettingsView.swift */; }; DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */; }; DD5334212C60EBEE00062F9D /* InsulinCartridgeChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334202C60EBEE00062F9D /* InsulinCartridgeChange.swift */; }; DD5334232C60ED3600062F9D /* IAge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334222C60ED3600062F9D /* IAge.swift */; }; @@ -137,15 +147,10 @@ DD7F4C232DD7A62200D449E9 /* AlarmType+SortDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7F4C222DD7A62200D449E9 /* AlarmType+SortDirection.swift */; }; DD7F4C252DD7B20700D449E9 /* AlarmType+timeUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7F4C242DD7B20700D449E9 /* AlarmType+timeUnit.swift */; }; DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */; }; - DD8060DB2E2ACE5900626B91 /* TabCustomizationModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8060DA2E2ACE5900626B91 /* TabCustomizationModal.swift */; }; - DD8316182DE3633D004467AA /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8316172DE3633D004467AA /* GeneralSettingsView.swift */; }; DD8316442DE47CA9004467AA /* BGPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8316432DE47CA9004467AA /* BGPicker.swift */; }; - DD8316462DE49B09004467AA /* GraphSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8316452DE49B09004467AA /* GraphSettingsView.swift */; }; DD8316482DE49EE5004467AA /* Storage+Migrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8316472DE49EE5004467AA /* Storage+Migrate.swift */; }; DD83164A2DE4C504004467AA /* SettingsStepperRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8316492DE4C504004467AA /* SettingsStepperRow.swift */; }; DD83164C2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */; }; - DD83164E2DE4E093004467AA /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD83164D2DE4E093004467AA /* CalendarSettingsView.swift */; }; - DD8316502DE4E635004467AA /* SettingsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD83164F2DE4E635004467AA /* SettingsMenuView.swift */; }; DD85E9952D739CFE001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD85E9942D739CED001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift */; }; DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */; }; @@ -408,8 +413,24 @@ 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeDisplayView.swift; sourceTree = ""; }; 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = ""; }; 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandSettings.swift; sourceTree = ""; }; - 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = ""; }; + 6589CC522E9E7D1600BB18FE /* ExportableSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportableSettings.swift; sourceTree = ""; }; + 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportExportSettingsView.swift; sourceTree = ""; }; + 6589CC542E9E7D1600BB18FE /* ImportExportSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportExportSettingsViewModel.swift; sourceTree = ""; }; + 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsView.swift; sourceTree = ""; }; + 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsViewModel.swift; sourceTree = ""; }; + 6589CC582E9E7D1600BB18FE /* CalendarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettingsView.swift; sourceTree = ""; }; + 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSettingsView.swift; sourceTree = ""; }; + 6589CC5A2E9E7D1600BB18FE /* ContactSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSettingsViewModel.swift; sourceTree = ""; }; + 6589CC5B2E9E7D1600BB18FE /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = ""; }; + 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = ""; }; + 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsView.swift; sourceTree = ""; }; + 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuView.swift; sourceTree = ""; }; + 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; + 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = ""; }; + 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = ""; }; + 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; @@ -443,14 +464,10 @@ DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = ""; }; DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = ""; }; DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.swift; sourceTree = ""; }; - DD1A97132D4294A4000DDC11 /* AdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsView.swift; sourceTree = ""; }; - DD1A97152D4294B2000DDC11 /* AdvancedSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsViewModel.swift; sourceTree = ""; }; DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = ""; }; DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuViewController.swift; sourceTree = ""; }; DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = ""; }; DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = ""; }; - DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = ""; }; - DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = ""; }; DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = ""; }; DD4878022C7B297E0048F05C /* StorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageValue.swift; sourceTree = ""; }; DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; @@ -482,8 +499,6 @@ DD4AFB602DB68BBC00BB593F /* AlarmListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmListView.swift; sourceTree = ""; }; DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UUID+Identifiable.swift"; sourceTree = ""; }; DD4AFB6A2DB6BF2A00BB593F /* Binding+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Optional.swift"; sourceTree = ""; }; - DD50C74F2D0828800057AE6F /* ContactSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSettingsViewModel.swift; sourceTree = ""; }; - DD50C7522D0828D10057AE6F /* ContactSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSettingsView.swift; sourceTree = ""; }; DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageUpdater.swift; sourceTree = ""; }; DD5334202C60EBEE00062F9D /* InsulinCartridgeChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinCartridgeChange.swift; sourceTree = ""; }; DD5334222C60ED3600062F9D /* IAge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAge.swift; sourceTree = ""; }; @@ -528,15 +543,10 @@ DD7F4C222DD7A62200D449E9 /* AlarmType+SortDirection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AlarmType+SortDirection.swift"; sourceTree = ""; }; DD7F4C242DD7B20700D449E9 /* AlarmType+timeUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AlarmType+timeUnit.swift"; sourceTree = ""; }; DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EKEventStore+Extensions.swift"; sourceTree = ""; }; - DD8060DA2E2ACE5900626B91 /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; - DD8316172DE3633D004467AA /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; DD8316432DE47CA9004467AA /* BGPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGPicker.swift; sourceTree = ""; }; - DD8316452DE49B09004467AA /* GraphSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsView.swift; sourceTree = ""; }; DD8316472DE49EE5004467AA /* Storage+Migrate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Migrate.swift"; sourceTree = ""; }; DD8316492DE4C504004467AA /* SettingsStepperRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStepperRow.swift; sourceTree = ""; }; DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BinaryFloatingPoint+localized.swift"; sourceTree = ""; }; - DD83164D2DE4E093004467AA /* CalendarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettingsView.swift; sourceTree = ""; }; - DD83164F2DE4E635004467AA /* SettingsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuView.swift; sourceTree = ""; }; DD85E9942D739CED001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnipodDashHeartbeatBluetoothTransmitter.swift; sourceTree = ""; }; DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseConversion.swift; sourceTree = ""; }; DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareClientExtension.swift; sourceTree = ""; }; @@ -813,6 +823,37 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 6589CC552E9E7D1600BB18FE /* ImportExport */ = { + isa = PBXGroup; + children = ( + 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */, + 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */, + 6589CC522E9E7D1600BB18FE /* ExportableSettings.swift */, + 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */, + 6589CC542E9E7D1600BB18FE /* ImportExportSettingsViewModel.swift */, + ); + path = ImportExport; + sourceTree = ""; + }; + 6589CC612E9E7D1600BB18FE /* Settings */ = { + isa = PBXGroup; + children = ( + 6589CC552E9E7D1600BB18FE /* ImportExport */, + 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */, + 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */, + 6589CC582E9E7D1600BB18FE /* CalendarSettingsView.swift */, + 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */, + 6589CC5A2E9E7D1600BB18FE /* ContactSettingsViewModel.swift */, + 6589CC5B2E9E7D1600BB18FE /* DexcomSettingsView.swift */, + 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */, + 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */, + 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */, + 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */, + 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */, + ); + path = Settings; + sourceTree = ""; + }; 6A5880E0B811AF443B05AB02 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -898,24 +939,6 @@ path = InfoTable; sourceTree = ""; }; - DD1A97122D429495000DDC11 /* Settings */ = { - isa = PBXGroup; - children = ( - DD8060DA2E2ACE5900626B91 /* TabCustomizationModal.swift */, - DD83164F2DE4E635004467AA /* SettingsMenuView.swift */, - DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */, - DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */, - DD83164D2DE4E093004467AA /* CalendarSettingsView.swift */, - DD8316452DE49B09004467AA /* GraphSettingsView.swift */, - DD1A97152D4294B2000DDC11 /* AdvancedSettingsViewModel.swift */, - DD1A97132D4294A4000DDC11 /* AdvancedSettingsView.swift */, - DD8316172DE3633D004467AA /* GeneralSettingsView.swift */, - DD50C74F2D0828800057AE6F /* ContactSettingsViewModel.swift */, - DD50C7522D0828D10057AE6F /* ContactSettingsView.swift */, - ); - path = Settings; - sourceTree = ""; - }; DD2C2E4D2D3B8ACF006413A5 /* Nightscout */ = { isa = PBXGroup; children = ( @@ -1438,6 +1461,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( + 6589CC612E9E7D1600BB18FE /* Settings */, DDCF9A7E2D85FCE6004DF4DD /* Alarm */, FC16A97624995FEE003D6245 /* Application */, DDFF3D792D140F1800BF9D9E /* BackgroundRefresh */, @@ -1454,7 +1478,6 @@ DD2C2E4D2D3B8ACF006413A5 /* Nightscout */, DD0C0C6E2C4AFFB800DBADDF /* Remote */, FC7CE59A248D334B001F83B8 /* Resources */, - DD1A97122D429495000DDC11 /* Settings */, DDC7E5142DBCE1B900EB1127 /* Snoozer */, DDC7E5CD2DC6637800EB1127 /* Storage */, DDEF503D2D32753A00999A5D /* Task */, @@ -1882,9 +1905,7 @@ DD5334292C6166A500062F9D /* InfoDisplaySettingsView.swift in Sources */, DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */, DD0B9D582DE1F3B20090C337 /* AlarmType+canAcknowledge.swift in Sources */, - DD2C2E562D3C3917006413A5 /* DexcomSettingsView.swift in Sources */, DD7F4BC52DD3CE0700D449E9 /* AlarmBGLimitSection.swift in Sources */, - DD8060DB2E2ACE5900626B91 /* TabCustomizationModal.swift in Sources */, DD7F4B9F2DD1F92700D449E9 /* AlarmActiveSection.swift in Sources */, DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */, DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */, @@ -1942,6 +1963,7 @@ DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */, DD0650F12DCE9A9E004D3B41 /* MissedReadingCondition.swift in Sources */, DDC6CA4B2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift in Sources */, + 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */, DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */, DDFF3D7F2D1414A200BF9D9E /* BLEDevice.swift in Sources */, DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */, @@ -1958,7 +1980,6 @@ DD7F4C1D2DD650D500D449E9 /* COBAlarmEditor.swift in Sources */, DDE75D272DE5E539007C1FC1 /* ActionRow.swift in Sources */, DD0C0C622C4175FD00DBADDF /* NSProfile.swift in Sources */, - DD8316182DE3633D004467AA /* GeneralSettingsView.swift in Sources */, DD58171E2D299FCA0041FB98 /* BluetoothDeviceDelegate.swift in Sources */, DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */, DDD10F032C518A6500D76A8E /* TreatmentResponse.swift in Sources */, @@ -2010,6 +2031,20 @@ FC8589BF252B54F500C8FC73 /* Mobileprovision.swift in Sources */, DD4878052C7B2C970048F05C /* Storage.swift in Sources */, DD493AE12ACF22FE009A6922 /* Profile.swift in Sources */, + 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */, + 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */, + 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */, + 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */, + 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */, + 6589CC672E9E7D1600BB18FE /* ImportExportSettingsViewModel.swift in Sources */, + 6589CC682E9E7D1600BB18FE /* ExportableSettings.swift in Sources */, + 6589CC692E9E7D1600BB18FE /* ContactSettingsViewModel.swift in Sources */, + 6589CC6A2E9E7D1600BB18FE /* DexcomSettingsView.swift in Sources */, + 6589CC6B2E9E7D1600BB18FE /* TabCustomizationModal.swift in Sources */, + 6589CC6C2E9E7D1600BB18FE /* GraphSettingsView.swift in Sources */, + 6589CC6D2E9E7D1600BB18FE /* CalendarSettingsView.swift in Sources */, + 6589CC6E2E9E7D1600BB18FE /* SettingsMenuView.swift in Sources */, + 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */, DD493ADF2ACF22BB009A6922 /* SAge.swift in Sources */, DDC6CA3F2DD7C6340060EE25 /* TemporaryAlarmEditor.swift in Sources */, DDF699992C5AA3060058A8D9 /* TempTargetPresetManager.swift in Sources */, @@ -2022,7 +2057,6 @@ DD13BC752C3FD6210062313B /* InfoType.swift in Sources */, DDCC3A4D2DDBB77C006F1C10 /* BatteryAlarmEditor.swift in Sources */, DDC6CA492DD8E47A0060EE25 /* PumpVolumeCondition.swift in Sources */, - DD1A97142D4294A5000DDC11 /* AdvancedSettingsView.swift in Sources */, DD9ACA0A2D33095600415D8A /* MinAgoTask.swift in Sources */, DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */, DDD10EFF2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift in Sources */, @@ -2030,8 +2064,8 @@ DD58171A2D299EF80041FB98 /* DexcomHeartbeatBluetoothDevice.swift in Sources */, DD7F4C0B2DD51C5500D449E9 /* OverrideEndCondition.swift in Sources */, DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */, + 6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */, DD7F4C192DD63FD500D449E9 /* RecBolusAlarmEditor.swift in Sources */, - DD50C7502D0828800057AE6F /* ContactSettingsViewModel.swift in Sources */, DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */, FC97881C2485969B00A7906C /* MainViewController.swift in Sources */, DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */, @@ -2049,7 +2083,6 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */, - DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */, DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */, DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */, DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */, @@ -2081,7 +2114,6 @@ DDC6CA432DD8CED20060EE25 /* SensorAgeCondition.swift in Sources */, DD7F4C212DD66BB200D449E9 /* FastRiseAlarmEditor.swift in Sources */, FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */, - DD83164E2DE4E093004467AA /* CalendarSettingsView.swift in Sources */, DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */, DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */, DD7E198A2ACDA62600DBD158 /* SensorStart.swift in Sources */, @@ -2094,9 +2126,7 @@ FC1BDD2D24A23204001B652C /* MainViewController+updateStats.swift in Sources */, DD4878102C7B74BF0048F05C /* TrioRemoteControlView.swift in Sources */, DD7F4C152DD51FEB00D449E9 /* TempTargetEndAlarmEditor.swift in Sources */, - DD2C2E542D3C37DC006413A5 /* DexcomSettingsViewModel.swift in Sources */, FCE537BC249A4D7D00F80BF8 /* carbBolusArrays.swift in Sources */, - DD8316462DE49B09004467AA /* GraphSettingsView.swift in Sources */, DD5334232C60ED3600062F9D /* IAge.swift in Sources */, FCD2A27D24C9D044009F7B7B /* Globals.swift in Sources */, DD0C0C642C45A59400DBADDF /* HKUnit+Extensions.swift in Sources */, @@ -2112,11 +2142,9 @@ DD7F4C132DD51FD500D449E9 /* TempTargetEndCondition.swift in Sources */, DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */, DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */, - DD8316502DE4E635004467AA /* SettingsMenuView.swift in Sources */, DDC7E5CF2DC77C2000EB1127 /* SnoozerViewModel.swift in Sources */, DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */, DD8316482DE49EE5004467AA /* Storage+Migrate.swift in Sources */, - DD1A97162D4294B3000DDC11 /* AdvancedSettingsViewModel.swift in Sources */, FCA2DDE62501095000254A8C /* Timers.swift in Sources */, DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */, DD9ED0C82D355244000D2A63 /* LogViewModel.swift in Sources */, diff --git a/LoopFollow/Helpers/AppConstants.swift b/LoopFollow/Helpers/AppConstants.swift index 2b9031f8d..e81ba159c 100644 --- a/LoopFollow/Helpers/AppConstants.swift +++ b/LoopFollow/Helpers/AppConstants.swift @@ -6,4 +6,34 @@ import Foundation // Class that contains general constants used in different classes class AppConstants { static let APP_GROUP_ID = "group.com.$(unique_id).LoopFollow" + + /// Extracts the app suffix from the bundle identifier + /// Bundle identifier format: com.$(unique_id).LoopFollow$(app_suffix) + /// Returns the suffix part (e.g., "2" for "com.example.LoopFollow2") + static var appSuffix: String { + guard let bundleId = Bundle.main.bundleIdentifier else { + return "" + } + + // Extract suffix from bundle identifier + // Pattern: com.$(unique_id).LoopFollow$(app_suffix) + let pattern = "LoopFollow(.+)$" + if let regex = try? NSRegularExpression(pattern: pattern) { + let range = NSRange(location: 0, length: bundleId.utf16.count) + if let match = regex.firstMatch(in: bundleId, options: [], range: range) { + let suffixRange = match.range(at: 1) + if let swiftRange = Range(suffixRange, in: bundleId) { + let suffix = String(bundleId[swiftRange]) + return suffix.isEmpty ? "" : "_\(suffix)" + } + } + } + + return "" + } + + /// Returns a unique identifier for this app instance based on the app suffix + static var appInstanceId: String { + return "LoopFollow\(appSuffix)" + } } diff --git a/LoopFollow/Helpers/Views/QRCodeDisplayView.swift b/LoopFollow/Helpers/Views/QRCodeDisplayView.swift index 49b22e59f..014024f3f 100644 --- a/LoopFollow/Helpers/Views/QRCodeDisplayView.swift +++ b/LoopFollow/Helpers/Views/QRCodeDisplayView.swift @@ -42,12 +42,6 @@ struct QRCodeDisplayView: View { .scaleEffect(1.5) ) } - - Text("Scan this QR code with another LoopFollow app to import remote command settings") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 20) } .onAppear { generateQRCode() diff --git a/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift b/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift index 33eb13aeb..20dd2acee 100644 --- a/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift +++ b/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift @@ -3,46 +3,62 @@ import AVFoundation import SwiftUI +import UIKit struct SimpleQRCodeScannerView: UIViewControllerRepresentable { @Environment(\.presentationMode) var presentationMode var completion: (Result) -> Void - // MARK: - Coordinator + func makeUIViewController(context _: Context) -> UINavigationController { + let scannerVC = SimpleQRCodeScannerViewController { result in + completion(result) + } - class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { - var parent: SimpleQRCodeScannerView - var session: AVCaptureSession? + let navController = UINavigationController(rootViewController: scannerVC) - init(parent: SimpleQRCodeScannerView) { - self.parent = parent + // Apply dark mode if needed + if Storage.shared.forceDarkMode.value { + scannerVC.overrideUserInterfaceStyle = .dark + navController.overrideUserInterfaceStyle = .dark } - func metadataOutput(_: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from _: AVCaptureConnection) { - if let session, session.isRunning { - if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, - metadataObject.type == .qr, - let stringValue = metadataObject.stringValue - { - DispatchQueue.global(qos: .userInitiated).async { - session.stopRunning() - } - parent.completion(.success(stringValue)) - } - } - } + return navController + } + + func updateUIViewController(_: UINavigationController, context _: Context) {} +} + +class SimpleQRCodeScannerViewController: UIViewController { + private var session: AVCaptureSession? + private var completion: (Result) -> Void + + init(completion: @escaping (Result) -> Void) { + self.completion = completion + super.init(nibName: nil, bundle: nil) } - // MARK: - UIViewControllerRepresentable Methods + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Add cancel button + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(cancelTapped) + ) + navigationItem.title = "Scan QR Code" - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) + setupCamera() } - func makeUIViewController(context: Context) -> UIViewController { - let controller = UIViewController() + private func setupCamera() { let session = AVCaptureSession() - context.coordinator.session = session // Assign session to coordinator + self.session = session guard let videoCaptureDevice = AVCaptureDevice.default(for: .video), let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), @@ -50,7 +66,7 @@ struct SimpleQRCodeScannerView: UIViewControllerRepresentable { else { let error = NSError(domain: "QRCodeScannerError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to set up camera input."]) completion(.failure(error)) - return controller + return } session.addInput(videoInput) @@ -58,33 +74,54 @@ struct SimpleQRCodeScannerView: UIViewControllerRepresentable { let metadataOutput = AVCaptureMetadataOutput() if session.canAddOutput(metadataOutput) { session.addOutput(metadataOutput) - metadataOutput.setMetadataObjectsDelegate(context.coordinator, queue: DispatchQueue.main) + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) metadataOutput.metadataObjectTypes = [.qr] } else { let error = NSError(domain: "QRCodeScannerError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to set up metadata output."]) completion(.failure(error)) - return controller + return } let previewLayer = AVCaptureVideoPreviewLayer(session: session) - previewLayer.frame = controller.view.layer.bounds + previewLayer.frame = view.layer.bounds previewLayer.videoGravity = .resizeAspectFill - controller.view.layer.addSublayer(previewLayer) + view.layer.addSublayer(previewLayer) DispatchQueue.global(qos: .userInitiated).async { session.startRunning() } - - return controller } - func updateUIViewController(_: UIViewController, context _: Context) {} - - func dismantleUIViewController(_: UIViewController, coordinator: Coordinator) { + @objc private func cancelTapped() { DispatchQueue.global(qos: .userInitiated).async { - if let session = coordinator.session, session.isRunning { + if let session = self.session, session.isRunning { session.stopRunning() } } + let error = NSError(domain: "QRCodeScannerError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Scanning cancelled by user."]) + completion(.failure(error)) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + if let previewLayer = view.layer.sublayers?.first as? AVCaptureVideoPreviewLayer { + previewLayer.frame = view.layer.bounds + } + } +} + +extension SimpleQRCodeScannerViewController: AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput(_: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from _: AVCaptureConnection) { + if let session, session.isRunning { + if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + metadataObject.type == .qr, + let stringValue = metadataObject.stringValue + { + DispatchQueue.global(qos: .userInitiated).async { + session.stopRunning() + } + completion(.success(stringValue)) + } + } } } diff --git a/LoopFollow/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift index e138b6192..cdeaffc41 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsView.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -12,6 +12,7 @@ struct NightscoutSettingsView: View { urlSection tokenSection statusSection + importSection } .onDisappear { viewModel.dismiss() @@ -54,4 +55,16 @@ struct NightscoutSettingsView: View { Text(viewModel.nightscoutStatus) } } + + private var importSection: some View { + Section(header: Text("Import Settings")) { + NavigationLink(destination: ImportExportSettingsView()) { + HStack { + Image(systemName: "square.and.arrow.down") + .foregroundColor(.blue) + Text("Import Settings from QR Code or iCloud") + } + } + } + } } diff --git a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift index e09dfc980..bdc270dc6 100644 --- a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift +++ b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift @@ -145,7 +145,7 @@ struct RemoteCommandSettings: Codable { } /// Checks if the settings are valid for the given remote type - func isValid() -> Bool { + func hasValidSettings() -> Bool { switch remoteType { case .none: return true diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 1214dcbcf..53f763286 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -1,8 +1,10 @@ // LoopFollow // RemoteSettingsView.swift +import AVFoundation import HealthKit import SwiftUI +import UIKit struct RemoteSettingsView: View { @ObservedObject var viewModel: RemoteSettingsViewModel @@ -18,7 +20,6 @@ struct RemoteSettingsView: View { enum AlertType { case validation - case qrCodeError case urlTokenValidation case urlTokenUpdate } @@ -61,34 +62,21 @@ struct RemoteSettingsView: View { .foregroundColor(.secondary) } - // MARK: - QR Code Sharing Section + // MARK: - Import/Export Settings Section Section { - if viewModel.remoteType == .none { - Button(action: { - viewModel.isShowingQRCodeScanner = true - }) { - HStack { - Image(systemName: "qrcode.viewfinder") - Text("Import Remote Settings from QR Code") - } - } - .buttonStyle(.borderedProminent) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - } else { - Button(action: { - viewModel.isShowingQRCodeDisplay = true - }) { - HStack { - Image(systemName: "qrcode") - Text("Export Remote Settings as QR Code") - } + NavigationLink(destination: ImportExportSettingsView()) { + HStack { + Image(systemName: "square.and.arrow.down") + .foregroundColor(.blue) + Text("Import/Export Settings") + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.caption) } - .buttonStyle(.borderedProminent) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) } + .buttonStyle(.plain) } // MARK: - Meal Section (for TRC only) @@ -294,12 +282,6 @@ struct RemoteSettingsView: View { message: Text(alertMessage ?? "Invalid input."), dismissButton: .default(Text("OK")) ) - case .qrCodeError: - return Alert( - title: Text("QR Code Error"), - message: Text(alertMessage ?? "An error occurred while processing the QR code."), - dismissButton: .default(Text("OK")) - ) case .urlTokenValidation: return Alert( title: Text("URL/Token Validation"), @@ -325,30 +307,6 @@ struct RemoteSettingsView: View { viewModel.handleLoopAPNSQRCodeScanResult(result) } } - .sheet(isPresented: $viewModel.isShowingQRCodeScanner) { - SimpleQRCodeScannerView { result in - viewModel.handleRemoteCommandQRCodeScanResult(result) - } - } - .sheet(isPresented: $viewModel.isShowingQRCodeDisplay) { - NavigationView { - VStack { - if let qrCodeString = viewModel.generateQRCodeForCurrentSettings() { - QRCodeDisplayView(qrCodeString: qrCodeString) - .padding() - } else { - Text("Failed to generate QR code") - .foregroundColor(.red) - .padding() - } - } - .navigationTitle("Share Remote Settings") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(trailing: Button("Done") { - viewModel.isShowingQRCodeDisplay = false - }) - } - } .sheet(isPresented: $viewModel.showURLTokenValidation) { NavigationView { URLTokenValidationView( @@ -380,15 +338,6 @@ struct RemoteSettingsView: View { let now = Date().timeIntervalSince1970 otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod))) } - .onReceive(viewModel.$qrCodeErrorMessage) { errorMessage in - if let errorMessage = errorMessage, !errorMessage.isEmpty { - handleQRCodeError(errorMessage) - // Clear the error message after showing the alert - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - viewModel.qrCodeErrorMessage = nil - } - } - } .onReceive(viewModel.$showURLTokenValidation) { showValidation in if showValidation { // The sheet will be shown automatically due to the binding @@ -429,14 +378,6 @@ struct RemoteSettingsView: View { showAlert = true } - // MARK: - QR Code Error Handler - - private func handleQRCodeError(_ message: String) { - alertMessage = message - alertType = .qrCodeError - showAlert = true - } - private var guardrailsSection: some View { Section(header: Text("Guardrails")) { HStack { diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 37b3ef151..082f71877 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -34,12 +34,6 @@ class RemoteSettingsViewModel: ObservableObject { @Published var isShowingLoopAPNSScanner: Bool = false @Published var loopAPNSErrorMessage: String? - // MARK: - QR Code Sharing Properties - - @Published var isShowingQRCodeScanner: Bool = false - @Published var isShowingQRCodeDisplay: Bool = false - @Published var qrCodeErrorMessage: String? - // MARK: - URL/Token Validation Properties @Published var pendingSettings: RemoteCommandSettings? @@ -234,48 +228,6 @@ class RemoteSettingsViewModel: ObservableObject { } } - // MARK: - QR Code Sharing Methods - - func handleRemoteCommandQRCodeScanResult(_ result: Result) { - DispatchQueue.main.async { - switch result { - case let .success(jsonString): - if let settings = RemoteCommandSettings.decodeFromJSON(jsonString) { - if settings.isValid() { - // Check URL and token compatibility - let validation = settings.validateCompatibilityWithCurrentStorage() - - if validation.isCompatible { - // No conflicts, apply settings directly - settings.applyToStorage() - self.updateViewModelFromStorage() - LogManager.shared.log(category: .remote, message: "Remote command settings imported from QR code") - } else { - // Conflicts detected, show validation view - self.pendingSettings = settings - self.validationMessage = validation.message - self.shouldPromptForURL = validation.shouldPromptForURL - self.shouldPromptForToken = validation.shouldPromptForToken - self.showURLTokenValidation = true - } - } else { - self.qrCodeErrorMessage = "Invalid remote command settings in QR code" - } - } else { - self.qrCodeErrorMessage = "Failed to decode remote command settings from QR code" - } - case let .failure(error): - self.qrCodeErrorMessage = "Scanning failed: \(error.localizedDescription)" - } - self.isShowingQRCodeScanner = false - } - } - - func generateQRCodeForCurrentSettings() -> String? { - let settings = RemoteCommandSettings.fromCurrentStorage() - return settings.encodeToJSON() - } - // MARK: - Public Methods for View Access /// Updates the view model properties from storage (accessible from view) diff --git a/LoopFollow/Settings/DexcomSettingsView.swift b/LoopFollow/Settings/DexcomSettingsView.swift index 5b746d680..c95d9dc1f 100644 --- a/LoopFollow/Settings/DexcomSettingsView.swift +++ b/LoopFollow/Settings/DexcomSettingsView.swift @@ -33,9 +33,23 @@ struct DexcomSettingsView: View { } .pickerStyle(SegmentedPickerStyle()) } + + importSection } } .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil) .navigationBarTitle("Dexcom Settings", displayMode: .inline) } + + private var importSection: some View { + Section(header: Text("Import Settings")) { + NavigationLink(destination: ImportExportSettingsView()) { + HStack { + Image(systemName: "square.and.arrow.down") + .foregroundColor(.blue) + Text("Import Settings from QR Code or iCloud") + } + } + } + } } diff --git a/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift b/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift new file mode 100644 index 000000000..8b3bda876 --- /dev/null +++ b/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift @@ -0,0 +1,248 @@ +// LoopFollow +// AlarmSelectionView.swift + +import SwiftUI + +struct AlarmSelectionView: View { + @ObservedObject private var allAlarms = Storage.shared.alarms + @State private var selectedAlarmIds: Set = [] + @State private var showingCharacterLimitAlert = false + + let exportedAlarmIds: Set + let onConfirm: ([Alarm]) -> Void + let onCancel: () -> Void + + // QR Code character limits (calibrated for alarm exports) + private let maxQRCharacters = 2000 + private let maxRecommendedAlarms = 5 + + // Computed property for actual character count (used internally) + private var actualCharacterCount: Int { + let selectedAlarms = allAlarms.value.filter { selectedAlarmIds.contains($0.id) } + let testExport = AlarmSettingsExport( + version: AppVersionManager().version(), + alarms: selectedAlarms, + alarmConfiguration: Storage.shared.alarmConfiguration.value + ) + + if let jsonString = testExport.encodeToJSON() { + return jsonString.count + } + return 0 + } + + private var exceedsCharacterLimit: Bool { + return actualCharacterCount > maxQRCharacters + } + + var body: some View { + NavigationView { + VStack { + // Character count indicator + characterCountView + + List { + ForEach(allAlarms.value) { alarm in + AlarmSelectionRow( + alarm: alarm, + isSelected: selectedAlarmIds.contains(alarm.id), + isExported: exportedAlarmIds.contains(alarm.id), + isDisabled: !canSelectAlarm(alarm), + onToggle: { toggleAlarm(alarm) } + ) + } + } + } + .navigationTitle("Select Alarms to Export") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems( + leading: Button("Cancel") { + onCancel() + }, + trailing: Button("Export") { + let selectedAlarms = allAlarms.value.filter { selectedAlarmIds.contains($0.id) } + onConfirm(selectedAlarms) + } + .disabled(selectedAlarmIds.isEmpty) + ) + } + .alert("Character Limit Reached", isPresented: $showingCharacterLimitAlert) { + Button("OK") {} + } message: { + Text("Adding this alarm would exceed the QR code character limit. Please remove some alarms first.") + } + } + + private var characterCountView: some View { + VStack(spacing: 8) { + HStack { + Text("Selected Alarms: \(selectedAlarmIds.count)") + .font(.headline) + Spacer() + Text("Max \(maxRecommendedAlarms) alarms at a time") + .font(.caption) + .foregroundColor(.secondary) + } + + if selectedAlarmIds.count > 0 { + ProgressView(value: Double(selectedAlarmIds.count), total: Double(maxRecommendedAlarms)) + .progressViewStyle(LinearProgressViewStyle(tint: progressBarColor)) + } + } + .padding() + .background(Color(.systemGray6)) + } + + private var progressBarColor: Color { + if exceedsCharacterLimit { + return .red + } else if selectedAlarmIds.count >= maxRecommendedAlarms { + return .blue + } else { + return .green + } + } + + private func canSelectAlarm(_ alarm: Alarm) -> Bool { + let testSelection = selectedAlarmIds.union([alarm.id]) + let testAlarms = allAlarms.value.filter { testSelection.contains($0.id) } + + // Block if we're at or over the recommended alarm limit + if testAlarms.count > maxRecommendedAlarms { + return false + } + + // Test actual character count to prevent exceeding QR code limit + let testExport = AlarmSettingsExport( + version: AppVersionManager().version(), + alarms: testAlarms, + alarmConfiguration: Storage.shared.alarmConfiguration.value + ) + + if let jsonString = testExport.encodeToJSON() { + return jsonString.count <= maxQRCharacters + } + + return false + } + + private func toggleAlarm(_ alarm: Alarm) { + if selectedAlarmIds.contains(alarm.id) { + selectedAlarmIds.remove(alarm.id) + } else { + if canSelectAlarm(alarm) { + selectedAlarmIds.insert(alarm.id) + } else { + showingCharacterLimitAlert = true + } + } + } +} + +struct AlarmSelectionRow: View { + let alarm: Alarm + let isSelected: Bool + let isExported: Bool + let isDisabled: Bool + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(alarm.name) + .font(.headline) + .foregroundColor(isDisabled ? .secondary : .primary) + + if isExported { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.caption) + } + } + + Text(alarmTypeDescription) + .font(.caption) + .foregroundColor(.secondary) + + if let aboveBG = alarm.aboveBG { + Text("Above: \(String(format: "%.0f", aboveBG))") + .font(.caption2) + .foregroundColor(.secondary) + } + + if let belowBG = alarm.belowBG { + Text("Below: \(String(format: "%.0f", belowBG))") + .font(.caption2) + .foregroundColor(.secondary) + } + } + + Spacer() + + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? .blue : (isDisabled ? .secondary : .primary)) + .font(.title2) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + .disabled(isDisabled && !isSelected) + } + + private var alarmTypeDescription: String { + switch alarm.type { + case .temporary: + return "Temporary Alert" + case .iob: + return "IOB Alert" + case .cob: + return "COB Alert" + case .low: + return "Low BG Alert" + case .high: + return "High BG Alert" + case .fastDrop: + return "Fast Drop Alert" + case .fastRise: + return "Fast Rise Alert" + case .missedReading: + return "Missed Reading Alert" + case .notLooping: + return "Not Looping Alert" + case .missedBolus: + return "Missed Bolus Alert" + case .sensorChange: + return "Sensor Change Alert" + case .pumpChange: + return "Pump Change Alert" + case .pump: + return "Pump Insulin Alert" + case .battery: + return "Low Battery" + case .batteryDrop: + return "Battery Drop" + case .recBolus: + return "Rec. Bolus" + case .overrideStart: + return "Override Started" + case .overrideEnd: + return "Override Ended" + case .tempTargetStart: + return "Temp Target Started" + case .tempTargetEnd: + return "Temp Target Ended" + case .buildExpire: + return "Looping app expiration" + } + } +} + +#Preview { + AlarmSelectionView( + exportedAlarmIds: [], + onConfirm: { _ in }, + onCancel: {} + ) +} diff --git a/LoopFollow/Settings/ImportExport/ExportableSettings.swift b/LoopFollow/Settings/ImportExport/ExportableSettings.swift new file mode 100644 index 000000000..c7642d2dc --- /dev/null +++ b/LoopFollow/Settings/ImportExport/ExportableSettings.swift @@ -0,0 +1,318 @@ +// LoopFollow +// ExportableSettings.swift + +import Foundation +import HealthKit + +// MARK: - Nightscout Settings Export + +struct NightscoutSettingsExport: Codable { + let version: String + let url: String + let token: String + let units: String + + static func fromCurrentStorage() -> NightscoutSettingsExport { + let storage = Storage.shared + return NightscoutSettingsExport( + version: AppVersionManager().version(), + url: storage.url.value, + token: storage.token.value, + units: storage.units.value + ) + } + + func applyToStorage() { + let storage = Storage.shared + storage.url.value = url + storage.token.value = token + storage.units.value = units + } + + func encodeToJSON() -> String? { + do { + let data = try JSONEncoder().encode(self) + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } + + func hasValidSettings() -> Bool { + return !url.isEmpty && !token.isEmpty + } +} + +// MARK: - Alarm Settings Export + +struct AlarmSettingsExport: Codable { + let version: String + let alarms: [Alarm] + let alarmConfiguration: AlarmConfiguration + + static func fromCurrentStorage() -> AlarmSettingsExport { + let storage = Storage.shared + return AlarmSettingsExport( + version: AppVersionManager().version(), + alarms: storage.alarms.value, + alarmConfiguration: storage.alarmConfiguration.value + ) + } + + static func fromSelectedAlarms(_ selectedAlarms: [Alarm]) -> AlarmSettingsExport { + let storage = Storage.shared + return AlarmSettingsExport( + version: AppVersionManager().version(), + alarms: selectedAlarms, + alarmConfiguration: storage.alarmConfiguration.value + ) + } + + func applyToStorage() { + let storage = Storage.shared + // When importing, merge with existing alarms instead of replacing + var existingAlarms = storage.alarms.value + var updatedAlarms: [Alarm] = [] + + // Keep existing alarms that aren't being imported + for existingAlarm in existingAlarms { + if !alarms.contains(where: { $0.id == existingAlarm.id }) { + updatedAlarms.append(existingAlarm) + } + } + + // Add imported alarms + updatedAlarms.append(contentsOf: alarms) + + storage.alarms.value = updatedAlarms + storage.alarmConfiguration.value = alarmConfiguration + } + + func encodeToJSON() -> String? { + do { + let data = try JSONEncoder().encode(self) + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } + + func hasValidSettings() -> Bool { + return !alarms.isEmpty + } +} + +// MARK: - Remote Settings Export + +struct RemoteSettingsExport: Codable { + let version: String + let remoteType: RemoteType + let user: String + let sharedSecret: String + let apnsKey: String + let keyId: String + let teamId: String? + let maxBolus: Double + let maxCarbs: Double + let maxProtein: Double + let maxFat: Double + let mealWithBolus: Bool + let mealWithFatProtein: Bool + let productionEnvironment: Bool + let loopAPNSQrCodeURL: String + let device: String + + static func fromCurrentStorage() -> RemoteSettingsExport { + let storage = Storage.shared + return RemoteSettingsExport( + version: AppVersionManager().version(), + remoteType: storage.remoteType.value, + user: storage.user.value, + sharedSecret: storage.sharedSecret.value, + apnsKey: storage.apnsKey.value, + keyId: storage.keyId.value, + teamId: storage.teamId.value, + maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()), + maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()), + maxProtein: storage.maxProtein.value.doubleValue(for: .gram()), + maxFat: storage.maxFat.value.doubleValue(for: .gram()), + mealWithBolus: storage.mealWithBolus.value, + mealWithFatProtein: storage.mealWithFatProtein.value, + productionEnvironment: storage.productionEnvironment.value, + loopAPNSQrCodeURL: storage.loopAPNSQrCodeURL.value, + device: storage.device.value + ) + } + + func applyToStorage() { + let storage = Storage.shared + + storage.remoteType.value = remoteType + storage.user.value = user + storage.sharedSecret.value = sharedSecret + storage.apnsKey.value = apnsKey + storage.keyId.value = keyId + storage.teamId.value = teamId + storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) + storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs) + storage.maxProtein.value = HKQuantity(unit: .gram(), doubleValue: maxProtein) + storage.maxFat.value = HKQuantity(unit: .gram(), doubleValue: maxFat) + storage.mealWithBolus.value = mealWithBolus + storage.mealWithFatProtein.value = mealWithFatProtein + storage.productionEnvironment.value = productionEnvironment + storage.loopAPNSQrCodeURL.value = loopAPNSQrCodeURL + + // Set device temporarily from import (will be overridden by Nightscout connection) + if !device.isEmpty { + storage.device.value = device + } else { + // Fallback to automatic device type based on remote type + switch remoteType { + case .loopAPNS: + storage.device.value = "Loop" + case .trc: + storage.device.value = "Trio" + case .nightscout: + // For Nightscout, we don't automatically set device type + // as it should be determined by the actual connection + break + case .none: + break + } + } + } + + func encodeToJSON() -> String? { + do { + let data = try JSONEncoder().encode(self) + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } + + func hasValidSettings() -> Bool { + switch remoteType { + case .none: + return true + case .nightscout: + return !user.isEmpty + case .trc: + return !user.isEmpty && !sharedSecret.isEmpty && !apnsKey.isEmpty && !keyId.isEmpty + case .loopAPNS: + return !keyId.isEmpty && !apnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty + } + } + + /// Validates compatibility with current storage settings + func validateCompatibilityWithCurrentStorage() -> (isCompatible: Bool, shouldPromptForURL: Bool, shouldPromptForToken: Bool, message: String) { + let storage = Storage.shared + var message = "" + var shouldPromptForURL = false + var shouldPromptForToken = false + + // Check if there are existing remote settings + let currentRemoteType = storage.remoteType.value + let currentUser = storage.user.value + + // If remote type is changing, warn user + if currentRemoteType != .none && currentRemoteType != remoteType { + message += "Remote type is changing from \(currentRemoteType.rawValue) to \(remoteType.rawValue). This may affect your remote commands.\n" + } + + // If user is changing, warn user + if !currentUser.isEmpty && currentUser != user { + message += "Remote user is changing from '\(currentUser)' to '\(user)'. This may affect your remote commands.\n" + } + + // For TRC and LoopAPNS, check if key details are changing + if remoteType == .trc || remoteType == .loopAPNS { + let currentKeyId = storage.keyId.value + let currentApnsKey = storage.apnsKey.value + + if !currentKeyId.isEmpty, currentKeyId != keyId { + message += "APNS Key ID is changing. This may affect your remote commands.\n" + } + + if !currentApnsKey.isEmpty, currentApnsKey != apnsKey { + message += "APNS Key is changing. This may affect your remote commands.\n" + } + } + + // For TRC, check shared secret + if remoteType == .trc { + let currentSharedSecret = storage.sharedSecret.value + if !currentSharedSecret.isEmpty, currentSharedSecret != sharedSecret { + message += "Shared secret is changing. This may affect your remote commands.\n" + } + } + + // For LoopAPNS, check team ID and QR code URL + if remoteType == .loopAPNS { + let currentTeamId = storage.teamId.value + let currentQrCodeURL = storage.loopAPNSQrCodeURL.value + + if let teamId = teamId, let currentTeamId = currentTeamId, teamId != currentTeamId { + message += "Team ID is changing. This may affect your remote commands.\n" + } + + if !currentQrCodeURL.isEmpty, currentQrCodeURL != loopAPNSQrCodeURL { + message += "Loop APNS QR Code URL is changing. This may affect your remote commands.\n" + } + } + + // If both have tokens but they don't match, show warning + let hasCurrentToken = !storage.token.value.isEmpty + if hasCurrentToken { + message += "Note: This import does not include Nightscout token settings. Your current Nightscout token will be preserved.\n" + } + + let isCompatible = !shouldPromptForURL && !shouldPromptForToken + + return (isCompatible, shouldPromptForURL, shouldPromptForToken, message) + } +} + +// MARK: - Combined Settings Export + +struct CombinedSettingsExport: Codable { + let version: String + let appVersion: String + let nightscout: NightscoutSettingsExport? + let remote: RemoteSettingsExport? + let alarms: AlarmSettingsExport? + let exportType: String + let timestamp: Date + + init(nightscout: NightscoutSettingsExport? = nil, + remote: RemoteSettingsExport? = nil, + alarms: AlarmSettingsExport? = nil, + exportType: String) + { + version = "1.0" + appVersion = AppVersionManager().version() + self.nightscout = nightscout + self.remote = remote + self.alarms = alarms + self.exportType = exportType + timestamp = Date() + } + + func encodeToJSON() -> String? { + do { + let data = try JSONEncoder().encode(self) + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } + + static func decodeFromJSON(_ jsonString: String) -> CombinedSettingsExport? { + guard let data = jsonString.data(using: .utf8) else { return nil } + do { + return try JSONDecoder().decode(CombinedSettingsExport.self, from: data) + } catch { + return nil + } + } +} diff --git a/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift b/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift new file mode 100644 index 000000000..c5083a7a1 --- /dev/null +++ b/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift @@ -0,0 +1,318 @@ +// LoopFollow +// ImportExportSettingsView.swift + +import AVFoundation +import SwiftUI +import UIKit + +struct ImportExportSettingsView: View { + @StateObject private var viewModel = ImportExportSettingsViewModel() + + var body: some View { + NavigationView { + List { + // MARK: - Import Section + + Section("Import Settings") { + Button(action: { + viewModel.isShowingQRCodeScanner = true + }) { + HStack { + Image(systemName: "qrcode.viewfinder") + .foregroundColor(.blue) + Text("Scan QR Code to Import Settings") + } + } + .buttonStyle(.plain) + } + + // MARK: - Export Section + + Section("Export Settings To QR Code") { + ForEach(ImportExportSettingsViewModel.ExportType.allCases, id: \.self) { exportType in + Button(action: { + if exportType == .alarms { + viewModel.showAlarmSelection() + } else { + viewModel.exportType = exportType + if let qrString = viewModel.generateQRCodeForExport() { + viewModel.qrCodeString = qrString + viewModel.isShowingQRCodeDisplay = true + } + } + }) { + HStack { + Image(systemName: exportType.icon) + .foregroundColor(.blue) + Text("Export \(exportType.rawValue)") + Spacer() + Image(systemName: exportType == .alarms ? "list.bullet" : "qrcode") + .foregroundColor(.secondary) + } + } + .buttonStyle(.plain) + } + } + + // MARK: - iCloud Section + + Section("iCloud Import") { + Button(action: { + viewModel.importFromiCloud() + }) { + HStack { + Image(systemName: "icloud.and.arrow.down") + .foregroundColor(.green) + Text("Import Settings from iCloud") + } + } + .buttonStyle(.plain) + } + + Section("iCloud Export") { + Button(action: { + viewModel.exportToiCloud() + }) { + HStack { + Image(systemName: "icloud.and.arrow.up") + .foregroundColor(.blue) + Text("Export All Settings to iCloud") + } + } + .buttonStyle(.plain) + } + + // MARK: - Status Message + + if !viewModel.qrCodeErrorMessage.isEmpty { + Section { + let isSuccess = viewModel.qrCodeErrorMessage.contains("successfully") || viewModel.qrCodeErrorMessage.contains("Successfully imported") + let displayText = isSuccess ? "✅ \(viewModel.qrCodeErrorMessage)" : viewModel.qrCodeErrorMessage + + Text(displayText) + .foregroundColor(isSuccess ? .green : .red) + .font(.caption) + } + } + } + .navigationTitle("Import/Export Settings") + .navigationBarTitleDisplayMode(.inline) + } + .sheet(isPresented: $viewModel.isShowingQRCodeScanner) { + SimpleQRCodeScannerView { result in + viewModel.handleQRCodeScanResult(result) + } + } + .sheet(isPresented: $viewModel.isShowingQRCodeDisplay) { + NavigationView { + VStack { + if !viewModel.qrCodeString.isEmpty { + QRCodeDisplayView( + qrCodeString: viewModel.qrCodeString, + size: CGSize(width: 300, height: 300) + ) + .padding() + + Text("Scan this QR code with another LoopFollow app to import \(viewModel.exportType.rawValue.lowercased())") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } else { + Text("Failed to generate QR code") + .foregroundColor(.red) + .padding() + } + } + .navigationTitle("Export \(viewModel.exportType.rawValue)") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: Button("Done") { + viewModel.isShowingQRCodeDisplay = false + }) + } + } + .sheet(isPresented: $viewModel.isShowingAlarmSelection) { + AlarmSelectionView( + exportedAlarmIds: viewModel.exportedAlarmIds, + onConfirm: { selectedAlarms in + viewModel.exportSelectedAlarms(selectedAlarms) + }, + onCancel: { + viewModel.cancelAlarmSelection() + } + ) + } + .onDisappear { + viewModel.resetExportedAlarms() + } + .sheet(isPresented: $viewModel.showImportConfirmation) { + ImportConfirmationView(viewModel: viewModel) + } + } +} + +struct ImportConfirmationView: View { + @ObservedObject var viewModel: ImportExportSettingsViewModel + + var body: some View { + NavigationView { + VStack(spacing: 20) { + // Header + VStack(spacing: 8) { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 50)) + .foregroundColor(.blue) + + Text("Import Settings") + .font(.title2) + .fontWeight(.semibold) + + Text("Review the settings that will be imported") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, 20) + + // Settings Preview + if let preview = viewModel.importPreview { + VStack(alignment: .leading, spacing: 16) { + Text("Settings to Import") + .font(.headline) + .padding(.horizontal) + + VStack(spacing: 12) { + if let url = preview.nightscoutURL, !url.isEmpty { + SettingRowView( + icon: "network", + title: "Nightscout URL", + value: url, + color: .blue + ) + } + + if let username = preview.dexcomUsername, !username.isEmpty { + SettingRowView( + icon: "person.circle", + title: "Dexcom Username", + value: username, + color: .green + ) + } + + if let remoteType = preview.remoteType, !remoteType.isEmpty, remoteType != "None" { + SettingRowView( + icon: "antenna.radiowaves.left.and.right", + title: "Remote Type", + value: remoteType, + color: .orange + ) + } + + if preview.alarmCount > 0 { + SettingRowView( + icon: "bell", + title: "Alarms", + value: "\(preview.alarmCount) alarm(s): \(preview.alarmNames.joined(separator: ", "))", + color: .red + ) + } + } + .padding(.horizontal) + } + } + + // Warning + VStack(spacing: 8) { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + Text("Warning") + .fontWeight(.semibold) + .foregroundColor(.orange) + } + + Text("This will overwrite your current settings") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + + Spacer() + + // Action Buttons + VStack(spacing: 12) { + Button(action: { + viewModel.confirmImport() + }) { + HStack { + Image(systemName: "checkmark") + Text("Import Settings") + } + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .cornerRadius(12) + } + + Button(action: { + viewModel.cancelImport() + }) { + HStack { + Image(systemName: "xmark") + Text("Cancel") + } + .font(.headline) + .foregroundColor(.primary) + .frame(maxWidth: .infinity) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } + } + .padding(.horizontal) + .padding(.bottom, 20) + } + .navigationBarHidden(true) + } + } +} + +struct SettingRowView: View { + let icon: String + let title: String + let value: String + let color: Color + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title2) + .foregroundColor(color) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline) + .fontWeight(.medium) + + Text(value) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + + Spacer() + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + } +} + +#Preview { + ImportExportSettingsView() +} diff --git a/LoopFollow/Settings/ImportExport/ImportExportSettingsViewModel.swift b/LoopFollow/Settings/ImportExport/ImportExportSettingsViewModel.swift new file mode 100644 index 000000000..8968d120e --- /dev/null +++ b/LoopFollow/Settings/ImportExport/ImportExportSettingsViewModel.swift @@ -0,0 +1,361 @@ +// LoopFollow +// ImportExportSettingsViewModel.swift + +import Foundation +import SwiftUI + +struct ImportPreview { + let nightscoutURL: String? + let dexcomUsername: String? + let remoteType: String? + let alarmCount: Int + let alarmNames: [String] +} + +class ImportExportSettingsViewModel: ObservableObject { + // MARK: - Published Properties + + @Published var isShowingQRCodeScanner = false + @Published var isShowingQRCodeDisplay = false + @Published var isShowingAlarmSelection = false + @Published var qrCodeErrorMessage = "" + @Published var qrCodeString = "" + @Published var exportType: ExportType = .nightscout + @Published var exportedAlarmIds: Set = [] + @Published var importPreview: ImportPreview? + @Published var showImportConfirmation = false + @Published var pendingImportSettings: CombinedSettingsExport? + @Published var pendingImportSource: String = "" + + // MARK: - Export Types + + enum ExportType: String, CaseIterable { + case nightscout = "Nightscout Settings" + case remote = "Remote Settings" + case alarms = "Alarm Settings" + + var icon: String { + switch self { + case .nightscout: return "network" + case .remote: return "antenna.radiowaves.left.and.right" + case .alarms: return "bell" + } + } + } + + // MARK: - QR Code Methods + + func handleQRCodeScanResult(_ result: Result) { + DispatchQueue.main.async { + switch result { + case let .success(jsonString): + self.processImportedSettings(jsonString) + case let .failure(error): + self.qrCodeErrorMessage = "Scanning failed: \(error.localizedDescription)" + } + self.isShowingQRCodeScanner = false + } + } + + private func processImportedSettings(_ jsonString: String) { + do { + LogManager.shared.log(category: .general, message: "Processing QR code data: \(jsonString.prefix(200))...") + + guard let data = jsonString.data(using: .utf8) else { + qrCodeErrorMessage = "Invalid QR code data" + return + } + + LogManager.shared.log(category: .general, message: "QR code data converted to Data, size: \(data.count) bytes") + + // Try to decode as JSON first to see what we get + do { + let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + LogManager.shared.log(category: .general, message: "JSON parsing successful: \(jsonObject)") + } catch { + LogManager.shared.log(category: .general, message: "JSON parsing failed: \(error.localizedDescription)") + } + + // Use migration manager to handle version compatibility + guard let settings = SettingsMigrationManager.migrateSettings(data) else { + LogManager.shared.log(category: .general, message: "SettingsMigrationManager.migrateSettings returned nil") + qrCodeErrorMessage = "Failed to decode or migrate settings from QR code" + return + } + + LogManager.shared.log(category: .general, message: "QR code decoded successfully. Components: nightscout=\(settings.nightscout != nil), remote=\(settings.remote != nil), alarms=\(settings.alarms != nil)") + + // Check version compatibility + let currentVersion = AppVersionManager().version() + if !SettingsMigrationManager.isCompatibleVersion(settings.appVersion) { + qrCodeErrorMessage = SettingsMigrationManager.getCompatibilityMessage(for: settings.appVersion) + // Still try to apply settings, but warn user + } + + // Store settings and create preview for confirmation + pendingImportSettings = settings + pendingImportSource = "QR code" + createImportPreview(from: settings) + + } catch { + let currentVersion = AppVersionManager().version() + qrCodeErrorMessage = "Import failed. This might be due to a version change (current: \(currentVersion)). Please try exporting settings from the source device again." + LogManager.shared.log(category: .general, message: "QR code import failed: \(error.localizedDescription)") + } + } + + private func applyImportedSettings(_ settings: CombinedSettingsExport, source: String) throws { + var importedComponents: [String] = [] + + // Apply settings based on what's available + if let nightscout = settings.nightscout { + // Check if Nightscout settings are already configured + let currentNightscout = NightscoutSettingsExport.fromCurrentStorage() + if currentNightscout.hasValidSettings() { + // Nightscout is already configured, warn user about overwrite + LogManager.shared.log(category: .general, message: "Warning: Nightscout settings are already configured. Import will overwrite existing Nightscout settings.") + } + + nightscout.applyToStorage() + importedComponents.append("Nightscout settings") + LogManager.shared.log(category: .general, message: "Nightscout settings imported from \(source) (version: \(nightscout.version))") + } + + if let remote = settings.remote { + if remote.hasValidSettings() { + let validation = remote.validateCompatibilityWithCurrentStorage() + if validation.isCompatible { + remote.applyToStorage() + importedComponents.append("Remote settings (\(remote.remoteType.rawValue))") + LogManager.shared.log(category: .general, message: "Remote settings imported from \(source) (version: \(remote.version), type: \(remote.remoteType.rawValue), device: \(remote.device))") + } else { + throw NSError(domain: "SettingsImport", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote settings conflict: \(validation.message)"]) + } + } else { + throw NSError(domain: "SettingsImport", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid remote settings in \(source)"]) + } + } + + if let alarms = settings.alarms { + LogManager.shared.log(category: .general, message: "Attempting to import alarm settings: \(alarms.alarms.count) alarms") + alarms.applyToStorage() + importedComponents.append("Alarm settings (\(alarms.alarms.count) alarms)") + LogManager.shared.log(category: .general, message: "Alarm settings imported from \(source) (version: \(alarms.version), \(alarms.alarms.count) alarms)") + } + + // Update the success message with what was imported + if !importedComponents.isEmpty { + let componentsList = importedComponents.joined(separator: ", ") + qrCodeErrorMessage = "Successfully imported: \(componentsList)" + } + } + + func generateQRCodeForExport() -> String? { + let settings: CombinedSettingsExport? + + switch exportType { + case .nightscout: + let nightscoutSettings = NightscoutSettingsExport.fromCurrentStorage() + if !nightscoutSettings.hasValidSettings() { + qrCodeErrorMessage = "Please configure your Nightscout settings first (URL and Token)" + return nil + } + settings = CombinedSettingsExport( + nightscout: nightscoutSettings, + exportType: exportType.rawValue + ) + case .remote: + let remoteSettings = RemoteSettingsExport.fromCurrentStorage() + if !remoteSettings.hasValidSettings() { + let currentRemoteType = Storage.shared.remoteType.value + if currentRemoteType == .none { + qrCodeErrorMessage = "Please configure your Remote settings first (select a remote type and configure required fields)" + } else { + qrCodeErrorMessage = "Please complete your Remote settings configuration (check required fields for \(currentRemoteType.rawValue))" + } + return nil + } + settings = CombinedSettingsExport( + remote: remoteSettings, + exportType: exportType.rawValue + ) + case .alarms: + let alarmSettings = AlarmSettingsExport.fromCurrentStorage() + LogManager.shared.log(category: .general, message: "Generating alarm export: \(alarmSettings.alarms.count) alarms") + if !alarmSettings.hasValidSettings() { + qrCodeErrorMessage = "Please configure your Alarm settings first" + return nil + } + settings = CombinedSettingsExport( + alarms: alarmSettings, + exportType: exportType.rawValue + ) + } + + return settings?.encodeToJSON() + } + + func showAlarmSelection() { + exportType = .alarms + isShowingAlarmSelection = true + } + + func exportSelectedAlarms(_ selectedAlarms: [Alarm]) { + let settings = AlarmSettingsExport.fromSelectedAlarms(selectedAlarms) + if let qrString = settings.encodeToJSON() { + qrCodeString = qrString + isShowingQRCodeDisplay = true + + // Track which alarms were exported + let exportedIds = Set(selectedAlarms.map { $0.id }) + exportedAlarmIds.formUnion(exportedIds) + } + isShowingAlarmSelection = false + } + + func cancelAlarmSelection() { + isShowingAlarmSelection = false + } + + func resetExportedAlarms() { + exportedAlarmIds.removeAll() + } + + // MARK: - iCloud Methods + + func exportToiCloud() { + // Create a comprehensive settings export for iCloud + let allSettings = CombinedSettingsExport( + nightscout: NightscoutSettingsExport.fromCurrentStorage(), + remote: RemoteSettingsExport.fromCurrentStorage(), + alarms: AlarmSettingsExport.fromCurrentStorage(), + exportType: "All Settings" + ) + + guard let jsonData = allSettings.encodeToJSON()?.data(using: .utf8) else { + qrCodeErrorMessage = "Failed to prepare settings for iCloud export" + return + } + + // Save to iCloud Documents with app suffix + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let fileName = "\(AppConstants.appInstanceId)Settings.json" + let iCloudPath = documentsPath.appendingPathComponent(fileName) + + LogManager.shared.log(category: .general, message: "Attempting to export settings to iCloud") + LogManager.shared.log(category: .general, message: "iCloud documents path: \(documentsPath.path)") + LogManager.shared.log(category: .general, message: "Saving settings file to: \(iCloudPath.path)") + + do { + try jsonData.write(to: iCloudPath) + qrCodeErrorMessage = "Settings exported to iCloud successfully" + LogManager.shared.log(category: .general, message: "All settings exported to iCloud successfully") + } catch { + qrCodeErrorMessage = "Failed to export to iCloud: \(error.localizedDescription)" + } + } + + func importFromiCloud() { + do { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let fileName = "\(AppConstants.appInstanceId)Settings.json" + let iCloudPath = documentsPath.appendingPathComponent(fileName) + + LogManager.shared.log(category: .general, message: "Attempting to import settings from iCloud") + LogManager.shared.log(category: .general, message: "iCloud documents path: \(documentsPath.path)") + LogManager.shared.log(category: .general, message: "Looking for settings file: \(iCloudPath.path)") + + guard FileManager.default.fileExists(atPath: iCloudPath.path) else { + LogManager.shared.log(category: .general, message: "Settings file not found at: \(iCloudPath.path)") + qrCodeErrorMessage = "No settings found in iCloud" + return + } + + LogManager.shared.log(category: .general, message: "Settings file found, attempting to read data") + + let data = try Data(contentsOf: iCloudPath) + guard let settings = SettingsMigrationManager.migrateSettings(data) else { + qrCodeErrorMessage = "Failed to decode settings from iCloud" + return + } + + // Check version compatibility + let currentVersion = AppVersionManager().version() + if !SettingsMigrationManager.isCompatibleVersion(settings.appVersion) { + qrCodeErrorMessage = SettingsMigrationManager.getCompatibilityMessage(for: settings.appVersion) + // Still try to apply settings, but warn user + } + + // Store settings and create preview for confirmation + pendingImportSettings = settings + pendingImportSource = "iCloud" + createImportPreview(from: settings) + + } catch { + let currentVersion = AppVersionManager().version() + qrCodeErrorMessage = "iCloud import failed. This might be due to a version change (current: \(currentVersion)). Please try exporting settings to iCloud again." + LogManager.shared.log(category: .general, message: "iCloud import failed: \(error.localizedDescription)") + } + } + + private func createImportPreview(from settings: CombinedSettingsExport) { + let nightscoutURL = settings.nightscout?.url.isEmpty == false ? settings.nightscout?.url : nil + let dexcomUsername: String? = nil // Dexcom settings are not part of the export structure + let remoteType = settings.remote?.remoteType != .none ? settings.remote?.remoteType.rawValue : nil + let alarmCount = settings.alarms?.alarms.count ?? 0 + let alarmNames = settings.alarms?.alarms.map { $0.name } ?? [] + + // Check if any settings are actually present + let hasAnySettings = (nightscoutURL != nil && !nightscoutURL!.isEmpty) || + (remoteType != nil && !remoteType!.isEmpty && remoteType != "None") || + alarmCount > 0 + + LogManager.shared.log(category: .general, message: "Import preview check - nightscoutURL: \(nightscoutURL ?? "nil"), remoteType: \(remoteType ?? "nil"), alarmCount: \(alarmCount), hasAnySettings: \(hasAnySettings)") + + if hasAnySettings { + LogManager.shared.log(category: .general, message: "Creating import preview with settings") + importPreview = ImportPreview( + nightscoutURL: nightscoutURL, + dexcomUsername: dexcomUsername, + remoteType: remoteType, + alarmCount: alarmCount, + alarmNames: alarmNames + ) + LogManager.shared.log(category: .general, message: "Created importPreview - nightscoutURL: \(importPreview?.nightscoutURL ?? "nil"), remoteType: \(importPreview?.remoteType ?? "nil"), alarmCount: \(importPreview?.alarmCount ?? 0)") + showImportConfirmation = true + LogManager.shared.log(category: .general, message: "Set showImportConfirmation = true") + } else { + LogManager.shared.log(category: .general, message: "No settings found, clearing import data") + // No settings found, show error message and clear any pending data + qrCodeErrorMessage = "No settings found in import data" + showImportConfirmation = false + importPreview = nil + pendingImportSettings = nil + pendingImportSource = "" + LogManager.shared.log(category: .general, message: "Set showImportConfirmation = false") + } + } + + func confirmImport() { + guard let settings = pendingImportSettings else { return } + + do { + try applyImportedSettings(settings, source: pendingImportSource) + } catch { + qrCodeErrorMessage = "Import failed: \(error.localizedDescription)" + } + + // Reset confirmation state + showImportConfirmation = false + importPreview = nil + pendingImportSettings = nil + pendingImportSource = "" + } + + func cancelImport() { + showImportConfirmation = false + importPreview = nil + pendingImportSettings = nil + pendingImportSource = "" + } +} diff --git a/LoopFollow/Settings/ImportExport/SettingsMigrationManager.swift b/LoopFollow/Settings/ImportExport/SettingsMigrationManager.swift new file mode 100644 index 000000000..9127863dd --- /dev/null +++ b/LoopFollow/Settings/ImportExport/SettingsMigrationManager.swift @@ -0,0 +1,83 @@ +// LoopFollow +// SettingsMigrationManager.swift + +import Foundation + +class SettingsMigrationManager { + // MARK: - Current Version + + static let currentVersion = "1.0" + + // MARK: - Migration Methods + + static func migrateSettings(_ data: Data) -> CombinedSettingsExport? { + // Try to decode with the current version + do { + let currentSettings = try JSONDecoder().decode(CombinedSettingsExport.self, from: data) + print("✅ Successfully decoded CombinedSettingsExport") + return currentSettings + } catch { + print("❌ Failed to decode CombinedSettingsExport: \(error)") + print("❌ Error details: \(error.localizedDescription)") + + // Try to decode as individual components + return tryDecodeIndividualComponents(data) + } + } + + private static func tryDecodeIndividualComponents(_ data: Data) -> CombinedSettingsExport? { + // Try to decode as AlarmSettingsExport + if let alarmSettings = try? JSONDecoder().decode(AlarmSettingsExport.self, from: data) { + print("✅ Successfully decoded as AlarmSettingsExport") + return CombinedSettingsExport( + alarms: alarmSettings, + exportType: "Alarm Settings" + ) + } + + // Try to decode as NightscoutSettingsExport + if let nightscoutSettings = try? JSONDecoder().decode(NightscoutSettingsExport.self, from: data) { + print("✅ Successfully decoded as NightscoutSettingsExport") + return CombinedSettingsExport( + nightscout: nightscoutSettings, + exportType: "Nightscout Settings" + ) + } + + // Try to decode as RemoteSettingsExport + if let remoteSettings = try? JSONDecoder().decode(RemoteSettingsExport.self, from: data) { + print("✅ Successfully decoded as RemoteSettingsExport") + return CombinedSettingsExport( + remote: remoteSettings, + exportType: "Remote Settings" + ) + } + + print("❌ Failed to decode as any known component") + return nil + } + + // MARK: - Version Compatibility + + static func isCompatibleVersion(_ version: String) -> Bool { + let currentVersionComponents = currentVersion.split(separator: ".").compactMap { Int($0) } + let importVersionComponents = version.split(separator: ".").compactMap { Int($0) } + + // For now, accept any version (can be made more strict later) + return true + } + + static func getCompatibilityMessage(for version: String) -> String { + return "Settings from version \(version) may not be fully compatible with current version \(currentVersion). Some features may not work as expected." + } + + // MARK: - Error Handling + + enum SettingsImportError: Error { + case unsupportedVersion(String) + case migrationFailed(String) + case corruptedData + case incompatibleAlarmFormat + case unknownError + } +} diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 3af773889..5343bf8ce 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -54,6 +54,12 @@ struct SettingsMenuView: View { showingTabCustomization = true } + NavigationRow(title: "Import/Export Settings", + icon: "square.and.arrow.down") + { + settingsPath.value.append(Sheet.importExport) + } + if !nightscoutURL.value.isEmpty { NavigationRow(title: "Information Display Settings", icon: "info.circle") @@ -66,12 +72,6 @@ struct SettingsMenuView: View { { settingsPath.value.append(Sheet.remote) } - } else { - NavigationRow(title: "Import Settings", - icon: "square.and.arrow.down") - { - settingsPath.value.append(Sheet.remote) - } } } @@ -279,6 +279,7 @@ private enum Sheet: Hashable, Identifiable { case infoDisplay case alarmsList, alarmSettings case remote + case importExport case calendar, contact case advanced case viewLog @@ -297,6 +298,7 @@ private enum Sheet: Hashable, Identifiable { case .alarmsList: AlarmListView() case .alarmSettings: AlarmSettingsView() case .remote: RemoteSettingsView(viewModel: .init()) + case .importExport: ImportExportSettingsView() case .calendar: CalendarSettingsView() case .contact: ContactSettingsView(viewModel: .init()) case .advanced: AdvancedSettingsView(viewModel: .init()) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index c579c0911..8dd9d8eaf 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -45,6 +45,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele var refreshScrollView: UIScrollView! var refreshControl: UIRefreshControl! + // Setup buttons for first-time configuration + private var setupNightscoutButton: UIButton! + private var setupDexcomButton: UIButton! + let speechSynthesizer = AVSpeechSynthesizer() // Variables for BG Charts @@ -153,7 +157,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele dexShare = ShareClient(username: shareUserName, password: sharePassword, shareServer: shareServer) // setup show/hide small graph and stats - BGChartFull.isHidden = !Storage.shared.showSmallGraph.value + updateGraphVisibility() statsView.isHidden = !Storage.shared.showStats.value BGChart.delegate = self @@ -259,7 +263,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele Storage.shared.showSmallGraph.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.BGChartFull.isHidden = !Storage.shared.showSmallGraph.value + self?.updateGraphVisibility() } .store(in: &cancellables) @@ -309,6 +313,28 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateNightscoutTabState() + self?.checkAndShowImportButtonIfNeeded() + } + .store(in: &cancellables) + + Storage.shared.token.$value + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.checkAndShowImportButtonIfNeeded() + } + .store(in: &cancellables) + + Storage.shared.shareUserName.$value + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.checkAndShowImportButtonIfNeeded() + } + .store(in: &cancellables) + + Storage.shared.sharePassword.$value + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.checkAndShowImportButtonIfNeeded() } .store(in: &cancellables) @@ -361,6 +387,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele setupTabBar() speechSynthesizer.delegate = self + + // Check if this is first-time setup and show import button + checkAndShowImportButtonIfNeeded() } private func setupTabBar() { @@ -945,6 +974,164 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele Storage.shared.infoSort.value = sortArray Storage.shared.infoVisible.value = visibleArray } + + // MARK: - First Time Setup + + private func checkAndShowImportButtonIfNeeded() { + // Check if this is first-time setup (no Nightscout URL configured AND no Dexcom configured) + let isNightscoutConfigured = !Storage.shared.url.value.isEmpty && !Storage.shared.token.value.isEmpty + let isDexcomConfigured = !Storage.shared.shareUserName.value.isEmpty && !Storage.shared.sharePassword.value.isEmpty + let isFirstTimeSetup = !isNightscoutConfigured && !isDexcomConfigured + + if isFirstTimeSetup { + setupFirstTimeButtons() + hideGraphs() + } else { + hideFirstTimeButtons() + showGraphs() + } + } + + private func setupFirstTimeButtons() { + // Create Setup Nightscout button + if setupNightscoutButton == nil { + setupNightscoutButton = UIButton(type: .system) + setupNightscoutButton.setTitle("Setup Nightscout", for: .normal) + setupNightscoutButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium) + setupNightscoutButton.backgroundColor = UIColor.systemBlue + setupNightscoutButton.setTitleColor(.white, for: .normal) + setupNightscoutButton.layer.cornerRadius = 12 + setupNightscoutButton.layer.shadowColor = UIColor.black.cgColor + setupNightscoutButton.layer.shadowOffset = CGSize(width: 0, height: 2) + setupNightscoutButton.layer.shadowOpacity = 0.3 + setupNightscoutButton.layer.shadowRadius = 4 + setupNightscoutButton.addTarget(self, action: #selector(setupNightscoutTapped), for: .touchUpInside) + + view.addSubview(setupNightscoutButton) + setupNightscoutButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + setupNightscoutButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + setupNightscoutButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -30), + setupNightscoutButton.widthAnchor.constraint(equalToConstant: 200), + setupNightscoutButton.heightAnchor.constraint(equalToConstant: 50), + ]) + } + + // Create Setup Dexcom Share button + if setupDexcomButton == nil { + setupDexcomButton = UIButton(type: .system) + setupDexcomButton.setTitle("Setup Dexcom Share", for: .normal) + setupDexcomButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium) + setupDexcomButton.backgroundColor = UIColor.systemGreen + setupDexcomButton.setTitleColor(.white, for: .normal) + setupDexcomButton.layer.cornerRadius = 12 + setupDexcomButton.layer.shadowColor = UIColor.black.cgColor + setupDexcomButton.layer.shadowOffset = CGSize(width: 0, height: 2) + setupDexcomButton.layer.shadowOpacity = 0.3 + setupDexcomButton.layer.shadowRadius = 4 + setupDexcomButton.addTarget(self, action: #selector(setupDexcomTapped), for: .touchUpInside) + + view.addSubview(setupDexcomButton) + setupDexcomButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + setupDexcomButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + setupDexcomButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 30), + setupDexcomButton.widthAnchor.constraint(equalToConstant: 200), + setupDexcomButton.heightAnchor.constraint(equalToConstant: 50), + ]) + } + + setupNightscoutButton.isHidden = false + setupDexcomButton.isHidden = false + } + + private func hideFirstTimeButtons() { + setupNightscoutButton?.isHidden = true + setupDexcomButton?.isHidden = true + } + + @objc private func setupNightscoutTapped() { + let nightscoutSettingsView = NightscoutSettingsView(viewModel: .init()) + let hostingController = UIHostingController(rootView: nightscoutSettingsView) + let navController = UINavigationController(rootViewController: hostingController) + + // Apply dark mode if needed + if Storage.shared.forceDarkMode.value { + hostingController.overrideUserInterfaceStyle = .dark + navController.overrideUserInterfaceStyle = .dark + } + + // Add a Done button + hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissModal) + ) + + navController.modalPresentationStyle = .pageSheet + present(navController, animated: true) + } + + @objc private func setupDexcomTapped() { + let dexcomSettingsView = DexcomSettingsView(viewModel: .init()) + let hostingController = UIHostingController(rootView: dexcomSettingsView) + let navController = UINavigationController(rootViewController: hostingController) + + // Apply dark mode if needed + if Storage.shared.forceDarkMode.value { + hostingController.overrideUserInterfaceStyle = .dark + navController.overrideUserInterfaceStyle = .dark + } + + // Add a Done button + hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissModal) + ) + + navController.modalPresentationStyle = .pageSheet + present(navController, animated: true) + } + + private func hideGraphs() { + BGChart.isHidden = true + BGChartFull.isHidden = true + } + + private func showGraphs() { + updateGraphVisibility() + } + + private func updateGraphVisibility() { + let isFirstTimeSetup = Storage.shared.url.value.isEmpty && Storage.shared.token.value.isEmpty + + if isFirstTimeSetup { + BGChart.isHidden = true + BGChartFull.isHidden = true + } else { + BGChart.isHidden = false + BGChartFull.isHidden = !Storage.shared.showSmallGraph.value + } + } + + @objc private func importSettingsButtonTapped() { + presentImportSettingsView() + } + + private func presentImportSettingsView() { + let importExportView = ImportExportSettingsView() + let hostingController = UIHostingController(rootView: importExportView) + hostingController.modalPresentationStyle = .pageSheet + + present(hostingController, animated: true) + } + + @objc private func dismissModal() { + dismiss(animated: true) + } } extension MainViewController: AVSpeechSynthesizerDelegate {