diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 27b692ee..6c2e76f7 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -24,6 +24,12 @@ 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 */; }; + 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 */; }; DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; }; DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; }; DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; }; @@ -55,6 +61,11 @@ DD5334232C60ED3600062F9D /* IAge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334222C60ED3600062F9D /* IAge.swift */; }; DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */; }; DD5334292C6166A500062F9D /* InfoDisplaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334282C6166A500062F9D /* InfoDisplaySettingsView.swift */; }; + DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334AF2D1447C500CDD6EA /* BLEManager.swift */; }; + DD5817172D2710E90041FB98 /* BLEDeviceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5817162D2710E50041FB98 /* BLEDeviceSelectionView.swift */; }; + DD58171A2D299EF80041FB98 /* DexcomHeartbeatBluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5817192D299EF40041FB98 /* DexcomHeartbeatBluetoothDevice.swift */; }; + DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD58171B2D299F8D0041FB98 /* BluetoothDevice.swift */; }; + DD58171E2D299FCA0041FB98 /* BluetoothDeviceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD58171D2D299FC50041FB98 /* BluetoothDeviceDelegate.swift */; }; DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */; }; DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; }; DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; }; @@ -66,6 +77,18 @@ DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */; }; DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */; }; + DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA032D32821200415D8A /* DeviceStatusTask.swift */; }; + DD9ACA062D32AF7900415D8A /* TreatmentsTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA052D32AF6E00415D8A /* TreatmentsTask.swift */; }; + DD9ACA082D32F68B00415D8A /* BGTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA072D32F68900415D8A /* BGTask.swift */; }; + DD9ACA0A2D33095600415D8A /* MinAgoTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA092D33095500415D8A /* MinAgoTask.swift */; }; + DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA0B2D33BB8400415D8A /* CalendarTask.swift */; }; + DD9ACA0E2D340BFF00415D8A /* AlarmTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA0D2D340BFE00415D8A /* AlarmTask.swift */; }; + DD9ACA102D34129200415D8A /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA0F2D34128600415D8A /* Task.swift */; }; + DD9ED0C82D355244000D2A63 /* LogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0C72D35523F000D2A63 /* LogViewModel.swift */; }; + DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0C92D355256000D2A63 /* LogView.swift */; }; + DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */; }; + DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CD2D355879000D2A63 /* LogEntry.swift */; }; + DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */; }; DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB0AF512BB1A8BE00AFA48B /* BuildDetails.swift */; }; DDB0AF552BB1B24A00AFA48B /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */; }; DDBE3ABD2CB5A961006B37DC /* OverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDBE3ABC2CB5A961006B37DC /* OverrideView.swift */; }; @@ -73,7 +96,6 @@ DDCF979624C1443C002C9752 /* GeneralSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979524C1443C002C9752 /* GeneralSettingsViewController.swift */; }; DDCF979824C1489C002C9752 /* GraphSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979724C1489C002C9752 /* GraphSettingsViewController.swift */; }; DDCF979A24C14DB4002C9752 /* WatchSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979924C14DB4002C9752 /* WatchSettingsViewController.swift */; }; - DDCF979C24C14EFB002C9752 /* AdvancedSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979B24C14EFB002C9752 /* AdvancedSettingsViewController.swift */; }; DDCF979E24C2382A002C9752 /* AppStateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979D24C2382A002C9752 /* AppStateController.swift */; }; DDCFCAF22B17273200BE5751 /* LoopFollowDisplayNameConfig.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = DDCFCAF12B17273200BE5751 /* LoopFollowDisplayNameConfig.xcconfig */; }; DDD10EFF2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10EFE2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift */; }; @@ -83,6 +105,9 @@ DDD10F072C529DE800D76A8E /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F062C529DE800D76A8E /* Observable.swift */; }; DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; }; DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE69ED12C7256260013EAEC /* RemoteType.swift */; }; + DDEF503A2D31615000999A5D /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF50392D31614200999A5D /* LogManager.swift */; }; + DDEF503C2D31BE2D00999A5D /* TaskScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF503B2D31BE2A00999A5D /* TaskScheduler.swift */; }; + DDEF503F2D32754F00999A5D /* ProfileTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF503E2D32754A00999A5D /* ProfileTask.swift */; }; DDF2C0102BEFA991007A20E6 /* GitHubService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF2C00F2BEFA991007A20E6 /* GitHubService.swift */; }; DDF2C0122BEFB733007A20E6 /* AppVersionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF2C0112BEFB733007A20E6 /* AppVersionManager.swift */; }; DDF2C0142BEFD468007A20E6 /* blacklisted-versions.json in Resources */ = {isa = PBXBuildFile; fileRef = DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */; }; @@ -93,6 +118,10 @@ DDF6999E2C5AAA640058A8D9 /* ErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6999D2C5AAA640058A8D9 /* ErrorMessageView.swift */; }; DDF9676E2AD08C6E00C5EB95 /* SiteChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF9676D2AD08C6E00C5EB95 /* SiteChange.swift */; }; DDFD5C532CB167DA00D3FD68 /* TRCCommandType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFD5C522CB167DA00D3FD68 /* TRCCommandType.swift */; }; + DDFF3D7F2D1414A200BF9D9E /* BLEDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D7E2D1414A200BF9D9E /* BLEDevice.swift */; }; + DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */; }; + DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */; }; + DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */; }; FC16A97A24996673003D6245 /* NightScout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97924996673003D6245 /* NightScout.swift */; }; FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7CE589248ABEA3001F83B8 /* AlarmSound.swift */; }; FC16A97D24996747003D6245 /* Alarms.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97C24996747003D6245 /* Alarms.swift */; }; @@ -266,6 +295,12 @@ 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 = ""; }; + 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 = ""; }; DD4878022C7B297E0048F05C /* StorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageValue.swift; sourceTree = ""; }; DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = ""; }; @@ -296,6 +331,11 @@ DD5334222C60ED3600062F9D /* IAge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAge.swift; sourceTree = ""; }; DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplaySettingsViewModel.swift; sourceTree = ""; }; DD5334282C6166A500062F9D /* InfoDisplaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplaySettingsView.swift; sourceTree = ""; }; + DD5334AF2D1447C500CDD6EA /* BLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEManager.swift; sourceTree = ""; }; + DD5817162D2710E50041FB98 /* BLEDeviceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEDeviceSelectionView.swift; sourceTree = ""; }; + DD5817192D299EF40041FB98 /* DexcomHeartbeatBluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomHeartbeatBluetoothDevice.swift; sourceTree = ""; }; + DD58171B2D299F8D0041FB98 /* BluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothDevice.swift; sourceTree = ""; }; + DD58171D2D299FC50041FB98 /* BluetoothDeviceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothDeviceDelegate.swift; sourceTree = ""; }; DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusLoop.swift; sourceTree = ""; }; DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = ""; }; DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = ""; }; @@ -307,6 +347,18 @@ DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EKEventStore+Extensions.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 = ""; }; + DD9ACA032D32821200415D8A /* DeviceStatusTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusTask.swift; sourceTree = ""; }; + DD9ACA052D32AF6E00415D8A /* TreatmentsTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreatmentsTask.swift; sourceTree = ""; }; + DD9ACA072D32F68900415D8A /* BGTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGTask.swift; sourceTree = ""; }; + DD9ACA092D33095500415D8A /* MinAgoTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinAgoTask.swift; sourceTree = ""; }; + DD9ACA0B2D33BB8400415D8A /* CalendarTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarTask.swift; sourceTree = ""; }; + DD9ACA0D2D340BFE00415D8A /* AlarmTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTask.swift; sourceTree = ""; }; + DD9ACA0F2D34128600415D8A /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = ""; }; + DD9ED0C72D35523F000D2A63 /* LogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewModel.swift; sourceTree = ""; }; + DD9ED0C92D355256000D2A63 /* LogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogView.swift; sourceTree = ""; }; + DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; + DD9ED0CD2D355879000D2A63 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = ""; }; + DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RileyLinkHeartbeatBluetoothDevice.swift; sourceTree = ""; }; DDB0AF502BB1A84500AFA48B /* capture-build-details.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "capture-build-details.sh"; sourceTree = ""; }; DDB0AF512BB1A8BE00AFA48B /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = ""; }; DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = ""; }; @@ -315,7 +367,6 @@ DDCF979524C1443C002C9752 /* GeneralSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsViewController.swift; sourceTree = ""; }; DDCF979724C1489C002C9752 /* GraphSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsViewController.swift; sourceTree = ""; }; DDCF979924C14DB4002C9752 /* WatchSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSettingsViewController.swift; sourceTree = ""; }; - DDCF979B24C14EFB002C9752 /* AdvancedSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsViewController.swift; sourceTree = ""; }; DDCF979D24C2382A002C9752 /* AppStateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateController.swift; sourceTree = ""; }; DDCFCAF12B17273200BE5751 /* LoopFollowDisplayNameConfig.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = LoopFollowDisplayNameConfig.xcconfig; sourceTree = ""; }; DDD10EFE2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableUserDefaultsValue.swift; sourceTree = ""; }; @@ -325,6 +376,9 @@ DDD10F062C529DE800D76A8E /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = ""; }; DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryTarget.swift; sourceTree = ""; }; DDE69ED12C7256260013EAEC /* RemoteType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteType.swift; sourceTree = ""; }; + DDEF50392D31614200999A5D /* LogManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = ""; }; + DDEF503B2D31BE2A00999A5D /* TaskScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskScheduler.swift; sourceTree = ""; }; + DDEF503E2D32754A00999A5D /* ProfileTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTask.swift; sourceTree = ""; }; DDF2C00F2BEFA991007A20E6 /* GitHubService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubService.swift; sourceTree = ""; }; DDF2C0112BEFB733007A20E6 /* AppVersionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionManager.swift; sourceTree = ""; }; DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "blacklisted-versions.json"; sourceTree = ""; }; @@ -335,6 +389,10 @@ DDF6999D2C5AAA640058A8D9 /* ErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageView.swift; sourceTree = ""; }; DDF9676D2AD08C6E00C5EB95 /* SiteChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteChange.swift; sourceTree = ""; }; DDFD5C522CB167DA00D3FD68 /* TRCCommandType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TRCCommandType.swift; sourceTree = ""; }; + DDFF3D7E2D1414A200BF9D9E /* BLEDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEDevice.swift; sourceTree = ""; }; + DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsView.swift; sourceTree = ""; }; + DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsViewModel.swift; sourceTree = ""; }; + DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshType.swift; sourceTree = ""; }; ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.debug.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.debug.xcconfig"; sourceTree = ""; }; FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = ""; }; FC16A97C24996747003D6245 /* Alarms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alarms.swift; sourceTree = ""; }; @@ -567,6 +625,33 @@ path = InfoTable; sourceTree = ""; }; + DD1A97122D429495000DDC11 /* Settings */ = { + isa = PBXGroup; + children = ( + DD1A97152D4294B2000DDC11 /* AdvancedSettingsViewModel.swift */, + DD1A97132D4294A4000DDC11 /* AdvancedSettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + DD2C2E4D2D3B8ACF006413A5 /* Nightscout */ = { + isa = PBXGroup; + children = ( + DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */, + DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */, + ); + path = Nightscout; + sourceTree = ""; + }; + DD2C2E522D3C36A8006413A5 /* Dexcom */ = { + isa = PBXGroup; + children = ( + DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */, + DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */, + ); + path = Dexcom; + sourceTree = ""; + }; DD4878062C7B2E9E0048F05C /* Settings */ = { isa = PBXGroup; children = ( @@ -665,6 +750,27 @@ path = Extensions; sourceTree = ""; }; + DD9ED0C62D355225000D2A63 /* Log */ = { + isa = PBXGroup; + children = ( + DD9ED0CD2D355879000D2A63 /* LogEntry.swift */, + DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */, + DD9ED0C92D355256000D2A63 /* LogView.swift */, + DD9ED0C72D35523F000D2A63 /* LogViewModel.swift */, + DDEF50392D31614200999A5D /* LogManager.swift */, + ); + path = Log; + sourceTree = ""; + }; + DDAD16302D2EF98C0084BE10 /* Devices */ = { + isa = PBXGroup; + children = ( + DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */, + DD5817192D299EF40041FB98 /* DexcomHeartbeatBluetoothDevice.swift */, + ); + path = Devices; + sourceTree = ""; + }; DDB0AF4F2BB1A81F00AFA48B /* Scripts */ = { isa = PBXGroup; children = ( @@ -673,6 +779,22 @@ path = Scripts; sourceTree = ""; }; + DDEF503D2D32753A00999A5D /* Task */ = { + isa = PBXGroup; + children = ( + DD9ACA0F2D34128600415D8A /* Task.swift */, + DD9ACA0D2D340BFE00415D8A /* AlarmTask.swift */, + DD9ACA0B2D33BB8400415D8A /* CalendarTask.swift */, + DD9ACA092D33095500415D8A /* MinAgoTask.swift */, + DD9ACA072D32F68900415D8A /* BGTask.swift */, + DD9ACA052D32AF6E00415D8A /* TreatmentsTask.swift */, + DD9ACA032D32821200415D8A /* DeviceStatusTask.swift */, + DDEF503E2D32754A00999A5D /* ProfileTask.swift */, + DDEF503B2D31BE2A00999A5D /* TaskScheduler.swift */, + ); + path = Task; + sourceTree = ""; + }; DDF699972C5AA2E50058A8D9 /* TempTargetPreset */ = { isa = PBXGroup; children = ( @@ -692,6 +814,30 @@ path = Views; sourceTree = ""; }; + DDFF3D792D140F1800BF9D9E /* BackgroundRefresh */ = { + isa = PBXGroup; + children = ( + DDFF3D7A2D140F4200BF9D9E /* BT */, + DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */, + DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */, + DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */, + ); + path = BackgroundRefresh; + sourceTree = ""; + }; + DDFF3D7A2D140F4200BF9D9E /* BT */ = { + isa = PBXGroup; + children = ( + DDAD16302D2EF98C0084BE10 /* Devices */, + DD58171D2D299FC50041FB98 /* BluetoothDeviceDelegate.swift */, + DD58171B2D299F8D0041FB98 /* BluetoothDevice.swift */, + DD5817162D2710E50041FB98 /* BLEDeviceSelectionView.swift */, + DDFF3D7E2D1414A200BF9D9E /* BLEDevice.swift */, + DD5334AF2D1447C500CDD6EA /* BLEManager.swift */, + ); + path = BT; + sourceTree = ""; + }; FC16A97624995FEE003D6245 /* Application */ = { isa = PBXGroup; children = ( @@ -856,6 +1002,12 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( + DD1A97122D429495000DDC11 /* Settings */, + DD2C2E522D3C36A8006413A5 /* Dexcom */, + DD2C2E4D2D3B8ACF006413A5 /* Nightscout */, + DD9ED0C62D355225000D2A63 /* Log */, + DDEF503D2D32753A00999A5D /* Task */, + DDFF3D792D140F1800BF9D9E /* BackgroundRefresh */, DD50C74D2D0828250057AE6F /* Contact */, DD5334252C61667700062F9D /* InfoDisplaySettings */, DD0C0C6E2C4AFFB800DBADDF /* Remote */, @@ -953,7 +1105,6 @@ DDCF979524C1443C002C9752 /* GeneralSettingsViewController.swift */, DDCF979724C1489C002C9752 /* GraphSettingsViewController.swift */, DDCF979924C14DB4002C9752 /* WatchSettingsViewController.swift */, - DDCF979B24C14EFB002C9752 /* AdvancedSettingsViewController.swift */, ); path = ViewControllers; sourceTree = ""; @@ -1221,13 +1372,19 @@ DD5334292C6166A500062F9D /* InfoDisplaySettingsView.swift in Sources */, FCC68850248935D800A0279D /* AlarmViewController.swift in Sources */, DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */, + DD2C2E562D3C3917006413A5 /* DexcomSettingsView.swift in Sources */, + DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */, + DD9ACA082D32F68B00415D8A /* BGTask.swift in Sources */, + DD9ACA102D34129200415D8A /* Task.swift in Sources */, DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */, FC7CE59F248D8D23001F83B8 /* SnoozeViewController.swift in Sources */, DDF6999B2C5AA32E0058A8D9 /* TempTargetPreset.swift in Sources */, + DD9ACA0E2D340BFF00415D8A /* AlarmTask.swift in Sources */, DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */, FCC6886724898F8000A0279D /* UserDefaultsValue.swift in Sources */, DDF699942C555B310058A8D9 /* ViewControllerManager.swift in Sources */, DDCF979E24C2382A002C9752 /* AppStateController.swift in Sources */, + DDEF503F2D32754F00999A5D /* ProfileTask.swift in Sources */, DD5334212C60EBEE00062F9D /* InsulinCartridgeChange.swift in Sources */, FC97881E2485969B00A7906C /* NightScoutViewController.swift in Sources */, DD608A0A2C23593900F91132 /* SMB.swift in Sources */, @@ -1242,21 +1399,27 @@ DDF2C0122BEFB733007A20E6 /* AppVersionManager.swift in Sources */, DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */, DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */, + DDFF3D7F2D1414A200BF9D9E /* BLEDevice.swift in Sources */, + DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */, FC16A97D24996747003D6245 /* Alarms.swift in Sources */, DDFD5C532CB167DA00D3FD68 /* TRCCommandType.swift in Sources */, DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */, + DD5817172D2710E90041FB98 /* BLEDeviceSelectionView.swift in Sources */, FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */, DDBE3ABD2CB5A961006B37DC /* OverrideView.swift in Sources */, DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */, DD0C0C622C4175FD00DBADDF /* NSProfile.swift in Sources */, + DD58171E2D299FCA0041FB98 /* BluetoothDeviceDelegate.swift in Sources */, DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */, DDD10F032C518A6500D76A8E /* TreatmentResponse.swift in Sources */, DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */, DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */, DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */, + DDEF503A2D31615000999A5D /* LogManager.swift in Sources */, DD4878172C7B75350048F05C /* BolusView.swift in Sources */, DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */, FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */, + DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */, DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */, DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */, DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */, @@ -1280,9 +1443,14 @@ DDF9676E2AD08C6E00C5EB95 /* SiteChange.swift in Sources */, DD13BC772C3FD64E0062313B /* InfoData.swift in Sources */, DD13BC752C3FD6210062313B /* InfoType.swift in Sources */, - DDCF979C24C14EFB002C9752 /* AdvancedSettingsViewController.swift in Sources */, + DD1A97142D4294A5000DDC11 /* AdvancedSettingsView.swift in Sources */, + DD9ACA0A2D33095600415D8A /* MinAgoTask.swift in Sources */, + DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */, DDD10EFF2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift in Sources */, + DD58171A2D299EF80041FB98 /* DexcomHeartbeatBluetoothDevice.swift in Sources */, + DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */, DD50C7502D0828800057AE6F /* ContactSettingsViewModel.swift in Sources */, + DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */, FC97881C2485969B00A7906C /* MainViewController.swift in Sources */, DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */, DD493AD52ACF2109009A6922 /* ResumePump.swift in Sources */, @@ -1291,15 +1459,19 @@ DD493AD72ACF2139009A6922 /* SuspendPump.swift in Sources */, FC9788182485969B00A7906C /* AppDelegate.swift in Sources */, DDD10F072C529DE800D76A8E /* Observable.swift in Sources */, + DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */, DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */, DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */, + DDEF503C2D31BE2D00999A5D /* TaskScheduler.swift in Sources */, DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */, DD493AD92ACF2171009A6922 /* Carbs.swift in Sources */, DD493AE92ACF2445009A6922 /* BGData.swift in Sources */, FCC6886B24898FD800A0279D /* ObservationToken.swift in Sources */, DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */, + DD9ACA062D32AF7900415D8A /* TreatmentsTask.swift in Sources */, DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */, + DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */, DD0C0C602C415B9D00DBADDF /* ProfileManager.swift in Sources */, DDF699962C5582290058A8D9 /* TextFieldWithToolBar.swift in Sources */, DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */, @@ -1308,14 +1480,17 @@ FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */, FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */, DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */, + DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */, DD7E198A2ACDA62600DBD158 /* SensorStart.swift in Sources */, FCD49B6C24AA536E007879DC /* DebugViewController.swift in Sources */, + DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */, DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */, DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift.swift in Sources */, FC1BDD2B24A22650001B652C /* Stats.swift in Sources */, DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */, FC1BDD2D24A23204001B652C /* StatsView.swift in Sources */, DD4878102C7B74BF0048F05C /* TrioRemoteControlView.swift in Sources */, + DD2C2E542D3C37DC006413A5 /* DexcomSettingsViewModel.swift in Sources */, FCE537BC249A4D7D00F80BF8 /* carbBolusArrays.swift in Sources */, DD5334232C60ED3600062F9D /* IAge.swift in Sources */, FCD2A27D24C9D044009F7B7B /* Globals.swift in Sources */, @@ -1325,7 +1500,12 @@ DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */, DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */, DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */, + DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */, + DD1A97162D4294B3000DDC11 /* AdvancedSettingsViewModel.swift in Sources */, FCA2DDE62501095000254A8C /* Timers.swift in Sources */, + DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */, + DD9ED0C82D355244000D2A63 /* LogViewModel.swift in Sources */, + DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */, DD493AE32ACF2358009A6922 /* CAge.swift in Sources */, DD493ADD2ACF21E0009A6922 /* Basals.swift in Sources */, FC16A98124996C07003D6245 /* DateTime.swift in Sources */, diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 9b773afe..e067d7d2 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -18,24 +18,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let notificationCenter = UNUserNotificationCenter.current() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - + LogManager.shared.log(category: .general, message: "App started") + LogManager.shared.cleanupOldLogs() + let options: UNAuthorizationOptions = [.alert, .sound, .badge] notificationCenter.requestAuthorization(options: options) { (didAllow, error) in if !didAllow { - print("User has declined notifications") + LogManager.shared.log(category: .general, message: "User has declined notifications") } } - + let store = EKEventStore() store.requestCalendarAccess { (granted, error) in if !granted { - print("Failed to get calendar access: \(String(describing: error))") + LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))") return } } - + let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: []) UNUserNotificationCenter.current().setNotificationCategories([category]) @@ -44,7 +45,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Ensure ViewControllerManager is initialized _ = ViewControllerManager.shared - + + _ = BLEManager.shared + return true } @@ -56,23 +59,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } // MARK: UISceneSession Lifecycle - + func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - - // This application should be called in background every X Minutes - UIApplication.shared.setMinimumBackgroundFetchInterval( - TimeInterval(UserDefaultsRepository.backgroundRefreshFrequency.value * 60) - ) - // set "prevent screen lock" to ON when the app is started for the first time if !UserDefaultsRepository.screenlockSwitchState.exists { UserDefaultsRepository.screenlockSwitchState.value = true } - + // set the "prevent screen lock" option when the app is started // This method doesn't seem to be working anymore. Added to view controllers as solution offered on SO UIApplication.shared.isIdleTimerDisabled = UserDefaultsRepository.screenlockSwitchState.value - + return true } diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index 21a29de5..00ab1aba 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -127,4 +127,3 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { updateQuickActions() } } - diff --git a/LoopFollow/BackgroundRefresh/BT/BLEDevice.swift b/LoopFollow/BackgroundRefresh/BT/BLEDevice.swift new file mode 100644 index 00000000..a444390c --- /dev/null +++ b/LoopFollow/BackgroundRefresh/BT/BLEDevice.swift @@ -0,0 +1,35 @@ +// +// BLEDevice.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-02. +// + +import Foundation + +struct BLEDevice: Identifiable, Codable, Equatable { + let id: UUID + + var name: String? + var rssi: Int + var isConnected: Bool + var advertisedServices: [String]? + var lastSeen: Date + var lastConnected: Date? + + init(id: UUID, + name: String? = nil, + rssi: Int, + isConnected: Bool = false, + advertisedServices: [String]? = nil, + lastSeen: Date = Date(), + lastConnected: Date? = nil) { + self.id = id + self.name = name + self.rssi = rssi + self.isConnected = isConnected + self.advertisedServices = advertisedServices + self.lastSeen = lastSeen + self.lastConnected = lastConnected + } +} diff --git a/LoopFollow/BackgroundRefresh/BT/BLEDeviceSelectionView.swift b/LoopFollow/BackgroundRefresh/BT/BLEDeviceSelectionView.swift new file mode 100644 index 00000000..8b79ad5c --- /dev/null +++ b/LoopFollow/BackgroundRefresh/BT/BLEDeviceSelectionView.swift @@ -0,0 +1,55 @@ +// +// BLEDeviceSelectionView.swift +// LoopFollow +// + +import SwiftUI + +struct BLEDeviceSelectionView: View { + @ObservedObject var bleManager: BLEManager + var selectedFilter: BackgroundRefreshType + var onSelectDevice: (BLEDevice) -> Void + + var body: some View { + VStack { + List { + if bleManager.devices.filter({ selectedFilter.matches($0) && !isSelected($0) }).isEmpty { + Text("No devices found yet. They'll appear here when discovered.") + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding() + } else { + ForEach(bleManager.devices.filter { selectedFilter.matches($0) && !isSelected($0) }, id: \.id) { device in + HStack { + VStack(alignment: .leading) { + Text(device.name ?? "Unknown") + + Text("RSSI: \(device.rssi) dBm") + .foregroundColor(.secondary) + .font(.footnote) + } + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + onSelectDevice(device) + } + } + } + } + } + .onAppear { + bleManager.startScanning() + } + .onDisappear { + bleManager.stopScanning() + } + } + + private func isSelected(_ device: BLEDevice) -> Bool { + guard let selectedDevice = Storage.shared.selectedBLEDevice.value else { + return false + } + return selectedDevice.id == device.id + } +} diff --git a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift new file mode 100644 index 00000000..0dbd7b8f --- /dev/null +++ b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift @@ -0,0 +1,215 @@ +// +// BLEManager.swift +// LoopFollow +// + +import Foundation +import CoreBluetooth +import Combine + +class BLEManager: NSObject, ObservableObject { + static let shared = BLEManager() + + @Published private(set) var devices: [BLEDevice] = [] + + private var centralManager: CBCentralManager! + private var activeDevice: BluetoothDevice? + + private override init() { + super.init() + + centralManager = CBCentralManager( + delegate: self, + queue: .main + ) + if let device = Storage.shared.selectedBLEDevice.value { + devices.append(device) + findAndUpdateDevice(with: device.id.uuidString) { device in + device.rssi = 0 + } + connect(device: device) + } + } + + func getSelectedDevice() -> BLEDevice? { + return devices.first { $0.id == Storage.shared.selectedBLEDevice.value?.id } + } + + func startScanning() { + guard centralManager.state == .poweredOn else { + LogManager.shared.log(category: .bluetooth, message: "Not powered on, cannot start scan.") + return + } + centralManager.scanForPeripherals(withServices: nil, options: nil) + + cleanupOldDevices() + } + + func disconnect() { + if let device = activeDevice { + device.disconnect() + activeDevice = nil + device.lastHeartbeatTime = nil + } + Storage.shared.selectedBLEDevice.value = nil + } + + func connect(device: BLEDevice) { + disconnect() + + if let matchedType = BackgroundRefreshType.allCases.first(where: { $0.matches(device) }) { + Storage.shared.backgroundRefreshType.value = matchedType + Storage.shared.selectedBLEDevice.value = device + + findAndUpdateDevice(with: device.id.uuidString) { device in + device.isConnected = false + device.lastConnected = nil + } + + switch matchedType { + case .dexcom: + activeDevice = DexcomHeartbeatBluetoothDevice(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self) + activeDevice?.connect() + case .rileyLink: + activeDevice = RileyLinkHeartbeatBluetoothDevice(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self) + activeDevice?.connect() + case .silentTune, .none: + return + } + } else { + LogManager.shared.log(category: .bluetooth, message: "No matching BackgroundRefreshType found for this device.") + } + } + + func stopScanning() { + centralManager.stopScan() + } + + func expectedHeartbeatInterval() -> TimeInterval? { + guard let device = activeDevice else { + return nil + } + + return device.expectedHeartbeatInterval() + } + + private func addOrUpdateDevice(_ device: BLEDevice) { + if let idx = devices.firstIndex(where: { $0.id == device.id }) { + var updatedDevice = devices[idx] + updatedDevice.rssi = device.rssi + updatedDevice.lastSeen = Date() + devices[idx] = updatedDevice + } else { + var newDevice = device + newDevice.lastSeen = Date() + devices.append(newDevice) + } + + devices = devices + } + + private func cleanupOldDevices() { + let expirationDate = Date().addingTimeInterval(-600) // 10 minutes ago + + // Get the selected device's ID (if any) + let selectedDeviceID = Storage.shared.selectedBLEDevice.value?.id + + // Filter devices, keeping those seen within the last 10 minutes or the selected device + devices = devices.filter { $0.lastSeen > expirationDate || $0.id == selectedDeviceID } + } +} + +// MARK: - CBCentralManagerDelegate +extension BLEManager: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + switch central.state { + case .poweredOn: + LogManager.shared.log(category: .bluetooth, message: "Central poweredOn", isDebug: true) + default: + LogManager.shared.log(category: .bluetooth, message: "Central state = \(central.state.rawValue), not powered on.", isDebug: true) + } + } + + func centralManager(_ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber) { + let uuid = peripheral.identifier + let services = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])? + .map { $0.uuidString } + + let device = BLEDevice( + id: uuid, + name: peripheral.name, + rssi: RSSI.intValue, + advertisedServices: services, + lastSeen: Date() + ) + + addOrUpdateDevice(device) + } + + func findAndUpdateDevice(with deviceAddress: String, update: (inout BLEDevice) -> Void) { + if let idx = devices.firstIndex(where: { $0.id.uuidString == deviceAddress }) { + var device = devices[idx] + update(&device) + devices[idx] = device + + devices = devices + } else { + LogManager.shared.log(category: .bluetooth, message: "Device not found in devices array for update") + } + } +} + +extension BLEManager: BluetoothDeviceDelegate { + func didConnectTo(bluetoothDevice: BluetoothDevice) { + LogManager.shared.log(category: .bluetooth, message: "Connected to: \(bluetoothDevice.deviceName ?? "Unknown")", isDebug: true) + + findAndUpdateDevice(with: bluetoothDevice.deviceAddress) { device in + device.isConnected = true + device.lastConnected = Date() + } + } + + func didDisconnectFrom(bluetoothDevice: BluetoothDevice) { + LogManager.shared.log(category: .bluetooth, message: "Disconnect from: \(bluetoothDevice.deviceName ?? "Unknown")", isDebug: true) + + findAndUpdateDevice(with: bluetoothDevice.deviceAddress) { device in + device.isConnected = false + device.lastConnected = Date() + } + } + + func heartBeat() { + guard let device = activeDevice else { + return + } + + let now = Date() + guard let expectedInterval = device.expectedHeartbeatInterval() else { + LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered") + device.lastHeartbeatTime = now + TaskScheduler.shared.checkTasksNow() + return + } + + let marginPercentage: Double = 0.15 // 15% margin + let margin = expectedInterval * marginPercentage + let threshold = expectedInterval + margin + + if let last = device.lastHeartbeatTime { + let elapsedTime = now.timeIntervalSince(last) + if elapsedTime > threshold { + let delay = elapsedTime - expectedInterval + LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)") + } + } else { + LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)") + } + + device.lastHeartbeatTime = now + + TaskScheduler.shared.checkTasksNow() + } +} diff --git a/LoopFollow/BackgroundRefresh/BT/BluetoothDevice.swift b/LoopFollow/BackgroundRefresh/BT/BluetoothDevice.swift new file mode 100644 index 00000000..b7e3f60d --- /dev/null +++ b/LoopFollow/BackgroundRefresh/BT/BluetoothDevice.swift @@ -0,0 +1,327 @@ +// +// BluetoothDevice.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-04. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation +import CoreBluetooth +import os +import UIKit + +class BluetoothDevice: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { + public weak var bluetoothDeviceDelegate: BluetoothDeviceDelegate? + private(set) var deviceAddress:String + private(set) var deviceName:String? + private let CBUUID_Advertisement:String? + private let servicesCBUUIDs:[CBUUID]? + private let CBUUID_ReceiveCharacteristic:String + private var centralManager: CBCentralManager? + private var peripheral: CBPeripheral? + private var timeStampLastStatusUpdate:Date + private var receiveCharacteristic:CBCharacteristic? + private let maxTimeToWaitForPeripheralResponse = 5.0 + private var connectTimeOutTimer: Timer? + var lastHeartbeatTime: Date? + + init(address:String, name:String?, CBUUID_Advertisement:String?, servicesCBUUIDs:[CBUUID]?, CBUUID_ReceiveCharacteristic:String, bluetoothDeviceDelegate: BluetoothDeviceDelegate) { + self.lastHeartbeatTime = nil + self.deviceAddress = address + self.deviceName = name + + self.servicesCBUUIDs = servicesCBUUIDs + self.CBUUID_Advertisement = CBUUID_Advertisement + self.CBUUID_ReceiveCharacteristic = CBUUID_ReceiveCharacteristic + + timeStampLastStatusUpdate = Date() + + self.bluetoothDeviceDelegate = bluetoothDeviceDelegate + + super.init() + + initialize() + } + + deinit { + disconnect() + } + + func connect() { + if let centralManager = centralManager, !retrievePeripherals(centralManager) { + _ = startScanning() + } + } + + func disconnect() { + if let peripheral = peripheral { + if let centralManager = centralManager { + centralManager.cancelPeripheralConnection(peripheral) + } + } + } + + func disconnectAndForget() { + disconnect() + + peripheral = nil + deviceName = nil + //deviceAddress = nil + } + + func stopScanning() { + self.centralManager?.stopScan() + } + + func isScanning() -> Bool { + if let centralManager = centralManager { + return centralManager.isScanning + } + return false + } + + func startScanning() -> BluetoothDevice.startScanningResult { + LogManager.shared.log(category: .bluetooth, message: "Start Scanning", isDebug: true) + + var returnValue = BluetoothDevice.startScanningResult.unknown + + if let peripheral = peripheral { + switch peripheral.state { + case .connected: + return .alreadyConnected + case .connecting: + if Date() > Date(timeInterval: maxTimeToWaitForPeripheralResponse, since: timeStampLastStatusUpdate) { + disconnect() + } + return .connecting + default:() + } + } + + var services:[CBUUID]? + if let CBUUID_Advertisement = CBUUID_Advertisement { + services = [CBUUID(string: CBUUID_Advertisement)] + } + + if let centralManager = centralManager { + if centralManager.isScanning { + return .alreadyScanning + } + switch centralManager.state { + case .poweredOn: + centralManager.scanForPeripherals(withServices: services, options: nil) + returnValue = .success + case .poweredOff: + return .poweredOff + case .unknown: + return .unknown + case .unauthorized: + return .unauthorized + default: + return returnValue + } + } else { + returnValue = .other(reason:"centralManager is nil, can not start scanning") + } + + return returnValue + } + + func readValueForCharacteristic(for characteristic: CBCharacteristic) { + peripheral?.readValue(for: characteristic) + } + + func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic) { + if let peripheral = peripheral { + peripheral.setNotifyValue(enabled, for: characteristic) + } + } + + fileprivate func stopScanAndconnect(to peripheral: CBPeripheral) { + LogManager.shared.log(category: .bluetooth, message: "Stop Scan And Connect", isDebug: true) + + self.centralManager?.stopScan() + self.deviceAddress = peripheral.identifier.uuidString + self.deviceName = peripheral.name + peripheral.delegate = self + self.peripheral = peripheral + + if peripheral.state == .disconnected { + connectTimeOutTimer = Timer.scheduledTimer(timeInterval: 5.0, target: self, selector: #selector(stopConnectAndRestartScanning), userInfo: nil, repeats: false) + centralManager?.connect(peripheral, options: nil) + } else { + if let newCentralManager = centralManager { + centralManager(newCentralManager, didConnect: peripheral) + } + } + } + + @objc fileprivate func stopConnectAndRestartScanning() { + disconnectAndForget() + _ = startScanning() + } + + public func cancelConnectionTimer() { + if let connectTimeOutTimer = connectTimeOutTimer { + connectTimeOutTimer.invalidate() + self.connectTimeOutTimer = nil + } + + } + + fileprivate func retrievePeripherals(_ central:CBCentralManager) -> Bool { + if let uuid = UUID(uuidString: deviceAddress) { + //trace(" uuid is not nil", log: log, category: ConstantsLog.categoryBlueToothTransmitter, type: .info) + let peripheralArr = central.retrievePeripherals(withIdentifiers: [uuid]) + if peripheralArr.count > 0 { + peripheral = peripheralArr[0] + if let peripheral = peripheral { + peripheral.delegate = self + central.connect(peripheral, options: nil) + return true + } + } + } + return false + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { + print("[BLE] didDiscover") + + timeStampLastStatusUpdate = Date() + + if peripheral.identifier.uuidString == deviceAddress { + stopScanAndconnect(to: peripheral) + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + cancelConnectionTimer() + + timeStampLastStatusUpdate = Date() + + bluetoothDeviceDelegate?.didConnectTo(bluetoothDevice: self) + + peripheral.discoverServices(servicesCBUUIDs) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + timeStampLastStatusUpdate = Date() + + let peripheralName = peripheral.name ?? "Unknown" + let errorMessage = error?.localizedDescription ?? "No error details provided" + + LogManager.shared.log(category: .bluetooth, message: "Failed to connect to peripheral '\(peripheralName)' (UUID: \(peripheral.identifier.uuidString)). Error: \(errorMessage). Retrying...") + + centralManager?.connect(peripheral, options: nil) + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + LogManager.shared.log(category: .bluetooth, message: "Central Manager Did Update State", isDebug: true) + + timeStampLastStatusUpdate = Date() + + if central.state == .poweredOn { + _ = retrievePeripherals(central) + } + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + timeStampLastStatusUpdate = Date() + + bluetoothDeviceDelegate?.didDisconnectFrom(bluetoothDevice: self) + + if let ownPeripheral = self.peripheral { + centralManager?.connect(ownPeripheral, options: nil) + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + timeStampLastStatusUpdate = Date() + + if let services = peripheral.services { + for service in services { + peripheral.discoverCharacteristics(nil, for: service) + } + } else { + disconnect() + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + timeStampLastStatusUpdate = Date() + + if let characteristics = service.characteristics { + for characteristic in characteristics { + if characteristic.uuid == CBUUID(string: CBUUID_ReceiveCharacteristic) { + receiveCharacteristic = characteristic + peripheral.setNotifyValue(true, for: characteristic) + } + } + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + timeStampLastStatusUpdate = Date() + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + timeStampLastStatusUpdate = Date() + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + timeStampLastStatusUpdate = Date() + } + + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { + LogManager.shared.log(category: .bluetooth, message: "Restoring BLE after crash/kill") + } + + private func initialize() { + var cBCentralManagerOptionRestoreIdentifierKeyToUse: String? + + cBCentralManagerOptionRestoreIdentifierKeyToUse = "LoopFollow-" + deviceAddress + + centralManager = CBCentralManager(delegate: self, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: true, CBCentralManagerOptionRestoreIdentifierKey: cBCentralManagerOptionRestoreIdentifierKeyToUse!]) + } + + enum startScanningResult: Equatable { + case success + case alreadyScanning + case poweredOff + case alreadyConnected + case connecting + case unknown + case unauthorized + case nfcScanNeeded + case other(reason:String) + + func description() -> String { + switch self { + case .success: + return "success" + case .alreadyScanning: + return "alreadyScanning" + case .poweredOff: + return "poweredOff" + case .alreadyConnected: + return "alreadyConnected" + case .connecting: + return "connecting" + case .other(let reason): + return "other reason : " + reason + case .unknown: + return "unknown" + case .unauthorized: + return "unauthorized" + case .nfcScanNeeded: + return "nfcScanNeeded" + } + } + } + + func expectedHeartbeatInterval() -> TimeInterval? { + return nil + } +} diff --git a/LoopFollow/BackgroundRefresh/BT/BluetoothDeviceDelegate.swift b/LoopFollow/BackgroundRefresh/BT/BluetoothDeviceDelegate.swift new file mode 100644 index 00000000..2050b280 --- /dev/null +++ b/LoopFollow/BackgroundRefresh/BT/BluetoothDeviceDelegate.swift @@ -0,0 +1,18 @@ +// +// BluetoothDeviceDelegate.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-04. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation +import CoreBluetooth + +protocol BluetoothDeviceDelegate: AnyObject { + func didConnectTo(bluetoothDevice: BluetoothDevice) + + func didDisconnectFrom(bluetoothDevice: BluetoothDevice) + + func heartBeat() +} diff --git a/LoopFollow/BackgroundRefresh/BT/Devices/DexcomHeartbeatBluetoothDevice.swift b/LoopFollow/BackgroundRefresh/BT/Devices/DexcomHeartbeatBluetoothDevice.swift new file mode 100644 index 00000000..325c1265 --- /dev/null +++ b/LoopFollow/BackgroundRefresh/BT/Devices/DexcomHeartbeatBluetoothDevice.swift @@ -0,0 +1,38 @@ +// +// DexcomHeartbeatBluetoothDevice.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-04. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation +import os +import CoreBluetooth +import AVFoundation + +class DexcomHeartbeatBluetoothDevice: BluetoothDevice { + private let CBUUID_Service_G7 = "F8083532-849E-531C-C594-30F1F86A4EA5" + private let CBUUID_Advertisement_G7 = "FEBC" + private let CBUUID_ReceiveCharacteristic_G7 = "F8083535-849E-531C-C594-30F1F86A4EA5" + + init(address:String, name:String?, bluetoothDeviceDelegate: BluetoothDeviceDelegate) { + super.init( + address: address, + name: name, + CBUUID_Advertisement: CBUUID_Advertisement_G7, + servicesCBUUIDs: [CBUUID(string: CBUUID_Service_G7)], + CBUUID_ReceiveCharacteristic: CBUUID_ReceiveCharacteristic_G7, + bluetoothDeviceDelegate: bluetoothDeviceDelegate + ) + } + + override func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + super.centralManager(central, didDisconnectPeripheral: peripheral, error: error) + self.bluetoothDeviceDelegate?.heartBeat() + } + + override func expectedHeartbeatInterval() -> TimeInterval? { + return 5 * 60 // 5 minutes in seconds + } +} diff --git a/LoopFollow/BackgroundRefresh/BT/Devices/RileyLinkHeartbeatBluetoothDevice.swift b/LoopFollow/BackgroundRefresh/BT/Devices/RileyLinkHeartbeatBluetoothDevice.swift new file mode 100644 index 00000000..3a68b50c --- /dev/null +++ b/LoopFollow/BackgroundRefresh/BT/Devices/RileyLinkHeartbeatBluetoothDevice.swift @@ -0,0 +1,47 @@ +// +// RileyLinkHeartbeatBluetoothTransmitter.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-08. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation +import CoreBluetooth + +class RileyLinkHeartbeatBluetoothDevice: BluetoothDevice { + private let CBUUID_Service_RileyLink: String = "0235733B-99C5-4197-B856-69219C2A3845" + private let CBUUID_ReceiveCharacteristic_TimerTick: String = "6E6C7910-B89E-43A5-78AF-50C5E2B86F7E" + private let CBUUID_ReceiveCharacteristic_Data: String = "C842E849-5028-42E2-867C-016ADADA9155" + + init(address:String, name:String?, bluetoothDeviceDelegate: BluetoothDeviceDelegate) { + super.init( + address: address, + name: name, + CBUUID_Advertisement: nil, + servicesCBUUIDs: [CBUUID(string: CBUUID_Service_RileyLink)], + CBUUID_ReceiveCharacteristic: CBUUID_ReceiveCharacteristic_TimerTick, + bluetoothDeviceDelegate: bluetoothDeviceDelegate + ) + } + + override func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + super.centralManager(central, didConnect: peripheral) + + self.bluetoothDeviceDelegate?.heartBeat() + } + + override func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + super.peripheral(peripheral, didUpdateValueFor: characteristic, error: error) + + guard characteristic.uuid == CBUUID(string: CBUUID_ReceiveCharacteristic_TimerTick) else { + return + } + + self.bluetoothDeviceDelegate?.heartBeat() + } + + override func expectedHeartbeatInterval() -> TimeInterval? { + return 60 + } +} diff --git a/LoopFollow/BackgroundRefresh/BT/DexcomG7HeartBeat.swift b/LoopFollow/BackgroundRefresh/BT/DexcomG7HeartBeat.swift new file mode 100644 index 00000000..6dff968c --- /dev/null +++ b/LoopFollow/BackgroundRefresh/BT/DexcomG7HeartBeat.swift @@ -0,0 +1,37 @@ +// +// DexcomG7HeartBeat.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-04. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +// Denna behövs + +import Foundation + +/// A simple class to represent the Dexcom G7 Heartbeat. +/// It wraps around a `BLEPeripheral` to store relevant information. +public class DexcomG7HeartBeat { + + // MARK: - Properties + + /// The BLEPeripheral instance associated with this heartbeat. + public let blePeripheral: BLEPeripheral + + // MARK: - Initialization + + /// Initializes a new DexcomG7HeartBeat instance. + /// - Parameters: + /// - address: The unique address of the BLE device. + /// - name: The name of the BLE device. + /// - alias: An optional alias for the device. + public init(address: String, name: String, alias: String? = nil) { + self.blePeripheral = BLEPeripheral( + address: address, + name: name, + alias: alias, + peripheralType: .DexcomG7HeartBeatType + ) + } +} diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift new file mode 100644 index 00000000..1584d9f3 --- /dev/null +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift @@ -0,0 +1,189 @@ +// +// BackgroundRefreshSettingsView.swift +// LoopFollow +// + +import SwiftUI + +struct BackgroundRefreshSettingsView: View { + @ObservedObject var viewModel: BackgroundRefreshSettingsViewModel + @Environment(\.presentationMode) var presentationMode + @State private var forceRefresh = false + @State private var timer: Timer? + + @ObservedObject var bleManager = BLEManager.shared + + var body: some View { + NavigationView { + Form { + refreshTypeSection + + if viewModel.backgroundRefreshType.isBluetooth { + selectedDeviceSection + availableDevicesSection + } + } + .navigationBarTitle("Background Refresh Settings", displayMode: .inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + presentationMode.wrappedValue.dismiss() + } + } + } + .onAppear { + startTimer() + } + .onDisappear { + stopTimer() + } + } + } + + // MARK: - Subviews / Computed Properties + + private var refreshTypeSection: some View { + Section { + Picker("Background Refresh Type", selection: $viewModel.backgroundRefreshType) { + ForEach(BackgroundRefreshType.allCases, id: \.self) { type in + Text(type.rawValue).tag(type) + } + } + .pickerStyle(MenuPickerStyle()) + + VStack(alignment: .leading, spacing: 4) { + Text("Adjust the background refresh type.") + .font(.footnote) + .foregroundColor(.secondary) + + switch viewModel.backgroundRefreshType { + case .none: + Text("No background refresh. Alarms and updates will not work unless the app is open in the foreground.") + .font(.footnote) + .foregroundColor(.secondary) + + case .silentTune: + Text("A silent tune will play in the background, keeping the app active. May be interrupted by other apps. Allows continuous updates but consumes more battery.") + .font(.footnote) + .foregroundColor(.secondary) + + case .rileyLink: + Text("Requires a RileyLink-compatible device within Bluetooth range. Provides updates once per minute and uses less battery than the silent tune method.") + .font(.footnote) + .foregroundColor(.secondary) + + case .dexcom: + Text("Requires a Dexcom G6/ONE/G7/ONE+ transmitter within Bluetooth range. Provides updates every 5 minutes and uses less battery than the silent tune method.") + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + } + + @ViewBuilder + private var selectedDeviceSection: some View { + if let storedDevice = bleManager.getSelectedDevice() { + Section(header: Text("Selected Device")) { + VStack(alignment: .leading, spacing: 4) { + Text(storedDevice.name ?? "Unknown Device") + .font(.headline) + + deviceConnectionStatus(for: storedDevice) + + if(storedDevice.rssi != 0) + { + Text("RSSI: \(storedDevice.rssi) dBm") + .foregroundColor(.secondary) + .font(.footnote) + } + + HStack { + Spacer() + Button(action: { + bleManager.disconnect() + }) { + Text("Disconnect") + .foregroundColor(.blue) + } + .buttonStyle(BorderlessButtonStyle()) + Spacer() + } + } + .padding(.vertical, 8) + } + .id(forceRefresh) + } + } + + private func formattedTimeString(from seconds: TimeInterval) -> String { + if seconds < 60 { + return "\(Int(seconds)) seconds" + } else { + let minutes = Int(seconds / 60) + let seconds = Int(seconds.truncatingRemainder(dividingBy: 60)) + return "\(minutes):\(String(format: "%02d", seconds)) minutes" + } + } + + private var availableDevicesSection: some View { + Section(header: scanningStatusHeader) { + BLEDeviceSelectionView( + bleManager: bleManager, + selectedFilter: viewModel.backgroundRefreshType, + onSelectDevice: { device in + bleManager.connect(device: device) + } + ) + } + } + + private var scanningStatusHeader: some View { + Text("Scanning for \(viewModel.backgroundRefreshType.rawValue)...") + .font(.subheadline) + .foregroundColor(.secondary) + } + + private func deviceConnectionStatus(for device: BLEDevice) -> some View { + let expectedConnectionTime: TimeInterval = bleManager.expectedHeartbeatInterval() ?? 300 + let now = Date() + let timeSinceLastConnection = device.isConnected ? 0 : now.timeIntervalSince(device.lastConnected ?? now) + + if device.isConnected { + return Text("Connected") + .foregroundColor(.green) + } else if let lastConnected = device.lastConnected { + let timeRatio = timeSinceLastConnection / expectedConnectionTime + let timeString = formattedTimeString(from: timeSinceLastConnection) + + if timeRatio < 1.0 { + return Text("Disconnected for \(timeString)") + .foregroundColor(.green) + } else if timeRatio <= 1.15 { + return Text("Disconnected for \(timeString)") + .foregroundColor(.orange) + } else if timeRatio <= 3.0 { + return Text("Disconnected for \(timeString)") + .foregroundColor(.red) + } else { + let date = dateTimeUtils.formattedDate(from: lastConnected) + return Text("Last connection: \(date)") + .foregroundColor(.red) + } + } else { + return Text("Reconnecting...") + .foregroundColor(.orange) + } + } + + private func startTimer() { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + self.forceRefresh.toggle() + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } +} diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift new file mode 100644 index 00000000..3cf22a49 --- /dev/null +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift @@ -0,0 +1,42 @@ +// +// BackgroundRefreshSettingsViewModel.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-02. +// + +import Foundation +import Combine + +class BackgroundRefreshSettingsViewModel: ObservableObject { + @Published var backgroundRefreshType: BackgroundRefreshType + + private var storage = Storage.shared + private var cancellables = Set() + + private var isInitialSetup = true // Tracks whether the value is being set initially + + init() { + self.backgroundRefreshType = storage.backgroundRefreshType.value + setupBindings() + } + + private func setupBindings() { + $backgroundRefreshType + .dropFirst() // Ignore the initial emission during setup + .sink { [weak self] newValue in + guard let self = self else { return } + self.handleBackgroundRefreshTypeChange(newValue) + + // Persist the change + self.storage.backgroundRefreshType.value = newValue + } + .store(in: &cancellables) + } + + private func handleBackgroundRefreshTypeChange(_ newValue: BackgroundRefreshType) { + LogManager.shared.log(category: .general, message: "Background refresh type changed to: \(newValue.rawValue)") + + BLEManager.shared.disconnect() + } +} diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshType.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshType.swift new file mode 100644 index 00000000..93cc9432 --- /dev/null +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshType.swift @@ -0,0 +1,47 @@ +// +// BackgroundRefreshType.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-02. +// + +import Foundation + +enum BackgroundRefreshType: String, Codable, CaseIterable { + case none = "None" + case silentTune = "Silent Tune" + case rileyLink = "RileyLink" + case dexcom = "Dexcom" + + /// Indicates if the device type uses Bluetooth + var isBluetooth: Bool { + switch self { + case .rileyLink, .dexcom: + return true + case .silentTune, .none: + return false + } + } + + /// Determines if a BLEDevice matches the specific device type + func matches(_ device: BLEDevice) -> Bool { + switch self { + case .rileyLink: + let rileyUUIDString = "0235733b-99c5-4197-b856-69219c2a3845" + if let services = device.advertisedServices { + return services.map { $0.lowercased() } + .contains(rileyUUIDString.lowercased()) + } + return false + + case .dexcom: + if let name = device.name { + return name.hasPrefix("DXCM") || name.hasPrefix("DX02") || name.hasPrefix("Dexcom") + } + return false + + case .silentTune, .none: + return false + } + } +} diff --git a/LoopFollow/Contact/ContactImageUpdater.swift b/LoopFollow/Contact/ContactImageUpdater.swift index 137165c5..da337c53 100644 --- a/LoopFollow/Contact/ContactImageUpdater.swift +++ b/LoopFollow/Contact/ContactImageUpdater.swift @@ -17,12 +17,12 @@ class ContactImageUpdater { func updateContactImage(bgValue: String, extra: String, stale: Bool) { queue.async { guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else { - print("Access to contacts is not authorized.") + LogManager.shared.log(category: .contact, message: "Access to contacts is not authorized.") return } guard let imageData = self.generateContactImage(bgValue: bgValue, extra: extra, stale: stale)?.pngData() else { - print("Failed to generate contact image.") + LogManager.shared.log(category: .contact, message: "Failed to generate contact image.") return } @@ -39,7 +39,7 @@ class ContactImageUpdater { let saveRequest = CNSaveRequest() saveRequest.update(mutableContact) try self.contactStore.execute(saveRequest) - print("Contact image updated successfully.") + LogManager.shared.log(category: .contact, message: "Contact image updated", isDebug: true) } else { let newContact = CNMutableContact() newContact.givenName = contactName @@ -47,10 +47,10 @@ class ContactImageUpdater { let saveRequest = CNSaveRequest() saveRequest.add(newContact, toContainerWithIdentifier: nil) try self.contactStore.execute(saveRequest) - print("New contact created with updated image.") + LogManager.shared.log(category: .contact, message: "New contact created") } } catch { - print("Failed to update or create contact: \(error)") + LogManager.shared.log(category: .contact, message: "Failed to update or create contact: \(error)") } } } diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index 1a8634e5..bb19107c 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -135,7 +135,9 @@ class AlarmSound { guard !self.isPlaying else { return } - + + enableAudio() + do { self.audioPlayer = try AVAudioPlayer(contentsOf: self.soundURL) self.audioPlayer!.delegate = self.audioPlayerDelegate @@ -244,21 +246,30 @@ class AlarmSound { self.systemOutputVolumeBeforeOverride = nil } + + fileprivate static func enableAudio() { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + LogManager.shared.log(category: .general, message: "Enable audio error: \(error)") + } + } } class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate { /* audioPlayerDidFinishPlaying:successfully: is called when a sound has finished playing. This method is NOT called if the player is stopped due to an interruption. */ func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { - NSLog("AlarmRule - audioPlayerDidFinishPlaying (\(flag))") + LogManager.shared.log(category: .general, message: "AlarmRule - audioPlayerDidFinishPlaying (\(flag))", isDebug: true) } /* if an error occurs while decoding it will be reported to the delegate. */ func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { if let error = error { - NSLog("AlarmRule - audioPlayerDecodeErrorDidOccur: \(error)") + LogManager.shared.log(category: .general, message: "AlarmRule - audioPlayerDecodeErrorDidOccur: \(error)") } else { - NSLog("AlarmRule - audioPlayerDecodeErrorDidOccur") + LogManager.shared.log(category: .general, message: "AlarmRule - audioPlayerDecodeErrorDidOccur") } } @@ -266,14 +277,14 @@ class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate { /* audioPlayerBeginInterruption: is called when the audio session has been interrupted while the player was playing. The player will have been paused. */ func audioPlayerBeginInterruption(_ player: AVAudioPlayer) { - NSLog("AlarmRule - audioPlayerBeginInterruption") + LogManager.shared.log(category: .general, message: "AlarmRule - audioPlayerBeginInterruption") } /* audioPlayerEndInterruption:withOptions: is called when the audio session interruption has ended and this player had been interrupted while playing. */ /* Currently the only flag is AVAudioSessionInterruptionFlags_ShouldResume. */ func audioPlayerEndInterruption(_ player: AVAudioPlayer, withOptions flags: Int) { - NSLog("AlarmRule - audioPlayerEndInterruption withOptions: \(flags)") + LogManager.shared.log(category: .general, message: "AlarmRule - audioPlayerEndInterruption withOptions: \(flags)") } } diff --git a/LoopFollow/Controllers/Alarms.swift b/LoopFollow/Controllers/Alarms.swift index 614dc3c1..e51a5e34 100644 --- a/LoopFollow/Controllers/Alarms.swift +++ b/LoopFollow/Controllers/Alarms.swift @@ -20,15 +20,13 @@ extension MainViewController { func checkAlarms(bgs: [ShareGlucoseData]) { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Checking Alarms") } // Don't check or fire alarms within 1 minute of prior alarm if checkAlarmTimer.isValid { return } let date = Date() let now = date.timeIntervalSince1970 let currentBG = bgs[bgs.count - 1].sgv - //let lastBG = bgs[bgs.count - 2].sgv // not used, protect index out of bounds - + var skipZero = false if UserDefaultsRepository.alertIgnoreZero.value && currentBG == 0 { skipZero = true @@ -339,12 +337,12 @@ extension MainViewController { //check for not looping alert if IsNightscoutEnabled() { + LogManager.shared.log(category: .alarm, message: "Checking NotLooping LastLoopTime was \(UserDefaultsRepository.alertLastLoopTime.value) that gives a diff of: \(Double(dateTimeUtils.getNowTimeIntervalUTC() - UserDefaultsRepository.alertLastLoopTime.value))", isDebug: true) if UserDefaultsRepository.alertNotLoopingActive.value && !UserDefaultsRepository.alertNotLoopingIsSnoozed.value && (Double(dateTimeUtils.getNowTimeIntervalUTC() - UserDefaultsRepository.alertLastLoopTime.value) >= Double(UserDefaultsRepository.alertNotLooping.value * 60)) && UserDefaultsRepository.alertLastLoopTime.value > 0 { - var trigger = true if (UserDefaultsRepository.alertNotLoopingUseLimits.value && ( (Float(currentBG) >= UserDefaultsRepository.alertNotLoopingUpperLimit.value @@ -363,6 +361,7 @@ extension MainViewController { if !UserDefaultsRepository.alertNotLoopingDayTimeAudible.value { playSound = false } } triggerAlarm(sound: UserDefaultsRepository.alertNotLoopingSound.value, snooozedBGReadingTime: nil, overrideVolume: UserDefaultsRepository.overrideSystemOutputVolume.value, numLoops: numLoops, snoozeTime: UserDefaultsRepository.alertNotLoopingSnooze.value, audio: playSound) + LogManager.shared.log(category: .alarm, message: "!!!Not Looping!!!") return } } @@ -961,7 +960,8 @@ extension MainViewController { // Speak always if always { speakBG(currentValue: currentValue, previousValue: previousValue) - print("Speaking because 'Always' is enabled.") + LogManager.shared.log(category: .general, message: "Speaking because 'Always' is enabled.", isDebug: true) + return } @@ -969,7 +969,7 @@ extension MainViewController { if speakLowBG { if currentValue <= Int(lowThreshold) || previousValue <= Int(lowThreshold) { speakBG(currentValue: currentValue, previousValue: previousValue) - print("Speaking because of 'Low' condition.") + LogManager.shared.log(category: .general, message: "Speaking because of 'Low' condition.", isDebug: true) return } } @@ -985,7 +985,7 @@ extension MainViewController { currentValue <= Int(lowThreshold) || previousValue <= Int(lowThreshold) || ((currentValue <= Int(highThreshold) && (previousValue - currentValue) >= Int(fastDropDelta))) { speakBG(currentValue: currentValue, previousValue: previousValue) - print("Speaking because of 'Proactive Low' condition. Predictive trigger: \(predictiveTrigger)") + LogManager.shared.log(category: .general, message: "Speaking because of 'Proactive Low' condition. Predictive trigger: \(predictiveTrigger)", isDebug: true) return } } @@ -994,12 +994,12 @@ extension MainViewController { if speakHighBG { if currentValue >= Int(highThreshold) || previousValue >= Int(highThreshold) { speakBG(currentValue: currentValue, previousValue: previousValue) - print("Speaking because of 'High' condition.") + LogManager.shared.log(category: .general, message: "Speaking because of 'High' condition.", isDebug: true) return } } - - print("No condition met for speaking.") + + LogManager.shared.log(category: .general, message: "No condition met for speaking.", isDebug: true) } struct AnnouncementTexts { @@ -1065,7 +1065,7 @@ extension MainViewController { try audioSession.setCategory(.playback, mode: .default) try audioSession.setActive(true) } catch { - print("Failed to set up audio session: \(error)") + LogManager.shared.log(category: .alarm, message: "speakBG, Failed to set up audio session: \(error)") } // Get the current time @@ -1075,7 +1075,7 @@ extension MainViewController { // If `lastSpeechTime` is `nil` (i.e., this is the first time `speakBG` is being called), use `Date.distantPast` as the default // value to ensure that the `guard` statement passes and the announcement is made. guard currentTime.timeIntervalSince(lastSpeechTime ?? .distantPast) >= 30 else { - print("Repeated calls to speakBG detected!") + LogManager.shared.log(category: .general, message: "Repeated calls to speakBG detected!", isDebug: true) return } diff --git a/LoopFollow/Controllers/AppStateController.swift b/LoopFollow/Controllers/AppStateController.swift index 422e1910..18053ca7 100644 --- a/LoopFollow/Controllers/AppStateController.swift +++ b/LoopFollow/Controllers/AppStateController.swift @@ -38,8 +38,6 @@ enum ChartSettingsChangeEnum: Int { enum GeneralSettingsChangeEnum: Int { case colorBGTextChange = 1 case speakBGChange = 2 - case backgroundRefreshFrequencyChange = 4 - case backgroundRefreshChange = 8 case appBadgeChange = 16 case dimScreenWhenIdleChange = 32 case forceDarkModeChang = 64 diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index bf6ba5e8..0be1d22a 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -9,44 +9,126 @@ import Foundation import UserNotifications +/// Enum representing different background alert durations. +enum BackgroundAlertDuration: TimeInterval, CaseIterable { + case sixMinutes = 360 // 6 minutes in seconds + case twelveMinutes = 720 // 12 minutes in seconds + case eighteenMinutes = 1080 // 18 minutes in seconds +} + +/// Enum representing unique identifiers for each background alert. +enum BackgroundAlertIdentifier: String, CaseIterable { + case sixMin = "loopfollow.background.alert.6min" + case twelveMin = "loopfollow.background.alert.12min" + case eighteenMin = "loopfollow.background.alert.18min" +} + class BackgroundAlertManager { static let shared = BackgroundAlertManager() - + private init() {} - + + /// Flag indicating whether background alerts are currently scheduled. private var isAlertScheduled: Bool = false - + + /// Title prefix for all background refresh notifications. + private let notificationTitlePrefix = "LoopFollow Background Refresh" + + /// Start scheduling background alerts. func startBackgroundAlert() { isAlertScheduled = true scheduleBackgroundAlert() } - + + /// Stop all scheduled background alerts. func stopBackgroundAlert() { isAlertScheduled = false - cancelBackgroundAlert() + removeDeliveredNotifications() + cancelBackgroundAlerts() } - + + /// (Re)schedule all background alerts based on predefined durations. func scheduleBackgroundAlert() { - guard isAlertScheduled, UserDefaultsRepository.backgroundRefresh.value else { return } - + removeDeliveredNotifications() + + guard isAlertScheduled, Storage.shared.backgroundRefreshType.value != .none else { return } + + let isBluetoothActive = Storage.shared.backgroundRefreshType.value.isBluetooth + let expectedHeartbeat = BLEManager.shared.expectedHeartbeatInterval() + + // Define alerts + let alerts: [BackgroundAlert] = [ + BackgroundAlert( + identifier: BackgroundAlertIdentifier.sixMin.rawValue, + timeInterval: BackgroundAlertDuration.sixMinutes.rawValue, + body: isBluetoothActive + ? "App inactive for 6 minutes. Verify Bluetooth connectivity." + : "App inactive for 6 minutes. Open to resume." + ), + BackgroundAlert( + identifier: BackgroundAlertIdentifier.twelveMin.rawValue, + timeInterval: BackgroundAlertDuration.twelveMinutes.rawValue, + body: isBluetoothActive + ? "App inactive for 12 minutes. Verify Bluetooth connectivity." + : "App inactive for 12 minutes. Open to resume." + ), + BackgroundAlert( + identifier: BackgroundAlertIdentifier.eighteenMin.rawValue, + timeInterval: BackgroundAlertDuration.eighteenMinutes.rawValue, + body: isBluetoothActive + ? "App inactive for 18 minutes. Verify Bluetooth connectivity." + : "App inactive for 18 minutes. Open to resume." + ) + ] + + for alert in alerts { + // Skip if the expected heartbeat interval matches or exceeds 1.2x the alert time interval + if let heartbeat = expectedHeartbeat, heartbeat * 1.2 >= alert.timeInterval { + continue + } + + let content = createNotificationContent(for: notificationTitlePrefix, body: alert.body) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: alert.timeInterval, repeats: false) + let request = UNNotificationRequest(identifier: alert.identifier, content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + LogManager.shared.log(category: .general, message: "Error scheduling \(alert.timeInterval / 60)-minute background alert: \(error)") + } + } + } + } + + /// Create notification content with a given title and body. + /// - Parameters: + /// - title: The title of the notification. + /// - body: The body text of the notification. + /// - Returns: Configured `UNMutableNotificationContent` object. + private func createNotificationContent(for title: String, body: String) -> UNMutableNotificationContent { let content = UNMutableNotificationContent() - content.title = "LoopFollow Background Refresh" - content.body = "The app is not active, open the app to resume." + content.title = title + content.body = body content.sound = .defaultCritical content.categoryIdentifier = "loopfollow.background.alert" - - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 360, repeats: false) - - let request = UNNotificationRequest(identifier: "loopfollow.background.alert", content: content, trigger: trigger) - - UNUserNotificationCenter.current().add(request) { error in - if let error = error { - print("Error scheduling background alert: \(error)") - } - } + return content } - - private func cancelBackgroundAlert() { - UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["loopfollow.background.alert"]) + + /// Cancel all scheduled background alerts. + private func cancelBackgroundAlerts() { + let identifiers = BackgroundAlertIdentifier.allCases.map { $0.rawValue } + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers) } + + /// Remove all delivered notifications + private func removeDeliveredNotifications() { + let identifiers = BackgroundAlertIdentifier.allCases.map { $0.rawValue } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) + } +} + +/// Struct representing a single background alert. +struct BackgroundAlert { + let identifier: String + let timeInterval: TimeInterval + let body: String } diff --git a/LoopFollow/Controllers/Graphs.swift b/LoopFollow/Controllers/Graphs.swift index 79287c3a..3f70cda7 100644 --- a/LoopFollow/Controllers/Graphs.swift +++ b/LoopFollow/Controllers/Graphs.swift @@ -234,8 +234,6 @@ extension MainViewController { } func chartScaled(_ chartView: ChartViewBase, scaleX: CGFloat, scaleY: CGFloat) { - print("Chart Scaled: \(BGChart.scaleX), \(BGChart.scaleY)") - // dont store huge values var scale: Float = Float(BGChart.scaleX) if(scale > ScaleXMax ) { @@ -801,7 +799,6 @@ extension MainViewController { } func updateBGGraph() { - if UserDefaultsRepository.debugLog.value { writeDebugLog(value: "##### Start BG Graph #####") } let dataIndex = 0 let entries = bgData guard !entries.isEmpty else { @@ -821,7 +818,6 @@ extension MainViewController { topBG = Float(entries[i].sgv) + maxBGOffset } let value = ChartDataEntry(x: Double(entries[i].date), y: Double(entries[i].sgv), data: formatPillText(line1: Localizer.toDisplayUnits(String(entries[i].sgv)), time: entries[i].date)) - if UserDefaultsRepository.debugLog.value { writeDebugLog(value: "BG: " + value.description) } mainChart.append(value) smallChart.append(value) @@ -834,8 +830,7 @@ extension MainViewController { } } - if UserDefaultsRepository.debugLog.value { writeDebugLog(value: "Total Graph BGs: " + mainChart.entries.count.description) } - + // Set Colors let lineBG = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet @@ -854,7 +849,6 @@ extension MainViewController { } } - if UserDefaultsRepository.debugLog.value { writeDebugLog(value: "Total Colors: " + mainChart.colors.count.description) } BGChart.rightAxis.axisMaximum = Double(calculateMaxBgGraphValue()) BGChart.setVisibleXRangeMinimum(600) @@ -868,7 +862,6 @@ extension MainViewController { if firstGraphLoad { var scaleX = CGFloat(UserDefaultsRepository.chartScaleX.value) - print("Scale: \(scaleX)") if( scaleX > CGFloat(ScaleXMax) ) { scaleX = CGFloat(ScaleXMax) UserDefaultsRepository.chartScaleX.value = ScaleXMax @@ -1717,7 +1710,7 @@ extension MainViewController { index.rawValue < chart.dataSets.count, let smallChartData = BGChartFull.lineData, index.rawValue < smallChartData.dataSets.count else { - print("Warning: Invalid GraphDataIndex \(index.description) or lineData is nil.") + //print("Warning: Invalid GraphDataIndex \(index.description) or lineData is nil.") return (nil, nil) } diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index 2234d741..7297d8ab 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -18,17 +18,14 @@ extension MainViewController { dexShare?.fetchData(count) { (err, result) -> () in if let error = err { - print("Error fetching Dexcom data: \(error.localizedDescription)") - - // If we get an error, immediately try to pull NS BG Data - if IsNightscoutEnabled() { - self.webLoadNSBGData() - } + LogManager.shared.log(category: .dexcom, message: "Error fetching Dexcom data: \(error.localizedDescription)") + self.webLoadNSBGData() return } guard let data = result else { - print("Received nil data from Dexcom") + LogManager.shared.log(category: .dexcom, message: "Received nil data from Dexcom") + self.webLoadNSBGData() return } @@ -36,8 +33,8 @@ extension MainViewController { let latestDate = data[0].date let now = dateTimeUtils.getNowTimeIntervalUTC() if (latestDate + 330) < now && IsNightscoutEnabled() { + LogManager.shared.log(category: .dexcom, message: "Dexcom data is old, loading from NS instead") self.webLoadNSBGData() - print("Dex data is old, loading from NS instead") return } @@ -52,14 +49,11 @@ extension MainViewController { // NS BG Data Web call func webLoadNSBGData(dexData: [ShareGlucoseData] = []) { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Download: BG") } - // This kicks it out in the instance where dexcom fails but they aren't using NS && if !IsNightscoutEnabled() { - self.startBGTimer(time: 10) return } - + var parameters: [String: String] = [:] let utcISODateFormatter = ISO8601DateFormatter() let date = Calendar.current.date(byAdding: .day, value: -1 * UserDefaultsRepository.downloadDays.value, to: Date())! @@ -115,12 +109,12 @@ extension MainViewController { self.ProcessDexBGData(data: nsData2, sourceName: sourceName) } case .failure(let error): - print("Failed to fetch data: \(error)") + LogManager.shared.log(category: .nightscout, message: "Failed to fetch data: \(error)") DispatchQueue.main.async { - if self.bgTimer.isValid { - self.bgTimer.invalidate() - } - self.startBGTimer(time: 10) + TaskScheduler.shared.rescheduleTask( + id: .fetchBG, + to: Date().addingTimeInterval(10) + ) } // if we have Dex data, use it if !dexData.isEmpty { @@ -133,14 +127,11 @@ extension MainViewController { // Dexcom BG Data Response processor func ProcessDexBGData(data: [ShareGlucoseData], sourceName: String){ - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: BG") } - let graphHours = 24 * UserDefaultsRepository.downloadDays.value if data.count == 0 { return } - let pullDate = data[data.count - 1].date let latestDate = data[0].date let now = dateTimeUtils.getNowTimeIntervalUTC() @@ -148,31 +139,33 @@ extension MainViewController { let secondsAgo = now - latestDate DispatchQueue.main.async { - // if reading is overdue over: 20:00, re-attempt every 5 minutes if secondsAgo >= (20 * 60) { - self.startBGTimer(time: (5 * 60)) - print("##### started 5 minute bg timer") - - // if the reading is overdue: 10:00-19:59, re-attempt every minute + TaskScheduler.shared.rescheduleTask( + id: .fetchBG, + to: Date().addingTimeInterval(5 * 60) + ) } else if secondsAgo >= (10 * 60) { - self.startBGTimer(time: 60) - print("##### started 1 minute bg timer") - - // if the reading is overdue: 7:00-9:59, re-attempt every 30 seconds + TaskScheduler.shared.rescheduleTask( + id: .fetchBG, + to: Date().addingTimeInterval(60) + ) } else if secondsAgo >= (7 * 60) { - self.startBGTimer(time: 30) - print("##### started 30 second bg timer") - - // if the reading is overdue: 5:00-6:59 re-attempt every 10 seconds + TaskScheduler.shared.rescheduleTask( + id: .fetchBG, + to: Date().addingTimeInterval(30) + ) } else if secondsAgo >= (5 * 60) { - self.startBGTimer(time: 10) - print("##### started 10 second bg timer") - - // We have a current reading. Set timer to 5:10 from last reading + TaskScheduler.shared.rescheduleTask( + id: .fetchBG, + to: Date().addingTimeInterval(10) + ) } else { - self.startBGTimer(time: 300 - secondsAgo + Double(UserDefaultsRepository.bgUpdateDelay.value)) - let timerVal = 310 - secondsAgo - print("##### started 5:10 bg timer: \(timerVal)") + let delay = (300 - secondsAgo + Double(UserDefaultsRepository.bgUpdateDelay.value)) + TaskScheduler.shared.rescheduleTask( + id: .fetchBG, + to: Date().addingTimeInterval(delay) + ) + if data.count > 1 { self.evaluateSpeakConditions(currentValue: data[0].sgv, previousValue: data[1].sgv) } @@ -212,11 +205,8 @@ extension MainViewController { // NS BG Data Front end updater func viewUpdateNSBG(sourceName: String) { DispatchQueue.main.async { - if UserDefaultsRepository.debugLog.value { - self.writeDebugLog(value: "Display: BG") - self.writeDebugLog(value: "Num BG: " + self.bgData.count.description) - } - + TaskScheduler.shared.rescheduleTask(id: .minAgoUpdate, to: Date()) + let entries = self.bgData if entries.count < 2 { return } // Protect index out of bounds @@ -229,12 +219,7 @@ extension MainViewController { let deltaBG = latestBG - priorBG let lastBGTime = entries[latestEntryIndex].date - let deltaTime = (TimeInterval(Date().timeIntervalSince1970) - lastBGTime) / 60 - var userUnit = " mg/dL" - if self.mmol { - userUnit = " mmol/L" - } - + let deltaTime = (TimeInterval(Date().timeIntervalSince1970) - lastBGTime) / 60 self.updateServerText(with: sourceName) var snoozerBG = "" diff --git a/LoopFollow/Controllers/Nightscout/CAge.swift b/LoopFollow/Controllers/Nightscout/CAge.swift index 0a714400..b5546e45 100644 --- a/LoopFollow/Controllers/Nightscout/CAge.swift +++ b/LoopFollow/Controllers/Nightscout/CAge.swift @@ -23,7 +23,7 @@ extension MainViewController { case .success(let data): self.updateCage(data: data) case .failure(let error): - print("Error: \(error.localizedDescription)") + LogManager.shared.log(category: .nightscout, message: "webLoadNSCage, error: \(error.localizedDescription)") } } } @@ -31,7 +31,6 @@ extension MainViewController { // NS Cage Response Processor func updateCage(data: [cageData]) { infoManager.clearInfoData(type: .cage) - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: CAGE") } if data.count == 0 { return } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 5ef0e837..45594967 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -13,11 +13,12 @@ import Charts extension MainViewController { // NS Device Status Web Call func webLoadNSDeviceStatus() { - if UserDefaultsRepository.debugLog.value { - self.writeDebugLog(value: "Download: device status") + let count = ObservableUserDefaults.shared.device.value == "Trio" && Observable.shared.isLastDeviceStatusSuggested.value ? "5" : "1" + if count != "1" { + LogManager.shared.log(category: .deviceStatus, message: "Fetching \(count) device status records") } - - let parameters: [String: String] = ["count": "1"] + + let parameters: [String: String] = ["count": count] NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters) { result in switch result { case .success(let json): @@ -28,19 +29,16 @@ extension MainViewController { } else { self.handleDeviceStatusError() } - case .failure: self.handleDeviceStatusError() } } } - + private func handleDeviceStatusError() { + LogManager.shared.log(category: .deviceStatus, message: "Device status fetch failed!") DispatchQueue.main.async { - if self.deviceStatusTimer.isValid { - self.deviceStatusTimer.invalidate() - } - self.startDeviceStatusTimer(time: 10) + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date().addingTimeInterval(10)) } } @@ -85,8 +83,8 @@ extension MainViewController { func updateDeviceStatusDisplay(jsonDeviceStatus: [[String:AnyObject]]) { infoManager.clearInfoData(types: [.iob, .cob, .override, .battery, .pump, .target, .isf, .carbRatio, .updated, .recBolus, .tdd]) - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: device status") } if jsonDeviceStatus.count == 0 { + LogManager.shared.log(category: .deviceStatus, message: "Device status is empty") return } @@ -148,7 +146,7 @@ extension MainViewController { // OpenAPS - handle new data if let lastLoopRecord = lastDeviceStatus?["openaps"] as! [String : AnyObject]? { - DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord) + DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord, jsonDeviceStatus: jsonDeviceStatus) } // Start the timer based on the timestamp @@ -156,32 +154,37 @@ extension MainViewController { let secondsAgo = now - latestLoopTime DispatchQueue.main.async { - // if Loop is overdue over: 20:00, re-attempt every 5 minutes if secondsAgo >= (20 * 60) { - self.startDeviceStatusTimer(time: (5 * 60)) - print("started 5 minute device status timer") - - // if the Loop is overdue: 10:00-19:59, re-attempt every minute + TaskScheduler.shared.rescheduleTask( + id: .deviceStatus, + to: Date().addingTimeInterval(5 * 60) + ) + } else if secondsAgo >= (10 * 60) { - self.startDeviceStatusTimer(time: 60) - print("started 1 minute device status timer") - - // if the Loop is overdue: 7:00-9:59, re-attempt every 30 seconds + TaskScheduler.shared.rescheduleTask( + id: .deviceStatus, + to: Date().addingTimeInterval(60) + ) + } else if secondsAgo >= (7 * 60) { - self.startDeviceStatusTimer(time: 30) - print("started 30 second device status timer") - - // if the Loop is overdue: 5:00-6:59 re-attempt every 10 seconds + TaskScheduler.shared.rescheduleTask( + id: .deviceStatus, + to: Date().addingTimeInterval(30) + ) + } else if secondsAgo >= (5 * 60) { - self.startDeviceStatusTimer(time: 10) - print("started 10 second device status timer") - - // We have a current Loop. Set timer to 5:10 from last reading + TaskScheduler.shared.rescheduleTask( + id: .deviceStatus, + to: Date().addingTimeInterval(10) + ) } else { - self.startDeviceStatusTimer(time: 310 - secondsAgo) - let timerVal = 310 - secondsAgo - print("started 5:10 device status timer: \(timerVal)") + let interval = (310 - secondsAgo) + TaskScheduler.shared.rescheduleTask( + id: .deviceStatus, + to: Date().addingTimeInterval(interval) + ) } } + LogManager.shared.log(category: .deviceStatus, message: "Update Device Status done", isDebug: true) } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index f991663c..45305766 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -16,15 +16,12 @@ extension MainViewController { ObservableUserDefaults.shared.device.value = "Loop" if let lastLoopTime = formatter.date(from: (lastLoopRecord["timestamp"] as! String))?.timeIntervalSince1970 { UserDefaultsRepository.alertLastLoopTime.value = lastLoopTime - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "lastLoopTime: " + String(lastLoopTime)) } if let failure = lastLoopRecord["failureReason"] { LoopStatusLabel.text = "X" latestLoopStatusString = "X" - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Loop Failure: X") } } else { var wasEnacted = false if let enacted = lastLoopRecord["enacted"] as? [String:AnyObject] { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Loop: Was Enacted") } wasEnacted = true if let lastTempBasal = enacted["rate"] as? Double { @@ -112,23 +109,17 @@ extension MainViewController { if bgData.count > 0 { lastBGTime = bgData[bgData.count - 1].date } - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "tempBasalTime: " + String(tempBasalTime)) } - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "lastBGTime: " + String(lastBGTime)) } - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "wasEnacted: " + String(wasEnacted)) } if tempBasalTime > lastBGTime && !wasEnacted { LoopStatusLabel.text = "⏀" latestLoopStatusString = "⏀" - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Open Loop: recommended temp. temp time > bg time, was not enacted") } } else { LoopStatusLabel.text = "↻" latestLoopStatusString = "↻" - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Looping: recommended temp, but temp time is < bg time and/or was enacted") } } } } else { LoopStatusLabel.text = "↻" latestLoopStatusString = "↻" - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Looping: no recommended temp") } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 19303df6..35ba3f61 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -8,7 +8,7 @@ import UIKit import HealthKit extension MainViewController { - func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) { + func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject], jsonDeviceStatus: [[String:AnyObject]]) { if let createdAtString = lastDeviceStatus?["created_at"] as? String, let lastLoopTime = formatter.date(from: createdAtString)?.timeIntervalSince1970 { ObservableUserDefaults.shared.device.value = lastDeviceStatus?["device"] as? String ?? "" @@ -31,9 +31,16 @@ extension MainViewController { wasEnacted = false } + Observable.shared.isLastDeviceStatusSuggested.value = !wasEnacted + if wasEnacted { UserDefaultsRepository.alertLastLoopTime.value = lastLoopTime + LogManager.shared.log(category: .deviceStatus, message: "New LastLoopTime: \(lastLoopTime)", isDebug: true) + evaluateNotLooping(lastLoopTime: UserDefaultsRepository.alertLastLoopTime.value) + } else { + LogManager.shared.log(category: .deviceStatus, message: "Last devicestatus was not enacted") + findFallbackEnactedAndSetLoopTime(in: jsonDeviceStatus, formatter: formatter) } if let timestamp = enactedOrSuggested["timestamp"] as? String, @@ -230,4 +237,30 @@ extension MainViewController { } } } + + private func findFallbackEnactedAndSetLoopTime( + in allDeviceStatuses: [[String: AnyObject]], + formatter: ISO8601DateFormatter + ) { + for i in 1 ..< allDeviceStatuses.count { + let ds = allDeviceStatuses[i] + guard + let openaps = ds["openaps"] as? [String: AnyObject], + openaps["failureReason"] == nil, + let enacted = openaps["enacted"] as? [String: AnyObject], + let dateString = ds["created_at"] as? String, + let dateTime = formatter.date(from: dateString)?.timeIntervalSince1970 + else { + continue + } + + UserDefaultsRepository.alertLastLoopTime.value = dateTime + LogManager.shared.log(category: .deviceStatus, message: "Found older enacted. Setting lastLoopTime to \(dateTime)", isDebug: true) + + evaluateNotLooping(lastLoopTime: dateTime) + return + } + + LogManager.shared.log(category: .deviceStatus, message: "No older record was enacted!") + } } diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index c106dea1..5406da54 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -27,7 +27,7 @@ extension MainViewController { self.updateIage(data: data) } case .failure(let error): - print("Failed to fetch data: \(error.localizedDescription)") + LogManager.shared.log(category: .nightscout, message: "webLoadNSIage, failed to fetch data: \(error.localizedDescription)") } } } diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index d900b4e9..8f444e07 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -15,15 +15,13 @@ extension MainViewController { case .success(let profileData): self.updateProfile(profileData: profileData) case .failure(let error): - print("Error fetching profile data: \(error.localizedDescription)") + LogManager.shared.log(category: .nightscout, message: "webLoadNSProfile, error fetching profile data: \(error.localizedDescription)") } } } // NS Profile Response Processor func updateProfile(profileData: NSProfile) { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: profile") } - guard let store = profileData.store["default"] ?? profileData.store["Default"] else { return } diff --git a/LoopFollow/Controllers/Nightscout/SAge.swift b/LoopFollow/Controllers/Nightscout/SAge.swift index 9eb1ac03..4f07a0ef 100644 --- a/LoopFollow/Controllers/Nightscout/SAge.swift +++ b/LoopFollow/Controllers/Nightscout/SAge.swift @@ -27,7 +27,7 @@ extension MainViewController { self.updateSage(data: data) } case .failure(let error): - print("Failed to fetch data: \(error.localizedDescription)") + LogManager.shared.log(category: .nightscout, message: "webLoadNSSage, failed to fetch data: \(error.localizedDescription)") } } } @@ -36,7 +36,6 @@ extension MainViewController { func updateSage(data: [sageData]) { infoManager.clearInfoData(type: .sage) - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process/Display: SAGE") } if data.count == 0 { return } @@ -62,7 +61,6 @@ extension MainViewController { if let sageTime = formatter.date(from: (lastSageString as! String))?.timeIntervalSince1970 { let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - sageTime - let days = 24 * 60 * 60 let formatter = DateComponentsFormatter() formatter.unitsStyle = .positional // Use the appropriate positioning for the current locale diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 8717e626..66f8f159 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -11,7 +11,6 @@ extension MainViewController { // NS Treatments Web Call // Downloads Basal, Bolus, Carbs, BG Check, Notes, Overrides func WebLoadNSTreatments() { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Download: Treatments") } if !UserDefaultsRepository.downloadTreatments.value { return } let startTimeString = dateTimeUtils.getDateTimeString(addingDays: -1 * UserDefaultsRepository.downloadDays.value) @@ -28,10 +27,10 @@ extension MainViewController { self.updateTreatments(entries: entries) } } else { - print("Error: Unexpected data structure") + LogManager.shared.log(category: .nightscout, message: "WebLoadNSTreatments, Unexpected data structure") } case .failure(let error): - print("Error: \(error.localizedDescription)") + LogManager.shared.log(category: .nightscout, message: "WebLoadNSTreatments, error \(error.localizedDescription)") } } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/BGCheck.swift b/LoopFollow/Controllers/Nightscout/Treatments/BGCheck.swift index af1437ad..90732cad 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/BGCheck.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/BGCheck.swift @@ -12,7 +12,6 @@ import UIKit extension MainViewController { // NS BG Check Response Processor func processNSBGCheck(entries: [[String:AnyObject]]) { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: BG Check") } bgCheckData.removeAll() entries.reversed().forEach { currentEntry in @@ -20,7 +19,6 @@ extension MainViewController { guard let parsedDate = NightscoutUtils.parseDate(dateStr), let glucose = currentEntry["glucose"] as? Double else { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "ERROR: Non-Double Glucose entry") } return } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 3ff9c939..40964621 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -34,7 +34,6 @@ extension MainViewController { let dateTimeStamp = dateParsed.timeIntervalSince1970 guard let basalRate = currentEntry["absolute"] as? Double else { - self.writeDebugLog(value: "ERROR: Null Basal entry") continue } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift index 4fdf1c00..15916a17 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift @@ -10,7 +10,6 @@ import Foundation extension MainViewController { // NS Meal Bolus Response Processor func processNSBolus(entries: [[String:AnyObject]]) { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Bolus") } // because it's a small array, we're going to destroy and reload every time. bolusData.removeAll() var lastFoundIndex = 0 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index 16a70816..285e1f5e 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -10,7 +10,6 @@ import Foundation extension MainViewController { // NS Carb Bolus Response Processor func processNSCarbs(entries: [[String:AnyObject]]) { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Carbs") } // Because it's a small array, we're going to destroy and reload every time. carbData.removeAll() var lastFoundIndex = 0 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift index 051cfd10..1dea8e0c 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift @@ -12,7 +12,6 @@ import UIKit extension MainViewController { // NS Note Response Processor func processNotes(entries: [[String:AnyObject]]) { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Notes") } // because it's a small array, we're going to destroy and reload every time. noteGraphData.removeAll() var lastFoundIndex = 0 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index e23722c0..68a80e22 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -12,7 +12,6 @@ import UIKit extension MainViewController { // NS Override Response Processor func processNSOverrides(entries: [[String:AnyObject]]) { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Overrides") } overrideGraphData.removeAll() var activeOverrideNote: String? = nil diff --git a/LoopFollow/Controllers/Nightscout/Treatments/ResumePump.swift b/LoopFollow/Controllers/Nightscout/Treatments/ResumePump.swift index 0c7e4def..fd803d16 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/ResumePump.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/ResumePump.swift @@ -10,7 +10,6 @@ import Foundation extension MainViewController { // NS Resume Pump Response Processor func processResumePump(entries: [[String:AnyObject]]) { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Resume Pump") } resumeGraphData.removeAll() var lastFoundIndex = 0 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/SuspendPump.swift b/LoopFollow/Controllers/Nightscout/Treatments/SuspendPump.swift index b53b3684..43ba0568 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/SuspendPump.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/SuspendPump.swift @@ -10,7 +10,6 @@ import Foundation extension MainViewController { // NS Suspend Pump Response Processor func processSuspendPump(entries: [[String:AnyObject]]) { - if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Suspend Pump") } suspendGraphData.removeAll() var lastFoundIndex = 0 diff --git a/LoopFollow/Controllers/Timers.swift b/LoopFollow/Controllers/Timers.swift index 756f4978..38ccb68e 100644 --- a/LoopFollow/Controllers/Timers.swift +++ b/LoopFollow/Controllers/Timers.swift @@ -9,94 +9,8 @@ import Foundation import UIKit - extension MainViewController { - - func restartAllTimers() { - if !bgTimer.isValid { startBGTimer(time: 2) } - if !profileTimer.isValid { startProfileTimer(time: 3) } - if !deviceStatusTimer.isValid { startDeviceStatusTimer(time: 4) } - if !treatmentsTimer.isValid { startTreatmentsTimer(time: 5) } - if !minAgoTimer.isValid { startMinAgoTimer(time: minAgoTimeInterval) } - if !calendarTimer.isValid { startCalendarTimer(time: 15) } - if !alarmTimer.isValid { startAlarmTimer(time: 30) } - } - - func invalidateTimers() { - bgTimer.invalidate() - profileTimer.invalidate() - deviceStatusTimer.invalidate() - treatmentsTimer.invalidate() - minAgoTimer.invalidate() - calendarTimer.invalidate() - alarmTimer.invalidate() - } - - // min Ago Timer - func startMinAgoTimer(time: TimeInterval) { - minAgoTimer = Timer.scheduledTimer(timeInterval: time, - target: self, - selector: #selector(MainViewController.minAgoTimerDidEnd(_:)), - userInfo: nil, - repeats: true) - } - - @objc func minAgoTimerDidEnd(_ timer: Timer) { - if bgData.count > 0 { - let bgSeconds = bgData.last!.date - let now = Date().timeIntervalSince1970 - let secondsAgo = now - bgSeconds - - // Update Min Ago Displays - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .positional // Use the appropriate positioning for the current locale - - if secondsAgo >= 720 { // 720 seconds = 12 minutes - formatter.allowedUnits = [.minute] // Only show minutes after 12 minutes have passed - } else if secondsAgo < 270 { // Less than 4.5 minutes - formatter.allowedUnits = [.minute] // Show only minutes if less than 4.5 minutes - } else { - formatter.allowedUnits = [.minute, .second] // Show minutes and seconds otherwise - } - - let formattedDuration = formatter.string(from: secondsAgo) ?? "" - let minAgoDisplayText = formattedDuration + " min ago" - - MinAgoText.text = minAgoDisplayText - latestMinAgoString = minAgoDisplayText - - if let snoozer = self.tabBarController!.viewControllers?[2] as? SnoozeViewController { - snoozer.MinAgoLabel.text = minAgoDisplayText - - // Start with the current BGLabel text - let bgLabelText = snoozer.BGLabel.text ?? "" - let attributeString = NSMutableAttributedString(string: bgLabelText) - - // Always apply the strikethrough style - attributeString.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: 0, length: attributeString.length)) - - // Conditionally set the strikethrough color based on the freshness of the data - if secondsAgo >= 720 { // Data is stale - attributeString.addAttribute(.strikethroughColor, value: UIColor.systemRed, range: NSRange(location: 0, length: attributeString.length)) - } else { // Data is fresh - attributeString.addAttribute(.strikethroughColor, value: UIColor.clear, range: NSRange(location: 0, length: attributeString.length)) - } - - snoozer.BGLabel.attributedText = attributeString - } - } else { - MinAgoText.text = "" - latestMinAgoString = "" - - if let snoozer = self.tabBarController!.viewControllers?[2] as? SnoozeViewController { - snoozer.MinAgoLabel.text = "" - // Reset BGLabel to ensure no formatting is carried over - snoozer.BGLabel.text = "" - snoozer.BGLabel.attributedText = NSAttributedString(string: "") - } - } - } - + // Runs a 60 second timer when an alarm is snoozed // Prevents the alarm from triggering again while saving the snooze time to settings // End function needs nothing done @@ -127,109 +41,7 @@ extension MainViewController { @objc func checkAlarmTimerDidEnd(_ timer:Timer) { } - - // BG Timer - // Runs to 5:10 after last reading timestamp - // Failed or no reading re-attempts after 10 second delay - // Changes to 30 second increments after 7:00 - // Changes to 1 minute increments after 10:00 - // Changes to 5 minute increments after 20:00 stale data - func startBGTimer(time: TimeInterval = 60 * 5) { - bgTimer = Timer.scheduledTimer(timeInterval: time, - target: self, - selector: #selector(MainViewController.bgTimerDidEnd(_:)), - userInfo: nil, - repeats: false) - } - - @objc func bgTimerDidEnd(_ timer:Timer) { - // reset timer to 1 minute if settings aren't entered - if UserDefaultsRepository.shareUserName.value == "" && UserDefaultsRepository.sharePassword.value == "" && !IsNightscoutEnabled() { - startBGTimer(time: 60) - return - } - - if UserDefaultsRepository.shareUserName.value != "" && UserDefaultsRepository.sharePassword.value != "" { - webLoadDexShare() - } else { - webLoadNSBGData() - } - BackgroundAlertManager.shared.scheduleBackgroundAlert() - } - - // Device Status Timer - // Runs to 5:10 after last reading timestamp - // Failed or no update re-attempts after 10 second delay - // Changes to 30 second increments after 7:00 - // Changes to 1 minute increments after 10:00 - // Changes to 5 minute increments after 20:00 stale data - func startDeviceStatusTimer(time: TimeInterval = 60 * 5) { - deviceStatusTimer = Timer.scheduledTimer(timeInterval: time, - target: self, - selector: #selector(MainViewController.deviceStatusTimerDidEnd(_:)), - userInfo: nil, - repeats: false) - } - - @objc func deviceStatusTimerDidEnd(_ timer:Timer) { - - // reset timer to 1 minute if settings aren't entered - if !IsNightscoutEnabled() { - startDeviceStatusTimer(time: 60) - return - } else { - webLoadNSDeviceStatus() - } - } - - // Treatments Timer - // Runs on 2 minute intervals - // Pauses with stale BG data - func startTreatmentsTimer(time: TimeInterval = 60 * 2) { - treatmentsTimer = Timer.scheduledTimer(timeInterval: time, - target: self, - selector: #selector(MainViewController.treatmentsTimerDidEnd(_:)), - userInfo: nil, - repeats: false) - } - - @objc func treatmentsTimerDidEnd(_ timer:Timer) { - // reset timer to 1 minute if settings aren't entered - if !IsNightscoutEnabled() { - startTreatmentsTimer(time: 60) - return - } - - if IsNightscoutEnabled() && UserDefaultsRepository.downloadTreatments.value { - WebLoadNSTreatments() - } - startTreatmentsTimer() - } - - // Profile Timer - // Runs on 10 minute intervals - // Pauses with stale BG data - func startProfileTimer(time: TimeInterval = 60 * 10) { - profileTimer = Timer.scheduledTimer(timeInterval: time, - target: self, - selector: #selector(MainViewController.profileTimerDidEnd(_:)), - userInfo: nil, - repeats: false) - } - - @objc func profileTimerDidEnd(_ timer:Timer) { - // reset timer to 1 minute if settings aren't entered - if !IsNightscoutEnabled() { - startProfileTimer(time: 60) - return - } - - if IsNightscoutEnabled() { - webLoadNSProfile() - startProfileTimer() - } - } - + // Cancel and reset the playing alarm if it has not been snoozed after 4 min 50 seconds. // This allows the next BG reading to either start the timer going or not fire if the situation has been resolved func startAlarmPlayingTimer(time: TimeInterval = 290) { @@ -245,62 +57,4 @@ extension MainViewController { stopAlarmAtNextReading() } } - - - // Alarm Timer - // Run the alarm checker every 15 seconds - func startAlarmTimer(time: TimeInterval) { - alarmTimer = Timer.scheduledTimer(timeInterval: time, - target: self, - selector: #selector(MainViewController.alarmTimerDidEnd(_:)), - userInfo: nil, - repeats: true) - - } - - @objc func alarmTimerDidEnd(_ timer:Timer) { - if bgData.count > 0 { - self.checkAlarms(bgs: bgData) - } - if overrideGraphData.count > 0 { - self.checkOverrideAlarms() - } - if tempTargetGraphData.count > 0 { - self.checkTempTargetAlarms() - } - } - - // Calendar Timer - // Run the calendar writer every 30 seconds - func startCalendarTimer(time: TimeInterval) { - calendarTimer = Timer.scheduledTimer(timeInterval: time, - target: self, - selector: #selector(MainViewController.calendarTimerDidEnd(_:)), - userInfo: nil, - repeats: true) - - } - - @objc func calendarTimerDidEnd(_ timer:Timer) { - if UserDefaultsRepository.writeCalendarEvent.value && UserDefaultsRepository.calendarIdentifier.value != "" { - self.writeCalendar() - } - } - - - - // Timer to allow us to write min ago calendar entries but not update them every 30 seconds - func startCalTimer(time: TimeInterval) { - calTimer = Timer.scheduledTimer(timeInterval: time, - target: self, - selector: #selector(MainViewController.calTimerDidEnd(_:)), - userInfo: nil, - repeats: false) - } - - // Nothing should be done when this timer ends because it just blocks the calendar from writing when it's active - @objc func calTimerDidEnd(_ timer:Timer) { - - } - } diff --git a/LoopFollow/Dexcom/DexcomSettingsView.swift b/LoopFollow/Dexcom/DexcomSettingsView.swift new file mode 100644 index 00000000..7e1ecdd8 --- /dev/null +++ b/LoopFollow/Dexcom/DexcomSettingsView.swift @@ -0,0 +1,44 @@ +// +// DexcomSettingsView.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-18. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import SwiftUI + +struct DexcomSettingsView: View { + @ObservedObject var viewModel: DexcomSettingsViewModel + @Environment(\.presentationMode) var presentationMode + + var body: some View { + NavigationView { + Form { + Section(header: Text("Dexcom Settings")) { + TextField("User Name", text: $viewModel.userName) + .autocapitalization(.none) + .disableAutocorrection(true) + + TextField("Password", text: $viewModel.password) + .autocapitalization(.none) + .disableAutocorrection(true) + + Picker("Server", selection: $viewModel.server) { + Text("US").tag("US") + Text("NON-US").tag("NON-US") + } + .pickerStyle(SegmentedPickerStyle()) + } + } + .navigationBarTitle("Dexcom Settings", displayMode: .inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + presentationMode.wrappedValue.dismiss() + } + } + } + } + } +} diff --git a/LoopFollow/Dexcom/DexcomSettingsViewModel.swift b/LoopFollow/Dexcom/DexcomSettingsViewModel.swift new file mode 100644 index 00000000..7fb1732a --- /dev/null +++ b/LoopFollow/Dexcom/DexcomSettingsViewModel.swift @@ -0,0 +1,37 @@ +// +// DexcomSettingsViewModel.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-18. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation +import Combine + +class DexcomSettingsViewModel: ObservableObject { + @Published var userName: String = UserDefaultsRepository.shareUserName.value { + willSet { + if newValue != userName { + UserDefaultsRepository.shareUserName.value = newValue + } + } + } + @Published var password: String = UserDefaultsRepository.sharePassword.value { + willSet { + if newValue != password { + UserDefaultsRepository.sharePassword.value = newValue + } + } + } + @Published var server: String = UserDefaultsRepository.shareServer.value { + willSet { + if newValue != server { + UserDefaultsRepository.shareServer.value = newValue + } + } + } + + init() { + } +} diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 4c5af073..adfe0386 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -22,9 +22,11 @@ class BackgroundTask { func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) player.stop() + LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } @objc fileprivate func interruptedAudio(_ notification: Notification) { + LogManager.shared.log(category: .general, message: "Silent audio interrupted") if notification.name == AVAudioSession.interruptionNotification && notification.userInfo != nil { var info = notification.userInfo! var intValue = 0 @@ -46,8 +48,9 @@ class BackgroundTask { self.player.volume = 0.01 self.player.prepareToPlay() self.player.play() - print("silent audio playing") - } catch { print(error) + LogManager.shared.log(category: .general, message: "Silent audio playing", isDebug: true) + } catch { + LogManager.shared.log(category: .general, message: "playAudio, error: \(error)") } } } diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index a0c421de..8916329b 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -2,6 +2,8 @@ + NSHumanReadableCopyright + AppGroupIdentifier group.com.$(unique_id).LoopFollow$(app_suffix) BGTaskSchedulerPermittedIdentifiers @@ -14,6 +16,8 @@ $(display_name) CFBundleExecutable $(EXECUTABLE_NAME) + CFBundleGetInfoString + CFBundleIdentifier com.$(unique_id).LoopFollow$(app_suffix) CFBundleInfoDictionaryVersion @@ -43,10 +47,14 @@ 13.0 LSRequiresIPhoneOS + NSBluetoothAlwaysUsageDescription + This app uses Bluetooth to connect to devices for managing background operations. NSCalendarsFullAccessUsageDescription Loop Follow would like to access your calendar to update BG readings NSCalendarsUsageDescription Loop Follow would like to access your calendar to save BG readings + NSContactsUsageDescription + This app requires access to contacts to update a contact image with real-time blood glucose information. NSFaceIDUsageDescription This app requires Face ID for secure authentication. UIApplicationSceneManifest @@ -72,6 +80,7 @@ audio processing + bluetooth-central UILaunchStoryboardName LaunchScreen @@ -95,8 +104,6 @@ UIInterfaceOrientationPortrait - NSContactsUsageDescription - This app requires access to contacts to update a contact image with real-time blood glucose information. UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait @@ -104,5 +111,9 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + diff --git a/LoopFollow/Log/LogEntry.swift b/LoopFollow/Log/LogEntry.swift new file mode 100644 index 00000000..a4d459fe --- /dev/null +++ b/LoopFollow/Log/LogEntry.swift @@ -0,0 +1,14 @@ +// +// LogEntry.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-13. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation + +struct LogEntry: Identifiable { + let id: UUID + let text: String +} diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift new file mode 100644 index 00000000..042526b1 --- /dev/null +++ b/LoopFollow/Log/LogManager.swift @@ -0,0 +1,107 @@ +// +// LogManager.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-10. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation + +class LogManager { + static let shared = LogManager() + + private let fileManager = FileManager.default + private let logDirectory: URL + private let dateFormatter: DateFormatter + private let consoleQueue = DispatchQueue(label: "com.loopfollow.log.console", qos: .background) + + enum Category: String, CaseIterable { + case bluetooth = "Bluetooth" + case nightscout = "Nightscout" + case apns = "APNS" + case general = "General" + case contact = "Contact" + case taskScheduler = "Task Scheduler" + case dexcom = "Dexcom" + case alarm = "Alarm" + case calendar = "Calendar" + case deviceStatus = "Device Status" + } + + init() { + // Create log directory in the app's Documents folder + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + logDirectory = documentsDirectory.appendingPathComponent("Logs") + + // Ensure the directory exists + if !fileManager.fileExists(atPath: logDirectory.path) { + try? fileManager.createDirectory(at: logDirectory, withIntermediateDirectories: true, attributes: nil) + } + + dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + } + + func log(category: Category, message: String, isDebug: Bool = false) { + let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) + let logMessage = "[\(timestamp)] [\(category.rawValue)] \(message)" + + consoleQueue.async { + print(logMessage) + } + + if !isDebug || Storage.shared.debugLogLevel.value { + let logFileURL = self.currentLogFileURL + self.append(logMessage + "\n", to: logFileURL) + } + } + + func cleanupOldLogs() { + let today = dateFormatter.string(from: Date()) + let yesterday = dateFormatter.string(from: Calendar.current.date(byAdding: .day, value: -1, to: Date())!) + + do { + let logFiles = try fileManager.contentsOfDirectory(at: logDirectory, includingPropertiesForKeys: nil) + for logFile in logFiles { + let filename = logFile.lastPathComponent + if !filename.contains(today) && !filename.contains(yesterday) { + try fileManager.removeItem(at: logFile) + } + } + } catch { + print("Failed to clean up old logs: \(error)") + } + } + + func logFileURL(for date: Date) -> URL { + let dateString = dateFormatter.string(from: date) + return logDirectory.appendingPathComponent("LoopFollow \(dateString).log") + } + + func logFilesForTodayAndYesterday() -> [URL] { + let today = logFileURL(for: Date()) + let yesterday = logFileURL(for: Calendar.current.date(byAdding: .day, value: -1, to: Date())!) + return [today, yesterday].filter { fileManager.fileExists(atPath: $0.path) } + } + + var currentLogFileURL: URL { + return logFileURL(for: Date()) + } + + private func append(_ message: String, to fileURL: URL) { + if !fileManager.fileExists(atPath: fileURL.path) { + fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: nil) + } + + if let fileHandle = try? FileHandle(forWritingTo: fileURL) { + defer { fileHandle.closeFile() } + fileHandle.seekToEndOfFile() + if let data = message.data(using: .utf8) { + fileHandle.write(data) + } + } else { + print("Failed to open log file at \(fileURL.path)") + } + } +} diff --git a/LoopFollow/Log/LogView.swift b/LoopFollow/Log/LogView.swift new file mode 100644 index 00000000..eff97efe --- /dev/null +++ b/LoopFollow/Log/LogView.swift @@ -0,0 +1,54 @@ +// +// LogView.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-13. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import SwiftUI + +struct LogView: View { + @ObservedObject var viewModel = LogViewModel() + @Environment(\.presentationMode) var presentationMode + + var body: some View { + NavigationView { + VStack { + Picker("Category", selection: $viewModel.selectedCategory) { + Text("All").tag(LogManager.Category?.none) + ForEach(LogManager.Category.allCases, id: \.self) { category in + Text(category.rawValue).tag(LogManager.Category?.some(category)) + } + } + .pickerStyle(MenuPickerStyle()) + + SearchBar(text: $viewModel.searchText) + .padding([.leading, .trailing]) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(viewModel.filteredLogEntries) { entry in + Text(entry.text) + .font(.system(size: 12, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 0) + } + } + .padding(.horizontal) + } + } + .navigationBarTitle("Today's Logs", displayMode: .inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + presentationMode.wrappedValue.dismiss() + } + } + } + .onAppear { + viewModel.loadLogEntries() + } + } + } +} diff --git a/LoopFollow/Log/LogViewModel.swift b/LoopFollow/Log/LogViewModel.swift new file mode 100644 index 00000000..bba61663 --- /dev/null +++ b/LoopFollow/Log/LogViewModel.swift @@ -0,0 +1,103 @@ +// +// LogViewModel.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-13. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation +import Combine + +class LogViewModel: ObservableObject { + @Published var allLogEntries: [LogEntry] = [] + @Published var filteredLogEntries: [LogEntry] = [] + @Published var selectedCategory: LogManager.Category? = nil + @Published var searchText: String = "" + + private var cancellables = Set() + + init() { + Publishers.CombineLatest($selectedCategory, $searchText) + .sink { [weak self] category, search in + self?.filterLogs(category: category, searchText: search) + } + .store(in: &cancellables) + + loadLogEntries() + + Timer.publish(every: 5.0, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.loadLogEntries() + } + .store(in: &cancellables) + } + + func loadLogEntries() { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self else { return } + let logManager = LogManager.shared + let logFileURL = logManager.currentLogFileURL + + guard FileManager.default.fileExists(atPath: logFileURL.path) else { + DispatchQueue.main.async { + self.allLogEntries = [] + self.filteredLogEntries = [] + } + return + } + + do { + let logContent = try String(contentsOf: logFileURL, encoding: .utf8) + var logLines = logContent.components(separatedBy: .newlines) + logLines = logLines.filter { !$0.isEmpty } + + // Reverse the log lines to have newest first + logLines.reverse() + + let uniqueLogEntries = logLines.map { LogEntry(id: UUID(), text: $0) } + + DispatchQueue.main.async { + self.allLogEntries = uniqueLogEntries + self.filterLogs(category: self.selectedCategory, searchText: self.searchText) + } + } catch { + print("Error reading log file: \(error)") + DispatchQueue.main.async { + self.allLogEntries = [] + self.filteredLogEntries = [] + } + } + } + } + + private func filterLogs(category: LogManager.Category?, searchText: String) { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + var filtered = self.allLogEntries + + // Filter by category and remove category tag + if let category = category { + let categoryTag = "[\(category.rawValue)] " + filtered = filtered.filter { $0.text.contains(categoryTag) } + .map { logEntry in + var text = logEntry.text + if let range = text.range(of: categoryTag) { + text.removeSubrange(range) + } + return LogEntry(id: logEntry.id, text: text.trimmingCharacters(in: .whitespaces)) + } + } + + // Filter by search text + if !searchText.isEmpty { + filtered = filtered.filter { $0.text.localizedCaseInsensitiveContains(searchText) } + } + + DispatchQueue.main.async { + self.filteredLogEntries = filtered + } + } + } +} diff --git a/LoopFollow/Log/SearchBar.swift b/LoopFollow/Log/SearchBar.swift new file mode 100644 index 00000000..daf4d573 --- /dev/null +++ b/LoopFollow/Log/SearchBar.swift @@ -0,0 +1,47 @@ +// +// SearchBar.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-13. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import SwiftUI +import UIKit + +struct SearchBar: UIViewRepresentable { + @Binding var text: String + + class Coordinator: NSObject, UISearchBarDelegate { + @Binding var text: String + + init(text: Binding) { + _text = text + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + text = searchText + } + + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(text: $text) + } + + func makeUIView(context: UIViewRepresentableContext) -> UISearchBar { + let searchBar = UISearchBar(frame: .zero) + searchBar.placeholder = "Search Log" + searchBar.delegate = context.coordinator + searchBar.autocapitalizationType = .none + searchBar.searchBarStyle = .minimal + return searchBar + } + + func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext) { + uiView.text = text + } +} diff --git a/LoopFollow/Loop Follow.entitlements b/LoopFollow/Loop Follow.entitlements index 6cb13fb6..a11ae82e 100644 --- a/LoopFollow/Loop Follow.entitlements +++ b/LoopFollow/Loop Follow.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.device.bluetooth + com.apple.security.network.client com.apple.security.personal-information.calendars diff --git a/LoopFollow/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift new file mode 100644 index 00000000..6433ccae --- /dev/null +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -0,0 +1,70 @@ +// +// NightscoutSettingsView.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-18. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import SwiftUI + +struct NightscoutSettingsView: View { + @ObservedObject var viewModel: NightscoutSettingsViewModel + @Environment(\.presentationMode) var presentationMode + + var body: some View { + NavigationView { + Form { + urlSection + tokenSection + statusSection + } + .navigationBarTitle("Nightscout Settings", displayMode: .inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + presentationMode.wrappedValue.dismiss() + } + } + } + .onDisappear { + viewModel.dismiss() + } + } + } + + // MARK: - Subviews / Computed Properties + + private var urlSection: some View { + Section { + TextField("URL", text: $viewModel.nightscoutURL) + .textContentType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .onChange(of: viewModel.nightscoutURL) { newValue in + viewModel.processURL(newValue) + } + } header: { + Text("URL") + } + } + + private var tokenSection: some View { + Section { + TextField("Token", text: $viewModel.nightscoutToken) + .textContentType(.password) + .autocapitalization(.none) + .disableAutocorrection(true) + } header: { + Text("Token") + } + } + + private var statusSection: some View { + Section { + Text(viewModel.nightscoutStatus) + } header: { + Text("Status") + } + } +} diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift new file mode 100644 index 00000000..edbc4600 --- /dev/null +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -0,0 +1,140 @@ +// +// NightscoutSettingsViewModel.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-18. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation +import Combine + +protocol NightscoutSettingsViewModelDelegate: AnyObject { + func nightscoutSettingsDidFinish() +} + +class NightscoutSettingsViewModel: ObservableObject { + weak var delegate: NightscoutSettingsViewModelDelegate? + + private var initialURL: String + private var initialToken: String + + @Published var nightscoutURL: String = ObservableUserDefaults.shared.url.value { + willSet { + if newValue != nightscoutURL { + ObservableUserDefaults.shared.url.value = newValue + triggerCheckStatus() + } + } + } + @Published var nightscoutToken: String = UserDefaultsRepository.token.value { + willSet { + if newValue != nightscoutToken { + UserDefaultsRepository.token.value = newValue + triggerCheckStatus() + } + } + } + @Published var nightscoutStatus: String = "Checking..." + + private var cancellables = Set() + private var checkStatusSubject = PassthroughSubject() + private var checkStatusWorkItem: DispatchWorkItem? + + init() { + self.initialURL = ObservableUserDefaults.shared.url.value + self.initialToken = UserDefaultsRepository.token.value + + setupDebounce() + checkNightscoutStatus() + } + + private func setupDebounce() { + checkStatusSubject + .debounce(for: .seconds(2), scheduler: DispatchQueue.main) + .sink { [weak self] in + self?.checkNightscoutStatus() + } + .store(in: &cancellables) + } + + private func triggerCheckStatus() { + checkStatusWorkItem?.cancel() + + nightscoutStatus = "Checking..." + + checkStatusWorkItem = DispatchWorkItem { + self.checkStatusSubject.send() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: checkStatusWorkItem!) + } + + func processURL(_ value: String) { + var useTokenUrl = false + + if let urlComponents = URLComponents(string: value), let queryItems = urlComponents.queryItems { + if let tokenItem = queryItems.first(where: { $0.name.lowercased() == "token" }) { + let tokenPattern = "^[^-\\s]+-[0-9a-fA-F]{16}$" + if let token = tokenItem.value, let _ = token.range(of: tokenPattern, options: .regularExpression) { + var baseComponents = urlComponents + baseComponents.queryItems = nil + if let baseURL = baseComponents.string { + nightscoutToken = token + nightscoutURL = baseURL + useTokenUrl = true + } + } + } + } + + if !useTokenUrl { + let filtered = value.replacingOccurrences(of: "[^A-Za-z0-9:/._-]", with: "", options: .regularExpression).lowercased() + var cleanURL = filtered + while cleanURL.count > 8 && cleanURL.last == "/" { + cleanURL = String(cleanURL.dropLast()) + } + nightscoutURL = cleanURL + } + } + + func checkNightscoutStatus() { + NightscoutUtils.verifyURLAndToken { error, jwtToken, nsWriteAuth in + DispatchQueue.main.async { + ObservableUserDefaults.shared.nsWriteAuth.value = nsWriteAuth + + self.updateStatusLabel(error: error) + } + } + } + + func updateStatusLabel(error: NightscoutUtils.NightscoutError?) { + if let error = error { + switch error { + case .invalidURL: + nightscoutStatus = "Invalid URL" + case .networkError: + nightscoutStatus = "Network Error" + case .invalidToken: + nightscoutStatus = "Invalid Token" + case .tokenRequired: + nightscoutStatus = "Token Required" + case .siteNotFound: + nightscoutStatus = "Site Not Found" + case .unknown: + nightscoutStatus = "Unknown Error" + case .emptyAddress: + nightscoutStatus = "Address Empty" + } + } else { + nightscoutStatus = "OK (Read\(ObservableUserDefaults.shared.nsWriteAuth.value ? " & Write" : ""))" + + if (nightscoutURL != initialURL || nightscoutToken != initialToken) { + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } + } + } + + func dismiss() { + delegate?.nightscoutSettingsDidFinish() + } +} diff --git a/LoopFollow/Remote/PushNotificationManager.swift b/LoopFollow/Remote/PushNotificationManager.swift index 7983a2ec..d07092ad 100644 --- a/LoopFollow/Remote/PushNotificationManager.swift +++ b/LoopFollow/Remote/PushNotificationManager.swift @@ -207,7 +207,7 @@ class PushNotificationManager { if !missingFields.isEmpty { let errorMessage = "Missing required fields, check your remote settings: \(missingFields.joined(separator: ", "))" - print(errorMessage) + LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } @@ -218,28 +218,28 @@ class PushNotificationManager { if !missingFields.isEmpty { let errorMessage = "Missing required data, verify that you are using the latest version of Trio: \(missingFields.joined(separator: ", "))" - print(errorMessage) + LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } if let validationErrors = validateCredentials() { let errorMessage = "Credential validation failed: \(validationErrors.joined(separator: ", "))" - print(errorMessage) + LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } guard let url = constructAPNsURL() else { let errorMessage = "Failed to construct APNs URL" - print(errorMessage) + LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } guard let jwt = getOrGenerateJWT() else { let errorMessage = "Failed to generate JWT, please check that the token is correct." - print(errorMessage) + LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } @@ -260,7 +260,7 @@ class PushNotificationManager { let task = URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { let errorMessage = "Failed to send push notification: \(error.localizedDescription)" - print(errorMessage) + LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } diff --git a/LoopFollow/Settings/AdvancedSettingsView.swift b/LoopFollow/Settings/AdvancedSettingsView.swift new file mode 100644 index 00000000..150abb63 --- /dev/null +++ b/LoopFollow/Settings/AdvancedSettingsView.swift @@ -0,0 +1,45 @@ +// +// AdvancedSettingsView.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-23. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import SwiftUI + +struct AdvancedSettingsView: View { + @ObservedObject var viewModel: AdvancedSettingsViewModel + @Environment(\.presentationMode) var presentationMode + + var body: some View { + NavigationView { + Form { + Section(header: Text("Advanced Settings")) { + Toggle("Download Treatments", isOn: $viewModel.downloadTreatments) + Toggle("Download Prediction", isOn: $viewModel.downloadPrediction) + Toggle("Graph Basal", isOn: $viewModel.graphBasal) + Toggle("Graph Bolus", isOn: $viewModel.graphBolus) + Toggle("Graph Carbs", isOn: $viewModel.graphCarbs) + Toggle("Graph Other Treatments", isOn: $viewModel.graphOtherTreatments) + + Stepper(value: $viewModel.bgUpdateDelay, in: 1...30, step: 1) { + Text("BG Update Delay (Sec): \(viewModel.bgUpdateDelay)") + } + } + + Section(header: Text("Logging Options")) { + Toggle("Debug Log Level", isOn: $viewModel.debugLogLevel) + } + } + .navigationBarTitle("Advanced Settings", displayMode: .inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + presentationMode.wrappedValue.dismiss() + } + } + } + } + } +} diff --git a/LoopFollow/Settings/AdvancedSettingsViewModel.swift b/LoopFollow/Settings/AdvancedSettingsViewModel.swift new file mode 100644 index 00000000..163263e5 --- /dev/null +++ b/LoopFollow/Settings/AdvancedSettingsViewModel.swift @@ -0,0 +1,62 @@ +// +// AdvancedSettingsViewModel.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-23. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation + +class AdvancedSettingsViewModel: ObservableObject { + @Published var downloadTreatments: Bool { + didSet { + UserDefaultsRepository.downloadTreatments.value = downloadTreatments + } + } + @Published var downloadPrediction: Bool { + didSet { + UserDefaultsRepository.downloadPrediction.value = downloadPrediction + } + } + @Published var graphBasal: Bool { + didSet { + UserDefaultsRepository.graphBasal.value = graphBasal + } + } + @Published var graphBolus: Bool { + didSet { + UserDefaultsRepository.graphBolus.value = graphBolus + } + } + @Published var graphCarbs: Bool { + didSet { + UserDefaultsRepository.graphCarbs.value = graphCarbs + } + } + @Published var graphOtherTreatments: Bool { + didSet { + UserDefaultsRepository.graphOtherTreatments.value = graphOtherTreatments + } + } + @Published var bgUpdateDelay: Int { + didSet { + UserDefaultsRepository.bgUpdateDelay.value = bgUpdateDelay + } + } + @Published var debugLogLevel: Bool { + didSet { + Storage.shared.debugLogLevel.value = debugLogLevel + } + } + init() { + self.downloadTreatments = UserDefaultsRepository.downloadTreatments.value + self.downloadPrediction = UserDefaultsRepository.downloadPrediction.value + self.graphBasal = UserDefaultsRepository.graphBasal.value + self.graphBolus = UserDefaultsRepository.graphBolus.value + self.graphCarbs = UserDefaultsRepository.graphCarbs.value + self.graphOtherTreatments = UserDefaultsRepository.graphOtherTreatments.value + self.bgUpdateDelay = UserDefaultsRepository.bgUpdateDelay.value + self.debugLogLevel = Storage.shared.debugLogLevel.value + } +} diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 026e569f..f0952fe4 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -14,6 +14,7 @@ class Observable { var tempTarget = ObservableValue(default: nil) var override = ObservableValue(default: nil) + var isLastDeviceStatusSuggested = ObservableValue(default: false) private init() {} } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index d8c1d8ec..c980a104 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -31,6 +31,13 @@ class Storage { var cachedJWT = StorageValue(key: "cachedJWT", defaultValue: nil) var jwtExpirationDate = StorageValue(key: "jwtExpirationDate", defaultValue: nil) + var backgroundRefreshType = StorageValue(key: "backgroundRefreshType", defaultValue: .silentTune) + + var selectedBLEDevice = StorageValue(key: "selectedBLEDevice", defaultValue: nil) + + var debugLogLevel = StorageValue(key: "debugLogLevel", defaultValue: false) + + static let shared = Storage() private init() { } diff --git a/LoopFollow/Storage/UserDefaults.swift b/LoopFollow/Storage/UserDefaults.swift index 34eb9127..60d2e6a4 100644 --- a/LoopFollow/Storage/UserDefaults.swift +++ b/LoopFollow/Storage/UserDefaults.swift @@ -57,8 +57,6 @@ class UserDefaultsRepository { static let hideInfoTable = UserDefaultsValue(key: "hideInfoTable", default: false) // Nightscout Settings - static let showNS = UserDefaultsValue(key: "showNS", default: false) - //static let url = UserDefaultsValue(key: "url", default: "") static let token = UserDefaultsValue(key: "token", default: "") static let units = UserDefaultsValue(key: "units", default: "mg/dL") @@ -81,7 +79,6 @@ class UserDefaultsRepository { } // Dexcom Share Settings - static let showDex = UserDefaultsValue(key: "showDex", default: false) static let shareUserName = UserDefaultsValue(key: "shareUserName", default: "") static let sharePassword = UserDefaultsValue(key: "sharePassword", default: "") static let shareServer = UserDefaultsValue(key: "shareServer", default: "US") @@ -121,8 +118,10 @@ class UserDefaultsRepository { static let speakHighBG = UserDefaultsValue(key: "speakHighBG", default: false) static let speakLanguage = UserDefaultsValue(key: "speakLanguage", default: "en") static let showDisplayName = UserDefaultsValue(key: "showDisplayName", default: false) - static let backgroundRefreshFrequency = UserDefaultsValue(key: "backgroundRefreshFrequency", default: 1) + + // Deprecated, used to detect if backgroundRefresh was set to off. TODO: Remove in the beginning of 2026 static let backgroundRefresh = UserDefaultsValue(key: "backgroundRefresh", default: true) + static let appBadge = UserDefaultsValue(key: "appBadge", default: true) static let dimScreenWhenIdle = UserDefaultsValue(key: "dimScreenWhenIdle", default: 0) static let forceDarkMode = UserDefaultsValue(key: "forceDarkMode", default: true) @@ -143,7 +142,6 @@ class UserDefaultsRepository { static let graphBasal = UserDefaultsValue(key: "graphBasal", default: true) static let graphBolus = UserDefaultsValue(key: "graphBolus", default: true) static let graphCarbs = UserDefaultsValue(key: "graphCarbs", default: true) - static let debugLog = UserDefaultsValue(key: "debugLog", default: false) static let bgUpdateDelay = UserDefaultsValue(key: "bgUpdateDelay", default: 10) static let downloadDays = UserDefaultsValue(key: "downloadDays", default: 1) diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift new file mode 100644 index 00000000..8fe64b1c --- /dev/null +++ b/LoopFollow/Task/AlarmTask.swift @@ -0,0 +1,35 @@ +// +// AlarmTask.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-12. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation + +extension MainViewController { + func scheduleAlarmTask(initialDelay: TimeInterval = 30) { + let firstRun = Date().addingTimeInterval(initialDelay) + TaskScheduler.shared.scheduleTask(id: .alarmCheck, nextRun: firstRun) { [weak self] in + guard let self = self else { return } + self.alarmTaskAction() + } + } + + func alarmTaskAction() { + DispatchQueue.main.async { + if self.bgData.count > 0 { + self.checkAlarms(bgs: self.bgData) + } + if self.overrideGraphData.count > 0 { + self.checkOverrideAlarms() + } + if self.tempTargetGraphData.count > 0 { + self.checkTempTargetAlarms() + } + + TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(30)) + } + } +} diff --git a/LoopFollow/Task/BGTask.swift b/LoopFollow/Task/BGTask.swift new file mode 100644 index 00000000..526a8c94 --- /dev/null +++ b/LoopFollow/Task/BGTask.swift @@ -0,0 +1,44 @@ +// +// BGTask.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-11. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation + +extension MainViewController { + func scheduleBGTask(initialDelay: TimeInterval = 2) { + let firstRun = Date().addingTimeInterval(initialDelay) + TaskScheduler.shared.scheduleTask(id: .fetchBG, nextRun: firstRun) { [weak self] in + guard let self = self else { return } + self.bgTaskAction() + } + } + + func bgTaskAction() { + // If anything goes wrong, try again in 60 seconds. + TaskScheduler.shared.rescheduleTask( + id: .fetchBG, + to: Date().addingTimeInterval(60) + ) + + // If no Dexcom credentials and no Nightscout, schedule a retry in 60 seconds. + if UserDefaultsRepository.shareUserName.value == "", + UserDefaultsRepository.sharePassword.value == "", + !IsNightscoutEnabled() + { + return + } + + // If Dexcom credentials exist, fetch from DexShare + if UserDefaultsRepository.shareUserName.value != "" && + UserDefaultsRepository.sharePassword.value != "" + { + self.webLoadDexShare() + } else { + self.webLoadNSBGData() + } + } +} diff --git a/LoopFollow/Task/CalendarTask.swift b/LoopFollow/Task/CalendarTask.swift new file mode 100644 index 00000000..9a21ecd8 --- /dev/null +++ b/LoopFollow/Task/CalendarTask.swift @@ -0,0 +1,29 @@ +// +// CalendarTask.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-12. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation + +extension MainViewController { + func scheduleCalendarTask(initialDelay: TimeInterval = 15) { + let startTime = Date().addingTimeInterval(initialDelay) + TaskScheduler.shared.scheduleTask(id: .calendarWrite, nextRun: startTime) { [weak self] in + guard let self = self else { return } + self.calendarTaskAction() + } + } + + func calendarTaskAction() { + if UserDefaultsRepository.writeCalendarEvent.value, + !UserDefaultsRepository.calendarIdentifier.value.isEmpty + { + self.writeCalendar() + } + + TaskScheduler.shared.rescheduleTask(id: .calendarWrite, to: Date().addingTimeInterval(30)) + } +} diff --git a/LoopFollow/Task/DeviceStatusTask.swift b/LoopFollow/Task/DeviceStatusTask.swift new file mode 100644 index 00000000..e023b0b9 --- /dev/null +++ b/LoopFollow/Task/DeviceStatusTask.swift @@ -0,0 +1,29 @@ +// +// DeviceStatusTask.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-11. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation + +extension MainViewController { + func scheduleDeviceStatusTask(initialDelay: TimeInterval = 4) { + let startTime = Date().addingTimeInterval(initialDelay) + TaskScheduler.shared.scheduleTask(id: .deviceStatus, nextRun: startTime) { [weak self] in + guard let self = self else { return } + self.deviceStatusAction() + } + } + + func deviceStatusAction() { + // If no NS config, we wait 60s before trying again: + guard IsNightscoutEnabled() else { + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date().addingTimeInterval(60)) + return + } + + webLoadNSDeviceStatus() + } +} diff --git a/LoopFollow/Task/MinAgoTask.swift b/LoopFollow/Task/MinAgoTask.swift new file mode 100644 index 00000000..31c8b565 --- /dev/null +++ b/LoopFollow/Task/MinAgoTask.swift @@ -0,0 +1,98 @@ +// +// MinAgoTask.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-11. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation +import UIKit + +extension MainViewController { + func scheduleMinAgoTask(initialDelay: TimeInterval = 1.0) { + let firstRun = Date().addingTimeInterval(initialDelay) + TaskScheduler.shared.scheduleTask(id: .minAgoUpdate, nextRun: firstRun) { [weak self] in + guard let self = self else { return } + self.minAgoTaskAction() + } + } + + func minAgoTaskAction() { + guard bgData.count > 0, let lastBG = bgData.last else { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.MinAgoText.text = "" + self.latestMinAgoString = "" + if let snoozer = self.tabBarController?.viewControllers?[2] as? SnoozeViewController { + snoozer.MinAgoLabel.text = "" + snoozer.BGLabel.text = "" + snoozer.BGLabel.attributedText = NSAttributedString(string: "") + } + } + TaskScheduler.shared.rescheduleTask(id: .minAgoUpdate, to: Date().addingTimeInterval(1)) + return + } + + let bgSeconds = lastBG.date + let now = Date() + let secondsAgo = now.timeIntervalSince1970 - bgSeconds + + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .positional + formatter.zeroFormattingBehavior = .dropLeading + + let shouldDisplaySeconds = secondsAgo >= 270 && secondsAgo < 720 // 4.5 to 12 minutes + + if shouldDisplaySeconds { + formatter.allowedUnits = [.minute, .second] + } else { + formatter.allowedUnits = [.minute] + } + + let formattedDuration = formatter.string(from: secondsAgo) ?? "" + let minAgoDisplayText = formattedDuration + " min ago" + + // Update UI only if the display text has changed + if minAgoDisplayText != latestMinAgoString { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.MinAgoText.text = minAgoDisplayText + self.latestMinAgoString = minAgoDisplayText + + if let snoozer = self.tabBarController?.viewControllers?[2] as? SnoozeViewController { + snoozer.MinAgoLabel.text = minAgoDisplayText + + let bgLabelText = snoozer.BGLabel.text ?? "" + let attributeString = NSMutableAttributedString(string: bgLabelText) + attributeString.addAttribute(.strikethroughStyle, + value: NSUnderlineStyle.single.rawValue, + range: NSRange(location: 0, length: attributeString.length)) + attributeString.addAttribute(.strikethroughColor, + value: secondsAgo >= 720 ? UIColor.systemRed : UIColor.clear, + range: NSRange(location: 0, length: attributeString.length)) + snoozer.BGLabel.attributedText = attributeString + } + } + } + + // Determine the next run interval based on the current state + let nextUpdateInterval: TimeInterval + if shouldDisplaySeconds { + // Update every second when showing seconds + nextUpdateInterval = 1.0 + } else if secondsAgo >= 240 && secondsAgo < 720 { + // Schedule exactly at the transition point to start showing seconds + nextUpdateInterval = 270.0 - secondsAgo + } else { + // Schedule exactly at the transition point to next minute + let secondsToNextMinute = 60.0 - (secondsAgo.truncatingRemainder(dividingBy: 60.0)) + nextUpdateInterval = secondsToNextMinute + } + + // Ensure the nextUpdateInterval is not negative or too small + let safeNextInterval = max(nextUpdateInterval, 1.0) + + TaskScheduler.shared.rescheduleTask(id: .minAgoUpdate, to: Date().addingTimeInterval(safeNextInterval)) + } +} diff --git a/LoopFollow/Task/ProfileTask.swift b/LoopFollow/Task/ProfileTask.swift new file mode 100644 index 00000000..e13287e4 --- /dev/null +++ b/LoopFollow/Task/ProfileTask.swift @@ -0,0 +1,31 @@ +// +// ProfileTask.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-11. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation + +extension MainViewController { + func scheduleProfileTask(initialDelay: TimeInterval = 3) { + let firstRun = Date().addingTimeInterval(initialDelay) + + TaskScheduler.shared.scheduleTask(id: .profile, nextRun: firstRun) { [weak self] in + guard let self = self else { return } + self.profileTaskAction() + } + } + + func profileTaskAction() { + guard IsNightscoutEnabled() else { + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date().addingTimeInterval(60)) + return + } + + self.webLoadNSProfile() + + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date().addingTimeInterval(10 * 60)) + } +} diff --git a/LoopFollow/Task/Task.swift b/LoopFollow/Task/Task.swift new file mode 100644 index 00000000..5c4e51c6 --- /dev/null +++ b/LoopFollow/Task/Task.swift @@ -0,0 +1,22 @@ +// +// Task.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-12. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation + +extension MainViewController { + + func scheduleAllTasks() { + scheduleBGTask() + scheduleProfileTask() + scheduleDeviceStatusTask() + scheduleTreatmentsTask() + scheduleMinAgoTask() + scheduleCalendarTask() + scheduleAlarmTask() + } +} diff --git a/LoopFollow/Task/TaskScheduler.swift b/LoopFollow/Task/TaskScheduler.swift new file mode 100644 index 00000000..a4931a6f --- /dev/null +++ b/LoopFollow/Task/TaskScheduler.swift @@ -0,0 +1,141 @@ +// +// TaskScheduler.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-10. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation +import UIKit + +enum TaskID: CaseIterable { + case profile + case deviceStatus + case treatments + case fetchBG + case minAgoUpdate + case calendarWrite + case alarmCheck +} + +struct ScheduledTask { + var nextRun: Date + var action: () -> Void +} + +class TaskScheduler { + static let shared = TaskScheduler() + + // Thread-safety: a serial queue so we don’t manipulate tasks from multiple threads at once + private let queue = DispatchQueue(label: "com.LoopFollow.TaskSchedulerQueue") + + private var tasks: [TaskID: ScheduledTask] = [:] + private var currentTimer: Timer? + + private init() {} + + // MARK: - Public API + + func scheduleTask(id: TaskID, nextRun: Date, action: @escaping () -> Void) { + queue.async { + let timeString = self.formatTime(nextRun) + LogManager.shared.log(category: .taskScheduler, message: "scheduleTask(\(id)): next run = \(timeString)", isDebug: true) + + self.tasks[id] = ScheduledTask(nextRun: nextRun, action: action) + self.rescheduleTimer() + } + } + + func rescheduleTask(id: TaskID, to newRunDate: Date) { + let timeString = self.formatTime(newRunDate) + LogManager.shared.log(category: .taskScheduler, message: "Reschedule Task \(id): next run = \(timeString)", isDebug: true) + + queue.async { + guard var existingTask = self.tasks[id] else { + return + } + existingTask.nextRun = newRunDate + self.tasks[id] = existingTask + self.checkTasksNow() + } + } + + func checkTasksNow() { + queue.async { + self.fireOverdueTasks() + self.rescheduleTimer() + } + } + + // MARK: - Private + + /// Updated signature to include info about who called us, and which task triggered it (if any). + private func rescheduleTimer() { + // Invalidate any existing timer + currentTimer?.invalidate() + currentTimer = nil + + guard let (_, earliestTask) = tasks.min(by: { $0.value.nextRun < $1.value.nextRun }) else { + LogManager.shared.log(category: .taskScheduler, message: "No tasks, no timer scheduled.") + return + } + + let interval = earliestTask.nextRun.timeIntervalSinceNow + let safeInterval = max(interval, 0) + + // Comment out this block to simulate heartbeat execution only + DispatchQueue.main.async { + self.currentTimer = Timer.scheduledTimer(withTimeInterval: safeInterval, repeats: false) { [weak self] _ in + guard let self = self else { return } + self.queue.async { + self.fireOverdueTasks() + self.rescheduleTimer() + } + } + } + } + + private func fireOverdueTasks() { + BackgroundAlertManager.shared.scheduleBackgroundAlert() + + let now = Date() + let tasksToSkipAlarmCheck: Set = [.deviceStatus, .treatments, .fetchBG] + + for taskID in TaskID.allCases { + guard let task = tasks[taskID], task.nextRun <= now else { + continue + } + + // Check if we should skip alarmCheck + if taskID == .alarmCheck { + let shouldSkip = tasksToSkipAlarmCheck.contains { + guard let checkTask = tasks[$0] else { return false } + return checkTask.nextRun <= now || checkTask.nextRun == .distantFuture + } + + if shouldSkip { + //LogManager.shared.log(category: .taskScheduler, message: "Skipping alarmCheck because one of the specified tasks is due or set to distant future.") + continue + } + } + + var updatedTask = task + updatedTask.nextRun = .distantFuture + tasks[taskID] = updatedTask + + LogManager.shared.log(category: .taskScheduler, message: "Executing task \(taskID)", isDebug: true) + + DispatchQueue.main.async { + task.action() + } + } + } + + private func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + return formatter.string(from: date) + } +} diff --git a/LoopFollow/Task/TreatmentsTask.swift b/LoopFollow/Task/TreatmentsTask.swift new file mode 100644 index 00000000..a886bdab --- /dev/null +++ b/LoopFollow/Task/TreatmentsTask.swift @@ -0,0 +1,31 @@ +// +// TreatmentsTask.swift +// LoopFollow +// +// Created by Jonas Björkert on 2025-01-11. +// Copyright © 2025 Jon Fawcett. All rights reserved. +// + +import Foundation + +extension MainViewController { + func scheduleTreatmentsTask(initialDelay: TimeInterval = 5) { + let firstRun = Date().addingTimeInterval(initialDelay) + TaskScheduler.shared.scheduleTask(id: .treatments, nextRun: firstRun) { [weak self] in + guard let self = self else { return } + self.treatmentsTaskAction() + } + } + + func treatmentsTaskAction() { + // If Nightscout not enabled, wait 60s and try again + guard IsNightscoutEnabled(), UserDefaultsRepository.downloadTreatments.value else { + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date().addingTimeInterval(60)) + return + } + + WebLoadNSTreatments() + + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date().addingTimeInterval(2 * 60)) + } +} diff --git a/LoopFollow/ViewControllers/AdvancedSettingsViewController.swift b/LoopFollow/ViewControllers/AdvancedSettingsViewController.swift deleted file mode 100644 index b184a3e6..00000000 --- a/LoopFollow/ViewControllers/AdvancedSettingsViewController.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// DebugSettingsViewController.swift -// LoopFollow -// -// Created by Jose Paredes on 7/16/20. -// Copyright © 2020 Jon Fawcett. All rights reserved. -// - -import Foundation -import Eureka -import EventKit -import EventKitUI - -class AdvancedSettingsViewController: FormViewController { - - var appStateController: AppStateController? - - override func viewDidLoad() { - super.viewDidLoad() - if UserDefaultsRepository.forceDarkMode.value { - overrideUserInterfaceStyle = .dark - } - buildAdvancedSettings() - } - private func buildAdvancedSettings() { - form - +++ Section("Advanced Settings") - - <<< SwitchRow("downloadTreatments"){ row in - row.title = "Download Treatments" - row.value = UserDefaultsRepository.downloadTreatments.value - }.onChange { [weak self] row in - guard let value = row.value else { return } - UserDefaultsRepository.downloadTreatments.value = value - } - <<< SwitchRow("downloadPrediction"){ row in - row.title = "Download Prediction" - row.value = UserDefaultsRepository.downloadPrediction.value - }.onChange { [weak self] row in - guard let value = row.value else { return } - UserDefaultsRepository.downloadPrediction.value = value - } - <<< SwitchRow("graphBasal"){ row in - row.title = "Graph Basal" - row.value = UserDefaultsRepository.graphBasal.value - }.onChange { [weak self] row in - guard let value = row.value else { return } - UserDefaultsRepository.graphBasal.value = value - } - <<< SwitchRow("graphBolus"){ row in - row.title = "Graph Bolus" - row.value = UserDefaultsRepository.graphBolus.value - }.onChange { [weak self] row in - guard let value = row.value else { return } - UserDefaultsRepository.graphBolus.value = value - } - <<< SwitchRow("graphCarbs"){ row in - row.title = "Graph Carbs" - row.value = UserDefaultsRepository.graphCarbs.value - }.onChange { [weak self] row in - guard let value = row.value else { return } - UserDefaultsRepository.graphCarbs.value = value - } - <<< SwitchRow("graphOtherTreatments"){ row in - row.title = "Graph Other Treatments" - row.value = UserDefaultsRepository.graphOtherTreatments.value - }.onChange { [weak self] row in - guard let value = row.value else { return } - UserDefaultsRepository.graphOtherTreatments.value = value - } - <<< StepperRow("bgUpdateDelay") { row in - row.title = "BG Update Delay (Sec)" - row.cell.stepper.stepValue = 1 - row.cell.stepper.minimumValue = 1 - row.cell.stepper.maximumValue = 30 - row.value = Double(UserDefaultsRepository.bgUpdateDelay.value) - row.displayValueFor = { value in - guard let value = value else { return nil } - return "\(Int(value))" - } - }.onChange { [weak self] row in - guard let value = row.value else { return } - UserDefaultsRepository.bgUpdateDelay.value = Int(value) - } - - - - - - - +++ ButtonRow() { - $0.title = "DONE" - }.onCellSelection { (row, arg) in - self.dismiss(animated:true, completion: nil) - } - } - - -} diff --git a/LoopFollow/ViewControllers/AlarmViewController.swift b/LoopFollow/ViewControllers/AlarmViewController.swift index b3043ed4..61f3bd7f 100644 --- a/LoopFollow/ViewControllers/AlarmViewController.swift +++ b/LoopFollow/ViewControllers/AlarmViewController.swift @@ -1805,7 +1805,7 @@ class AlarmViewController: FormViewController { <<< StepperRow("alertNotLooping") { row in row.title = "Time" row.cell.stepper.stepValue = 5 - row.cell.stepper.minimumValue = 10 + row.cell.stepper.minimumValue = 15 row.cell.stepper.maximumValue = 60 row.value = Double(UserDefaultsRepository.alertNotLooping.value) row.displayValueFor = { value in diff --git a/LoopFollow/ViewControllers/GeneralSettingsViewController.swift b/LoopFollow/ViewControllers/GeneralSettingsViewController.swift index d1393440..e6ca6542 100644 --- a/LoopFollow/ViewControllers/GeneralSettingsViewController.swift +++ b/LoopFollow/ViewControllers/GeneralSettingsViewController.swift @@ -48,14 +48,6 @@ class GeneralSettingsViewController: FormViewController { } } - <<< SwitchRow("backgroundRefresh"){ row in - row.title = "Background Refresh" - row.tag = "backgroundRefresh" - row.value = UserDefaultsRepository.backgroundRefresh.value - }.onChange { [weak self] row in - guard let value = row.value else { return } - UserDefaultsRepository.backgroundRefresh.value = value - } <<< SwitchRow("persistentNotification") { row in row.title = "Persistent Notification" row.value = UserDefaultsRepository.persistentNotification.value diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 76ab25e0..ee411b04 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -12,6 +12,7 @@ import EventKit import ShareClient import UserNotifications import AVFAudio +import CoreBluetooth func IsNightscoutEnabled() -> Bool { return !ObservableUserDefaults.shared.url.value.isEmpty @@ -73,25 +74,14 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // check every 30 Seconds whether new bgvalues should be retrieved let timeInterval: TimeInterval = 30.0 - // Min Ago Timer - var minAgoTimer = Timer() - var minAgoTimeInterval: TimeInterval = 1.0 - // Check Alarms Timer // Don't check within 1 minute of alarm triggering to give the snoozer time to save data var checkAlarmTimer = Timer() var checkAlarmInterval: TimeInterval = 60.0 - - var calTimer = Timer() - - var bgTimer = Timer() - var profileTimer = Timer() - var deviceStatusTimer = Timer() - var treatmentsTimer = Timer() - var alarmTimer = Timer() - var calendarTimer = Timer() var graphNowTimer = Timer() + var lastCalendarWriteAttemptTime: TimeInterval = 0 + // Info Table Setup var infoManager: InfoManager! var profileManager = ProfileManager.shared @@ -157,12 +147,20 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele override func viewDidLoad() { super.viewDidLoad() + //Migration of UserDefaultsRepository -> Storage handling + if !UserDefaultsRepository.backgroundRefresh.value { + Storage.shared.backgroundRefreshType.value = .none + UserDefaultsRepository.backgroundRefresh.value = true + } + + // Ensure alertNotLooping has a minimum value of 15. + if UserDefaultsRepository.alertNotLooping.value < 15 { + UserDefaultsRepository.alertNotLooping.value = 15 + } + // Synchronize info types to ensure arrays are the correct size UserDefaultsRepository.synchronizeInfoTypes() - // Reset deprecated settings - UserDefaultsRepository.debugLog.value = false; - infoTable.rowHeight = 21 infoTable.dataSource = self infoTable.tableFooterView = UIView(frame: .zero) @@ -209,9 +207,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // setup display for NS vs Dex showHideNSDetails() - // Load Startup Data - restartAllTimers() - + scheduleAllTasks() + // Set up refreshScrollView for BGText refreshScrollView = UIScrollView() refreshScrollView.translatesAutoresizingMaskIntoConstraints = false @@ -233,7 +230,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele refreshScrollView.alwaysBounceVertical = true refreshScrollView.delegate = self - NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil) } @@ -243,7 +239,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Clean all timers and start new ones when refreshing @objc func refresh() { - print("Refreshing") + LogManager.shared.log(category: .general, message: "Refreshing") // Clear prediction for both Loop or OpenAPS @@ -269,8 +265,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } MinAgoText.text = "Refreshing" - invalidateTimers() - restartAllTimers() + latestMinAgoString = "Refreshing" + scheduleAllTasks() + currentCage = nil currentSage = nil currentIage = nil @@ -387,26 +384,29 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // We want to always come back to the home screen tabBarController?.selectedIndex = 0 - // Cancel the current timer and start a fresh background timer using the settings value only if background task is enabled - - if UserDefaultsRepository.backgroundRefresh.value { - BackgroundAlertManager.shared.startBackgroundAlert() + if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.startBackgroundTask() } - + + if Storage.shared.backgroundRefreshType.value != .none { + BackgroundAlertManager.shared.startBackgroundAlert() + } } @objc func appCameToForeground() { // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = UserDefaultsRepository.screenlockSwitchState.value; - // Cancel the background tasks, start a fresh timer - if UserDefaultsRepository.backgroundRefresh.value { + if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.stopBackgroundTask() + } + + if Storage.shared.backgroundRefreshType.value != .none { BackgroundAlertManager.shared.stopBackgroundAlert() } + + TaskScheduler.shared.checkTasksNow() - restartAllTimers() checkAndNotifyVersionStatus() checkAppExpirationStatus() } @@ -543,13 +543,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } func writeCalendar() { - if UserDefaultsRepository.debugLog.value { - self.writeDebugLog(value: "Write calendar start") - } - self.store.requestCalendarAccess { (granted, error) in if !granted { - print("Failed to get calendar access: \(String(describing: error))") + LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))") return } self.processCalendarUpdates() @@ -558,15 +554,19 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele func processCalendarUpdates() { if UserDefaultsRepository.calendarIdentifier.value == "" { return } - + if self.bgData.count < 1 { return } - + // This lets us fire the method to write Min Ago entries only once a minute starting after 6 minutes but allows new readings through - if self.lastCalDate == self.bgData[self.bgData.count - 1].date - && (self.calTimer.isValid || (dateTimeUtils.getNowTimeIntervalUTC() - self.lastCalDate) < 360) { - return + let now = dateTimeUtils.getNowTimeIntervalUTC() + let newestBGDate = bgData[bgData.count - 1].date + + if lastCalDate == newestBGDate { + if (now - lastCalendarWriteAttemptTime) < 60 || (now - newestBGDate) < 360 { + return + } } - + // Create Event info var deltaBG = 0 // protect index out of bounds if self.bgData.count > 1 { @@ -584,7 +584,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let direction = self.bgDirectionGraphic(self.bgData[self.bgData.count - 1].direction ?? "") var eventStartDate = Date(timeIntervalSince1970: self.bgData[self.bgData.count - 1].date) - // if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Calendar start date") } var eventEndDate = eventStartDate.addingTimeInterval(60 * 10) var eventTitle = UserDefaultsRepository.watchLine1.value if (UserDefaultsRepository.watchLine2.value.count > 1) { @@ -628,9 +627,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele for i in eVDelete! { do { try self.store.remove(i, span: EKSpan.thisEvent, commit: true) - //if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Calendar Delete") } } catch let error { - //if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Error - Calendar Delete") } print(error) } } @@ -644,16 +641,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele event.calendar = self.store.calendar(withIdentifier: UserDefaultsRepository.calendarIdentifier.value) do { try self.store.save(event, span: .thisEvent, commit: true) - self.calTimer.invalidate() - self.startCalTimer(time: (60 * 1)) - + self.lastCalendarWriteAttemptTime = now + self.lastCalDate = self.bgData[self.bgData.count - 1].date - //if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Calendar Write: " + eventTitle) } //UserDefaultsRepository.savedEventID.value = event.eventIdentifier //save event id to access this particular event later } catch { - print("*** Error storing to the calendar") - // Display error to user - //if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Error: Calendar Write") } + LogManager.shared.log(category: .calendar, message: "Error storing to the calendar") } } @@ -665,18 +658,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele snoozer.sendNotification(self, bgVal: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)), directionVal: latestDirectionString, deltaVal: Localizer.toDisplayUnits(String(latestDeltaString)), minAgoVal: latestMinAgoString, alertLabelVal: "Latest BG") } } - - func writeDebugLog(value: String) { - DispatchQueue.main.async { - var logText = "\n" + dateTimeUtils.printNow() + " - " + value - print(logText) - guard let debug = self.tabBarController!.viewControllers?[2] as? SnoozeViewController else { return } - if debug.debugTextView.text.lengthOfBytes(using: .utf8) > 20000 { - debug.debugTextView.text = "" - } - debug.debugTextView.text += logText - } - } // General Notifications diff --git a/LoopFollow/ViewControllers/SettingsViewController.swift b/LoopFollow/ViewControllers/SettingsViewController.swift index 8c615eb0..1a4586e2 100644 --- a/LoopFollow/ViewControllers/SettingsViewController.swift +++ b/LoopFollow/ViewControllers/SettingsViewController.swift @@ -12,7 +12,7 @@ import EventKit import EventKitUI import SwiftUI -class SettingsViewController: FormViewController { +class SettingsViewController: FormViewController, NightscoutSettingsViewModelDelegate { var tokenRow: TextRow? var appStateController: AppStateController? var statusLabelRow: LabelRow! @@ -45,8 +45,6 @@ class SettingsViewController: FormViewController { if UserDefaultsRepository.forceDarkMode.value { overrideUserInterfaceStyle = .dark } - UserDefaultsRepository.showNS.value = false - UserDefaultsRepository.showDex.value = false let buildDetails = BuildDetails.default let formattedBuildDate = dateTimeUtils.formattedDate(from: buildDetails.buildDate()) @@ -66,146 +64,38 @@ class SettingsViewController: FormViewController { guard let value = row.value else { return } UserDefaultsRepository.units.value = value } - <<< SwitchRow("showNS"){ row in - row.title = "Show Nightscout Settings" - row.value = UserDefaultsRepository.showNS.value - }.onChange { [weak self] row in - guard let value = row.value else { return } - UserDefaultsRepository.showNS.value = value - } - <<< TextRow() { row in - row.title = "URL" - row.placeholder = "https://mycgm.herokuapp.com" - row.value = ObservableUserDefaults.shared.url.value - row.hidden = "$showNS == false" - }.cellSetup { (cell, row) in - cell.textField.autocorrectionType = .no - cell.textField.autocapitalizationType = .none - }.onChange { row in - guard let value = row.value else { - ObservableUserDefaults.shared.url.value = "" - self.showHideNSDetails() - return - } - - var useTokenUrl = false - - // Attempt to handle special case: pasted URL including token - if let urlComponents = URLComponents(string: value), let queryItems = urlComponents.queryItems { - if let tokenItem = queryItems.first(where: { $0.name.lowercased() == "token" }) { - let tokenPattern = "^[^-\\s]+-[0-9a-fA-F]{16}$" - if let token = tokenItem.value, let _ = token.range(of: tokenPattern, options: .regularExpression) { - var baseComponents = urlComponents - baseComponents.queryItems = nil - if let baseURL = baseComponents.string { - UserDefaultsRepository.token.value = token - self.tokenRow?.value = token - self.tokenRow?.updateCell() - - ObservableUserDefaults.shared.url.value = baseURL - row.value = baseURL - row.updateCell() - useTokenUrl = true - } - } - } - } - - if !useTokenUrl { - // Normalize input: remove unwanted characters and lowercase - let filtered = value.replacingOccurrences(of: "[^A-Za-z0-9:/._-]", with: "", options: .regularExpression).lowercased() - - // Further clean-up: Remove trailing slashes - var cleanURL = filtered - while cleanURL.count > 8 && cleanURL.last == "/" { - cleanURL = String(cleanURL.dropLast()) - } - - ObservableUserDefaults.shared.url.value = cleanURL - row.value = cleanURL - row.updateCell() - } - - self.showHideNSDetails() - - // Verify Nightscout URL and token - self.checkNightscoutStatus() - } - - <<< TextRow() { row in - row.title = "NS Token" - row.placeholder = "Leave blank if not using tokens" - row.value = UserDefaultsRepository.token.value - row.hidden = "$showNS == false" - self.tokenRow = row - }.cellSetup { (cell, row) in - cell.textField.autocorrectionType = .no - cell.textField.autocapitalizationType = .none - cell.textField.textContentType = .password - }.onChange { row in - if row.value == nil { - UserDefaultsRepository.token.value = "" - } - guard let value = row.value else { return } - UserDefaultsRepository.token.value = value - - // Verify Nightscout URL and token - self.checkNightscoutStatus() - } - <<< LabelRow() { row in - row.title = "NS Status" - row.value = "Checking..." - statusLabelRow = row - row.hidden = "$showNS == false" - } - <<< SwitchRow("showDex"){ row in - row.title = "Show Dexcom Settings" - row.value = UserDefaultsRepository.showDex.value - }.onChange { row in - guard let value = row.value else { return } - UserDefaultsRepository.showDex.value = value - } - <<< TextRow(){ row in - row.title = "User Name" - row.value = UserDefaultsRepository.shareUserName.value - row.hidden = "$showDex == false" - }.cellSetup { (cell, row) in - cell.textField.autocorrectionType = .no - cell.textField.autocapitalizationType = .none - }.onChange { row in - if row.value == nil { - UserDefaultsRepository.shareUserName.value = "" - } - guard let value = row.value else { return } - UserDefaultsRepository.shareUserName.value = value - } - <<< TextRow(){ row in - row.title = "Password" - row.value = UserDefaultsRepository.sharePassword.value - row.hidden = "$showDex == false" - }.cellSetup { (cell, row) in - cell.textField.autocorrectionType = .no - cell.textField.isSecureTextEntry = true - cell.textField.autocapitalizationType = .none - }.onChange { row in - if row.value == nil { - UserDefaultsRepository.sharePassword.value = "" - } - guard let value = row.value else { return } - UserDefaultsRepository.sharePassword.value = value + <<< ButtonRow("nightscout") { + $0.title = "Nightscout Settings" + $0.presentationMode = .show( + controllerProvider: .callback(builder: { + self.presentNightscoutSettingsView() + return UIViewController() + }), onDismiss: nil + ) } - <<< SegmentedRow("shareServer") { row in - row.title = "Server" - row.options = ["US", "NON-US"] - row.value = UserDefaultsRepository.shareServer.value - row.hidden = "$showDex == false" - }.onChange { row in - guard let value = row.value else { return } - UserDefaultsRepository.shareServer.value = value + <<< ButtonRow("dexcom") { + $0.title = "Dexcom Settings" + $0.presentationMode = .show( + controllerProvider: .callback(builder: { + self.presentDexcomSettingsView() + return UIViewController() + }), onDismiss: nil + ) } +++ Section("App Settings") + <<< ButtonRow("backgroundRefreshSettings") { + $0.title = "Background Refresh Settings" + $0.presentationMode = .show( + controllerProvider: .callback(builder: { + self.presentBackgroundRefreshSettings() + return UIViewController() + }), + onDismiss: nil + ) + } + <<< ButtonRow() { $0.title = "General Settings" $0.presentationMode = .show( @@ -282,12 +172,28 @@ class SettingsViewController: FormViewController { $0.title = "Advanced Settings" $0.presentationMode = .show( controllerProvider: .callback(builder: { - let controller = AdvancedSettingsViewController() - controller.appStateController = self.appStateController - return controller - } - ), onDismiss: nil) + self.presentAdvancedSettingsView() + return UIViewController() + }), onDismiss: nil) + } + +++ Section("Logging") + <<< ButtonRow("viewlog") { + $0.title = "View Log" + $0.presentationMode = .show( + controllerProvider: .callback(builder: { + self.presentLogView() + return UIViewController() + }), onDismiss: nil) + } + <<< ButtonRow("shareLogs") { + $0.title = "Share Logs" + $0.cellSetup { cell, row in + cell.accessibilityIdentifier = "ShareLogsButton" + } + $0.onCellSelection { [weak self] _, _ in + self?.shareLogs() + } } +++ Section("Build Information") @@ -316,14 +222,12 @@ class SettingsViewController: FormViewController { } showHideNSDetails() - checkNightscoutStatus() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) refreshVersionInfo() - checkNightscoutStatus() } func refreshVersionInfo() { @@ -363,41 +267,6 @@ class SettingsViewController: FormViewController { #endif } - func updateStatusLabel(error: NightscoutUtils.NightscoutError?) { - if let error = error { - switch error { - case .invalidURL: - statusLabelRow.value = "Invalid URL" - case .networkError: - statusLabelRow.value = "Network Error" - case .invalidToken: - statusLabelRow.value = "Invalid Token" - case .tokenRequired: - statusLabelRow.value = "Token Required" - case .siteNotFound: - statusLabelRow.value = "Site Not Found" - case .unknown: - statusLabelRow.value = "Unknown Error" - case .emptyAddress: - statusLabelRow.value = "Address Empty" - } - } else { - statusLabelRow.value = "OK (Read\(ObservableUserDefaults.shared.nsWriteAuth.value ? " & Write" : ""))" - NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) - } - statusLabelRow.updateCell() - } - - func checkNightscoutStatus() { - NightscoutUtils.verifyURLAndToken { error, jwtToken, nsWriteAuth in - DispatchQueue.main.async { - ObservableUserDefaults.shared.nsWriteAuth.value = nsWriteAuth - - self.updateStatusLabel(error: error) - } - } - } - func presentInfoDisplaySettings() { let viewModel = InfoDisplaySettingsViewModel() let settingsView = InfoDisplaySettingsView(viewModel: viewModel) @@ -425,7 +294,6 @@ class SettingsViewController: FormViewController { present(hostingController, animated: true, completion: nil) } - func presentContactSettings() { let viewModel = ContactSettingsViewModel() let contactSettingsView = ContactSettingsView(viewModel: viewModel) @@ -438,4 +306,89 @@ class SettingsViewController: FormViewController { present(hostingController, animated: true, completion: nil) } + + func presentBackgroundRefreshSettings() { + let viewModel = BackgroundRefreshSettingsViewModel() + let settingsView = BackgroundRefreshSettingsView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: settingsView) + hostingController.modalPresentationStyle = .formSheet + + if UserDefaultsRepository.forceDarkMode.value { + hostingController.overrideUserInterfaceStyle = .dark + } + + present(hostingController, animated: true, completion: nil) + } + + func presentLogView() { + let viewModel = LogViewModel() + let logView = LogView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: logView) + hostingController.modalPresentationStyle = .formSheet + + if UserDefaultsRepository.forceDarkMode.value { + hostingController.overrideUserInterfaceStyle = .dark + } + + present(hostingController, animated: true, completion: nil) + } + + func presentNightscoutSettingsView() { + let viewModel = NightscoutSettingsViewModel() + viewModel.delegate = self + + let view = NightscoutSettingsView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: view) + hostingController.modalPresentationStyle = .formSheet + + if UserDefaultsRepository.forceDarkMode.value { + hostingController.overrideUserInterfaceStyle = .dark + } + + present(hostingController, animated: true, completion: nil) + } + + func nightscoutSettingsDidFinish() { + showHideNSDetails() + } + + func presentDexcomSettingsView() { + let viewModel = DexcomSettingsViewModel() + let settingsView = DexcomSettingsView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: settingsView) + hostingController.modalPresentationStyle = .formSheet + + if UserDefaultsRepository.forceDarkMode.value { + hostingController.overrideUserInterfaceStyle = .dark + } + + present(hostingController, animated: true, completion: nil) + } + + func presentAdvancedSettingsView() { + let viewModel = AdvancedSettingsViewModel() + let view = AdvancedSettingsView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: view) + hostingController.modalPresentationStyle = .formSheet + + if UserDefaultsRepository.forceDarkMode.value { + hostingController.overrideUserInterfaceStyle = .dark + } + + present(hostingController, animated: true, completion: nil) + } + + private func shareLogs() { + let logFilesToShare = LogManager.shared.logFilesForTodayAndYesterday() + + if !logFilesToShare.isEmpty { + let activityViewController = UIActivityViewController(activityItems: logFilesToShare, applicationActivities: nil) + activityViewController.popoverPresentationController?.sourceView = self.view + present(activityViewController, animated: true, completion: nil) + } else { + let alert = UIAlertController(title: "No Logs Available", message: "There are no logs to share.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true, completion: nil) + } + } } diff --git a/LoopFollow/ViewControllers/SnoozeViewController.swift b/LoopFollow/ViewControllers/SnoozeViewController.swift index 472d9fcd..859dc3f6 100644 --- a/LoopFollow/ViewControllers/SnoozeViewController.swift +++ b/LoopFollow/ViewControllers/SnoozeViewController.swift @@ -246,7 +246,7 @@ class SnoozeViewController: UIViewController, UNUserNotificationCenterDelegate { alarms.reloadSnoozeTime(key: "alertTempTargetEndSnoozedTime", setNil: false, value: currentDate.addingTimeInterval(snoozeDuration)) default: - print("Unhandled alarm: \(AlarmSound.whichAlarm)") + LogManager.shared.log(category: .alarm, message: "Unhandled alarm: \(AlarmSound.whichAlarm)") } }