Ver código fonte

BT heartbeat

Jonas Björkert 1 ano atrás
pai
commit
5803f487e4
41 arquivos alterados com 2128 adições e 421 exclusões
  1. 136 0
      LoopFollow.xcodeproj/project.pbxproj
  2. 11 14
      LoopFollow/Application/AppDelegate.swift
  3. 0 1
      LoopFollow/Application/SceneDelegate.swift
  4. 48 0
      LoopFollow/BackgroundRefresh/BT/BLEDevice.swift
  5. 50 0
      LoopFollow/BackgroundRefresh/BT/BLEDeviceSelectionView.swift
  6. 148 0
      LoopFollow/BackgroundRefresh/BT/BLEManager.swift
  7. 323 0
      LoopFollow/BackgroundRefresh/BT/BluetoothDevice.swift
  8. 18 0
      LoopFollow/BackgroundRefresh/BT/BluetoothDeviceDelegate.swift
  9. 45 0
      LoopFollow/BackgroundRefresh/BT/Devices/DexcomG7HeartbeatBluetoothDevice.swift
  10. 50 0
      LoopFollow/BackgroundRefresh/BT/Devices/RileyLinkHeartbeatBluetoothDevice.swift
  11. 37 0
      LoopFollow/BackgroundRefresh/BT/DexcomG7HeartBeat.swift
  12. 108 0
      LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift
  13. 42 0
      LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift
  14. 47 0
      LoopFollow/BackgroundRefresh/BackgroundRefreshType.swift
  15. 3 3
      LoopFollow/Contact/ContactImageUpdater.swift
  16. 0 2
      LoopFollow/Controllers/AppStateController.swift
  17. 91 23
      LoopFollow/Controllers/BackgroundAlertManager.swift
  18. 42 51
      LoopFollow/Controllers/Nightscout/BGData.swift
  19. 28 29
      LoopFollow/Controllers/Nightscout/DeviceStatus.swift
  20. 2 248
      LoopFollow/Controllers/Timers.swift
  21. 13 2
      LoopFollow/Info.plist
  22. 14 0
      LoopFollow/Log/LogEntry.swift
  23. 92 0
      LoopFollow/Log/LogManager.swift
  24. 54 0
      LoopFollow/Log/LogView.swift
  25. 97 0
      LoopFollow/Log/LogViewModel.swift
  26. 47 0
      LoopFollow/Log/SearchBar.swift
  27. 2 0
      LoopFollow/Loop Follow.entitlements
  28. 5 0
      LoopFollow/Storage/Storage.swift
  29. 3 1
      LoopFollow/Storage/UserDefaults.swift
  30. 35 0
      LoopFollow/Task/AlarmTask.swift
  31. 47 0
      LoopFollow/Task/BGTask.swift
  32. 29 0
      LoopFollow/Task/CalendarTask.swift
  33. 29 0
      LoopFollow/Task/DeviceStatusTask.swift
  34. 95 0
      LoopFollow/Task/MinAgoTask.swift
  35. 31 0
      LoopFollow/Task/ProfileTask.swift
  36. 22 0
      LoopFollow/Task/Task.swift
  37. 142 0
      LoopFollow/Task/TaskScheduler.swift
  38. 31 0
      LoopFollow/Task/TreatmentsTask.swift
  39. 0 8
      LoopFollow/ViewControllers/GeneralSettingsViewController.swift
  40. 39 38
      LoopFollow/ViewControllers/MainViewController.swift
  41. 72 1
      LoopFollow/ViewControllers/SettingsViewController.swift

+ 136 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -55,6 +55,11 @@
 		DD5334232C60ED3600062F9D /* IAge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334222C60ED3600062F9D /* IAge.swift */; };
 		DD5334232C60ED3600062F9D /* IAge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334222C60ED3600062F9D /* IAge.swift */; };
 		DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */; };
 		DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */; };
 		DD5334292C6166A500062F9D /* InfoDisplaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334282C6166A500062F9D /* InfoDisplaySettingsView.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 /* DexcomG7HeartbeatBluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5817192D299EF40041FB98 /* DexcomG7HeartbeatBluetoothDevice.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 */; };
 		DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */; };
 		DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; };
 		DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; };
 		DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; };
 		DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; };
@@ -66,6 +71,18 @@
 		DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */; };
 		DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */; };
 		DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; };
 		DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; };
 		DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD98F54324BCEFEE0007425A /* ShareClientExtension.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 */; };
 		DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB0AF512BB1A8BE00AFA48B /* BuildDetails.swift */; };
 		DDB0AF552BB1B24A00AFA48B /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */; };
 		DDB0AF552BB1B24A00AFA48B /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */; };
 		DDBE3ABD2CB5A961006B37DC /* OverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDBE3ABC2CB5A961006B37DC /* OverrideView.swift */; };
 		DDBE3ABD2CB5A961006B37DC /* OverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDBE3ABC2CB5A961006B37DC /* OverrideView.swift */; };
@@ -83,6 +100,9 @@
 		DDD10F072C529DE800D76A8E /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F062C529DE800D76A8E /* Observable.swift */; };
 		DDD10F072C529DE800D76A8E /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F062C529DE800D76A8E /* Observable.swift */; };
 		DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; };
 		DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; };
 		DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE69ED12C7256260013EAEC /* RemoteType.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 */; };
 		DDF2C0102BEFA991007A20E6 /* GitHubService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF2C00F2BEFA991007A20E6 /* GitHubService.swift */; };
 		DDF2C0122BEFB733007A20E6 /* AppVersionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF2C0112BEFB733007A20E6 /* AppVersionManager.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 */; };
 		DDF2C0142BEFD468007A20E6 /* blacklisted-versions.json in Resources */ = {isa = PBXBuildFile; fileRef = DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */; };
@@ -93,6 +113,10 @@
 		DDF6999E2C5AAA640058A8D9 /* ErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6999D2C5AAA640058A8D9 /* ErrorMessageView.swift */; };
 		DDF6999E2C5AAA640058A8D9 /* ErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6999D2C5AAA640058A8D9 /* ErrorMessageView.swift */; };
 		DDF9676E2AD08C6E00C5EB95 /* SiteChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF9676D2AD08C6E00C5EB95 /* SiteChange.swift */; };
 		DDF9676E2AD08C6E00C5EB95 /* SiteChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF9676D2AD08C6E00C5EB95 /* SiteChange.swift */; };
 		DDFD5C532CB167DA00D3FD68 /* TRCCommandType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFD5C522CB167DA00D3FD68 /* TRCCommandType.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 */; };
 		FC16A97A24996673003D6245 /* NightScout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97924996673003D6245 /* NightScout.swift */; };
 		FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7CE589248ABEA3001F83B8 /* AlarmSound.swift */; };
 		FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7CE589248ABEA3001F83B8 /* AlarmSound.swift */; };
 		FC16A97D24996747003D6245 /* Alarms.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97C24996747003D6245 /* Alarms.swift */; };
 		FC16A97D24996747003D6245 /* Alarms.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97C24996747003D6245 /* Alarms.swift */; };
@@ -296,6 +320,11 @@
 		DD5334222C60ED3600062F9D /* IAge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAge.swift; sourceTree = "<group>"; };
 		DD5334222C60ED3600062F9D /* IAge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAge.swift; sourceTree = "<group>"; };
 		DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplaySettingsViewModel.swift; sourceTree = "<group>"; };
 		DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplaySettingsViewModel.swift; sourceTree = "<group>"; };
 		DD5334282C6166A500062F9D /* InfoDisplaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplaySettingsView.swift; sourceTree = "<group>"; };
 		DD5334282C6166A500062F9D /* InfoDisplaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplaySettingsView.swift; sourceTree = "<group>"; };
+		DD5334AF2D1447C500CDD6EA /* BLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEManager.swift; sourceTree = "<group>"; };
+		DD5817162D2710E50041FB98 /* BLEDeviceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEDeviceSelectionView.swift; sourceTree = "<group>"; };
+		DD5817192D299EF40041FB98 /* DexcomG7HeartbeatBluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomG7HeartbeatBluetoothDevice.swift; sourceTree = "<group>"; };
+		DD58171B2D299F8D0041FB98 /* BluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothDevice.swift; sourceTree = "<group>"; };
+		DD58171D2D299FC50041FB98 /* BluetoothDeviceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothDeviceDelegate.swift; sourceTree = "<group>"; };
 		DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusLoop.swift; sourceTree = "<group>"; };
 		DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusLoop.swift; sourceTree = "<group>"; };
 		DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = "<group>"; };
 		DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = "<group>"; };
 		DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = "<group>"; };
 		DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = "<group>"; };
@@ -307,6 +336,18 @@
 		DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EKEventStore+Extensions.swift"; sourceTree = "<group>"; };
 		DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EKEventStore+Extensions.swift"; sourceTree = "<group>"; };
 		DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseConversion.swift; sourceTree = "<group>"; };
 		DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseConversion.swift; sourceTree = "<group>"; };
 		DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareClientExtension.swift; sourceTree = "<group>"; };
 		DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareClientExtension.swift; sourceTree = "<group>"; };
+		DD9ACA032D32821200415D8A /* DeviceStatusTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusTask.swift; sourceTree = "<group>"; };
+		DD9ACA052D32AF6E00415D8A /* TreatmentsTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreatmentsTask.swift; sourceTree = "<group>"; };
+		DD9ACA072D32F68900415D8A /* BGTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGTask.swift; sourceTree = "<group>"; };
+		DD9ACA092D33095500415D8A /* MinAgoTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinAgoTask.swift; sourceTree = "<group>"; };
+		DD9ACA0B2D33BB8400415D8A /* CalendarTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarTask.swift; sourceTree = "<group>"; };
+		DD9ACA0D2D340BFE00415D8A /* AlarmTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTask.swift; sourceTree = "<group>"; };
+		DD9ACA0F2D34128600415D8A /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = "<group>"; };
+		DD9ED0C72D35523F000D2A63 /* LogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewModel.swift; sourceTree = "<group>"; };
+		DD9ED0C92D355256000D2A63 /* LogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogView.swift; sourceTree = "<group>"; };
+		DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = "<group>"; };
+		DD9ED0CD2D355879000D2A63 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
+		DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RileyLinkHeartbeatBluetoothDevice.swift; sourceTree = "<group>"; };
 		DDB0AF502BB1A84500AFA48B /* capture-build-details.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "capture-build-details.sh"; sourceTree = "<group>"; };
 		DDB0AF502BB1A84500AFA48B /* capture-build-details.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "capture-build-details.sh"; sourceTree = "<group>"; };
 		DDB0AF512BB1A8BE00AFA48B /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = "<group>"; };
 		DDB0AF512BB1A8BE00AFA48B /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = "<group>"; };
 		DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = "<group>"; };
 		DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = "<group>"; };
@@ -325,6 +366,9 @@
 		DDD10F062C529DE800D76A8E /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = "<group>"; };
 		DDD10F062C529DE800D76A8E /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = "<group>"; };
 		DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryTarget.swift; sourceTree = "<group>"; };
 		DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryTarget.swift; sourceTree = "<group>"; };
 		DDE69ED12C7256260013EAEC /* RemoteType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteType.swift; sourceTree = "<group>"; };
 		DDE69ED12C7256260013EAEC /* RemoteType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteType.swift; sourceTree = "<group>"; };
+		DDEF50392D31614200999A5D /* LogManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = "<group>"; };
+		DDEF503B2D31BE2A00999A5D /* TaskScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskScheduler.swift; sourceTree = "<group>"; };
+		DDEF503E2D32754A00999A5D /* ProfileTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTask.swift; sourceTree = "<group>"; };
 		DDF2C00F2BEFA991007A20E6 /* GitHubService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubService.swift; sourceTree = "<group>"; };
 		DDF2C00F2BEFA991007A20E6 /* GitHubService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubService.swift; sourceTree = "<group>"; };
 		DDF2C0112BEFB733007A20E6 /* AppVersionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionManager.swift; sourceTree = "<group>"; };
 		DDF2C0112BEFB733007A20E6 /* AppVersionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionManager.swift; sourceTree = "<group>"; };
 		DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "blacklisted-versions.json"; sourceTree = "<group>"; };
 		DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "blacklisted-versions.json"; sourceTree = "<group>"; };
@@ -335,6 +379,10 @@
 		DDF6999D2C5AAA640058A8D9 /* ErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageView.swift; sourceTree = "<group>"; };
 		DDF6999D2C5AAA640058A8D9 /* ErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageView.swift; sourceTree = "<group>"; };
 		DDF9676D2AD08C6E00C5EB95 /* SiteChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteChange.swift; sourceTree = "<group>"; };
 		DDF9676D2AD08C6E00C5EB95 /* SiteChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteChange.swift; sourceTree = "<group>"; };
 		DDFD5C522CB167DA00D3FD68 /* TRCCommandType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TRCCommandType.swift; sourceTree = "<group>"; };
 		DDFD5C522CB167DA00D3FD68 /* TRCCommandType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TRCCommandType.swift; sourceTree = "<group>"; };
+		DDFF3D7E2D1414A200BF9D9E /* BLEDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEDevice.swift; sourceTree = "<group>"; };
+		DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsView.swift; sourceTree = "<group>"; };
+		DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsViewModel.swift; sourceTree = "<group>"; };
+		DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshType.swift; sourceTree = "<group>"; };
 		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 = "<group>"; };
 		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 = "<group>"; };
 		FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = "<group>"; };
 		FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = "<group>"; };
 		FC16A97C24996747003D6245 /* Alarms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alarms.swift; sourceTree = "<group>"; };
 		FC16A97C24996747003D6245 /* Alarms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alarms.swift; sourceTree = "<group>"; };
@@ -665,6 +713,27 @@
 			path = Extensions;
 			path = Extensions;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
 		};
 		};
+		DD9ED0C62D355225000D2A63 /* Log */ = {
+			isa = PBXGroup;
+			children = (
+				DD9ED0CD2D355879000D2A63 /* LogEntry.swift */,
+				DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */,
+				DD9ED0C92D355256000D2A63 /* LogView.swift */,
+				DD9ED0C72D35523F000D2A63 /* LogViewModel.swift */,
+				DDEF50392D31614200999A5D /* LogManager.swift */,
+			);
+			path = Log;
+			sourceTree = "<group>";
+		};
+		DDAD16302D2EF98C0084BE10 /* Devices */ = {
+			isa = PBXGroup;
+			children = (
+				DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */,
+				DD5817192D299EF40041FB98 /* DexcomG7HeartbeatBluetoothDevice.swift */,
+			);
+			path = Devices;
+			sourceTree = "<group>";
+		};
 		DDB0AF4F2BB1A81F00AFA48B /* Scripts */ = {
 		DDB0AF4F2BB1A81F00AFA48B /* Scripts */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
@@ -673,6 +742,22 @@
 			path = Scripts;
 			path = Scripts;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
 		};
 		};
+		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 = "<group>";
+		};
 		DDF699972C5AA2E50058A8D9 /* TempTargetPreset */ = {
 		DDF699972C5AA2E50058A8D9 /* TempTargetPreset */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
@@ -692,6 +777,30 @@
 			path = Views;
 			path = Views;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
 		};
 		};
+		DDFF3D792D140F1800BF9D9E /* BackgroundRefresh */ = {
+			isa = PBXGroup;
+			children = (
+				DDFF3D7A2D140F4200BF9D9E /* BT */,
+				DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */,
+				DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */,
+				DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */,
+			);
+			path = BackgroundRefresh;
+			sourceTree = "<group>";
+		};
+		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 = "<group>";
+		};
 		FC16A97624995FEE003D6245 /* Application */ = {
 		FC16A97624995FEE003D6245 /* Application */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
@@ -856,6 +965,9 @@
 		FC8DEEE32485D1680075863F /* LoopFollow */ = {
 		FC8DEEE32485D1680075863F /* LoopFollow */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
+				DD9ED0C62D355225000D2A63 /* Log */,
+				DDEF503D2D32753A00999A5D /* Task */,
+				DDFF3D792D140F1800BF9D9E /* BackgroundRefresh */,
 				DD50C74D2D0828250057AE6F /* Contact */,
 				DD50C74D2D0828250057AE6F /* Contact */,
 				DD5334252C61667700062F9D /* InfoDisplaySettings */,
 				DD5334252C61667700062F9D /* InfoDisplaySettings */,
 				DD0C0C6E2C4AFFB800DBADDF /* Remote */,
 				DD0C0C6E2C4AFFB800DBADDF /* Remote */,
@@ -1221,13 +1333,18 @@
 				DD5334292C6166A500062F9D /* InfoDisplaySettingsView.swift in Sources */,
 				DD5334292C6166A500062F9D /* InfoDisplaySettingsView.swift in Sources */,
 				FCC68850248935D800A0279D /* AlarmViewController.swift in Sources */,
 				FCC68850248935D800A0279D /* AlarmViewController.swift in Sources */,
 				DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */,
 				DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */,
+				DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */,
+				DD9ACA082D32F68B00415D8A /* BGTask.swift in Sources */,
+				DD9ACA102D34129200415D8A /* Task.swift in Sources */,
 				DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */,
 				DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */,
 				FC7CE59F248D8D23001F83B8 /* SnoozeViewController.swift in Sources */,
 				FC7CE59F248D8D23001F83B8 /* SnoozeViewController.swift in Sources */,
 				DDF6999B2C5AA32E0058A8D9 /* TempTargetPreset.swift in Sources */,
 				DDF6999B2C5AA32E0058A8D9 /* TempTargetPreset.swift in Sources */,
+				DD9ACA0E2D340BFF00415D8A /* AlarmTask.swift in Sources */,
 				DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */,
 				DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */,
 				FCC6886724898F8000A0279D /* UserDefaultsValue.swift in Sources */,
 				FCC6886724898F8000A0279D /* UserDefaultsValue.swift in Sources */,
 				DDF699942C555B310058A8D9 /* ViewControllerManager.swift in Sources */,
 				DDF699942C555B310058A8D9 /* ViewControllerManager.swift in Sources */,
 				DDCF979E24C2382A002C9752 /* AppStateController.swift in Sources */,
 				DDCF979E24C2382A002C9752 /* AppStateController.swift in Sources */,
+				DDEF503F2D32754F00999A5D /* ProfileTask.swift in Sources */,
 				DD5334212C60EBEE00062F9D /* InsulinCartridgeChange.swift in Sources */,
 				DD5334212C60EBEE00062F9D /* InsulinCartridgeChange.swift in Sources */,
 				FC97881E2485969B00A7906C /* NightScoutViewController.swift in Sources */,
 				FC97881E2485969B00A7906C /* NightScoutViewController.swift in Sources */,
 				DD608A0A2C23593900F91132 /* SMB.swift in Sources */,
 				DD608A0A2C23593900F91132 /* SMB.swift in Sources */,
@@ -1242,21 +1359,27 @@
 				DDF2C0122BEFB733007A20E6 /* AppVersionManager.swift in Sources */,
 				DDF2C0122BEFB733007A20E6 /* AppVersionManager.swift in Sources */,
 				DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */,
 				DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */,
 				DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */,
 				DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */,
+				DDFF3D7F2D1414A200BF9D9E /* BLEDevice.swift in Sources */,
+				DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */,
 				FC16A97D24996747003D6245 /* Alarms.swift in Sources */,
 				FC16A97D24996747003D6245 /* Alarms.swift in Sources */,
 				DDFD5C532CB167DA00D3FD68 /* TRCCommandType.swift in Sources */,
 				DDFD5C532CB167DA00D3FD68 /* TRCCommandType.swift in Sources */,
 				DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */,
 				DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */,
+				DD5817172D2710E90041FB98 /* BLEDeviceSelectionView.swift in Sources */,
 				FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */,
 				FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */,
 				DDBE3ABD2CB5A961006B37DC /* OverrideView.swift in Sources */,
 				DDBE3ABD2CB5A961006B37DC /* OverrideView.swift in Sources */,
 				DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */,
 				DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */,
 				DD0C0C622C4175FD00DBADDF /* NSProfile.swift in Sources */,
 				DD0C0C622C4175FD00DBADDF /* NSProfile.swift in Sources */,
+				DD58171E2D299FCA0041FB98 /* BluetoothDeviceDelegate.swift in Sources */,
 				DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */,
 				DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */,
 				DDD10F032C518A6500D76A8E /* TreatmentResponse.swift in Sources */,
 				DDD10F032C518A6500D76A8E /* TreatmentResponse.swift in Sources */,
 				DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */,
 				DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */,
 				DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */,
 				DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */,
 				DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */,
 				DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */,
+				DDEF503A2D31615000999A5D /* LogManager.swift in Sources */,
 				DD4878172C7B75350048F05C /* BolusView.swift in Sources */,
 				DD4878172C7B75350048F05C /* BolusView.swift in Sources */,
 				DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */,
 				DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */,
 				FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
 				FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
+				DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */,
 				DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */,
 				DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */,
 				DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */,
 				DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */,
 				DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */,
 				DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */,
@@ -1280,9 +1403,14 @@
 				DDF9676E2AD08C6E00C5EB95 /* SiteChange.swift in Sources */,
 				DDF9676E2AD08C6E00C5EB95 /* SiteChange.swift in Sources */,
 				DD13BC772C3FD64E0062313B /* InfoData.swift in Sources */,
 				DD13BC772C3FD64E0062313B /* InfoData.swift in Sources */,
 				DD13BC752C3FD6210062313B /* InfoType.swift in Sources */,
 				DD13BC752C3FD6210062313B /* InfoType.swift in Sources */,
+				DD9ACA0A2D33095600415D8A /* MinAgoTask.swift in Sources */,
 				DDCF979C24C14EFB002C9752 /* AdvancedSettingsViewController.swift in Sources */,
 				DDCF979C24C14EFB002C9752 /* AdvancedSettingsViewController.swift in Sources */,
+				DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */,
 				DDD10EFF2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift in Sources */,
 				DDD10EFF2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift in Sources */,
+				DD58171A2D299EF80041FB98 /* DexcomG7HeartbeatBluetoothDevice.swift in Sources */,
+				DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */,
 				DD50C7502D0828800057AE6F /* ContactSettingsViewModel.swift in Sources */,
 				DD50C7502D0828800057AE6F /* ContactSettingsViewModel.swift in Sources */,
+				DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */,
 				FC97881C2485969B00A7906C /* MainViewController.swift in Sources */,
 				FC97881C2485969B00A7906C /* MainViewController.swift in Sources */,
 				DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */,
 				DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */,
 				DD493AD52ACF2109009A6922 /* ResumePump.swift in Sources */,
 				DD493AD52ACF2109009A6922 /* ResumePump.swift in Sources */,
@@ -1291,15 +1419,19 @@
 				DD493AD72ACF2139009A6922 /* SuspendPump.swift in Sources */,
 				DD493AD72ACF2139009A6922 /* SuspendPump.swift in Sources */,
 				FC9788182485969B00A7906C /* AppDelegate.swift in Sources */,
 				FC9788182485969B00A7906C /* AppDelegate.swift in Sources */,
 				DDD10F072C529DE800D76A8E /* Observable.swift in Sources */,
 				DDD10F072C529DE800D76A8E /* Observable.swift in Sources */,
+				DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */,
 				DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */,
 				DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */,
 				DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */,
 				DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */,
 				DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */,
 				DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */,
+				DDEF503C2D31BE2D00999A5D /* TaskScheduler.swift in Sources */,
 				DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */,
 				DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */,
 				DD493AD92ACF2171009A6922 /* Carbs.swift in Sources */,
 				DD493AD92ACF2171009A6922 /* Carbs.swift in Sources */,
 				DD493AE92ACF2445009A6922 /* BGData.swift in Sources */,
 				DD493AE92ACF2445009A6922 /* BGData.swift in Sources */,
 				FCC6886B24898FD800A0279D /* ObservationToken.swift in Sources */,
 				FCC6886B24898FD800A0279D /* ObservationToken.swift in Sources */,
 				DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */,
 				DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */,
+				DD9ACA062D32AF7900415D8A /* TreatmentsTask.swift in Sources */,
 				DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */,
 				DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */,
+				DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */,
 				DD0C0C602C415B9D00DBADDF /* ProfileManager.swift in Sources */,
 				DD0C0C602C415B9D00DBADDF /* ProfileManager.swift in Sources */,
 				DDF699962C5582290058A8D9 /* TextFieldWithToolBar.swift in Sources */,
 				DDF699962C5582290058A8D9 /* TextFieldWithToolBar.swift in Sources */,
 				DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */,
 				DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */,
@@ -1308,8 +1440,10 @@
 				FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */,
 				FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */,
 				FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */,
 				FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */,
 				DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */,
 				DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */,
+				DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */,
 				DD7E198A2ACDA62600DBD158 /* SensorStart.swift in Sources */,
 				DD7E198A2ACDA62600DBD158 /* SensorStart.swift in Sources */,
 				FCD49B6C24AA536E007879DC /* DebugViewController.swift in Sources */,
 				FCD49B6C24AA536E007879DC /* DebugViewController.swift in Sources */,
+				DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */,
 				DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */,
 				DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */,
 				DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift.swift in Sources */,
 				DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift.swift in Sources */,
 				FC1BDD2B24A22650001B652C /* Stats.swift in Sources */,
 				FC1BDD2B24A22650001B652C /* Stats.swift in Sources */,
@@ -1326,6 +1460,8 @@
 				DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */,
 				DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */,
 				DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */,
 				DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */,
 				FCA2DDE62501095000254A8C /* Timers.swift in Sources */,
 				FCA2DDE62501095000254A8C /* Timers.swift in Sources */,
+				DD9ED0C82D355244000D2A63 /* LogViewModel.swift in Sources */,
+				DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */,
 				DD493AE32ACF2358009A6922 /* CAge.swift in Sources */,
 				DD493AE32ACF2358009A6922 /* CAge.swift in Sources */,
 				DD493ADD2ACF21E0009A6922 /* Basals.swift in Sources */,
 				DD493ADD2ACF21E0009A6922 /* Basals.swift in Sources */,
 				FC16A98124996C07003D6245 /* DateTime.swift in Sources */,
 				FC16A98124996C07003D6245 /* DateTime.swift in Sources */,

+ 11 - 14
LoopFollow/Application/AppDelegate.swift

@@ -18,8 +18,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
     let notificationCenter = UNUserNotificationCenter.current()
     let notificationCenter = UNUserNotificationCenter.current()
 
 
     func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
     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]
         let options: UNAuthorizationOptions = [.alert, .sound, .badge]
         notificationCenter.requestAuthorization(options: options) {
         notificationCenter.requestAuthorization(options: options) {
             (didAllow, error) in
             (didAllow, error) in
@@ -27,7 +28,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
                 print("User has declined notifications")
                 print("User has declined notifications")
             }
             }
         }
         }
-        
+
         let store = EKEventStore()
         let store = EKEventStore()
         store.requestCalendarAccess { (granted, error) in
         store.requestCalendarAccess { (granted, error) in
             if !granted {
             if !granted {
@@ -35,7 +36,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
                 return
                 return
             }
             }
         }
         }
-        
+
         let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
         let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
         let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: [])
         let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: [])
         UNUserNotificationCenter.current().setNotificationCategories([category])
         UNUserNotificationCenter.current().setNotificationCategories([category])
@@ -44,7 +45,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
 
         // Ensure ViewControllerManager is initialized
         // Ensure ViewControllerManager is initialized
         _ = ViewControllerManager.shared
         _ = ViewControllerManager.shared
-        
+
+        _ = BLEManager.shared
+
         return true
         return true
     }
     }
 
 
@@ -56,23 +59,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
     }
     }
 
 
     // MARK: UISceneSession Lifecycle
     // MARK: UISceneSession Lifecycle
-    
+
     func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
     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
         // set "prevent screen lock" to ON when the app is started for the first time
         if !UserDefaultsRepository.screenlockSwitchState.exists {
         if !UserDefaultsRepository.screenlockSwitchState.exists {
             UserDefaultsRepository.screenlockSwitchState.value = true
             UserDefaultsRepository.screenlockSwitchState.value = true
         }
         }
-        
+
         // set the "prevent screen lock" option when the app is started
         // 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
         // This method doesn't seem to be working anymore. Added to view controllers as solution offered on SO
         UIApplication.shared.isIdleTimerDisabled = UserDefaultsRepository.screenlockSwitchState.value
         UIApplication.shared.isIdleTimerDisabled = UserDefaultsRepository.screenlockSwitchState.value
-        
+
         return true
         return true
     }
     }
 
 

+ 0 - 1
LoopFollow/Application/SceneDelegate.swift

@@ -127,4 +127,3 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
         updateQuickActions()
         updateQuickActions()
     }
     }
 }
 }
-

+ 48 - 0
LoopFollow/BackgroundRefresh/BT/BLEDevice.swift

@@ -0,0 +1,48 @@
+//
+//  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
+    }
+
+    func updateLastSeen() -> BLEDevice {
+        var updatedDevice = self
+        updatedDevice.lastSeen = Date()
+        return updatedDevice
+    }
+
+    func updateLastConnected() -> BLEDevice {
+        var updatedDevice = self
+        updatedDevice.lastConnected = Date()
+        updatedDevice.isConnected = true
+        return updatedDevice
+    }
+}

+ 50 - 0
LoopFollow/BackgroundRefresh/BT/BLEDeviceSelectionView.swift

@@ -0,0 +1,50 @@
+//
+//  BLEDeviceSelectionView.swift
+//  LoopFollow
+//
+
+import SwiftUI
+
+struct BLEDeviceSelectionView: View {
+    @ObservedObject var bleManager: BLEManager
+    var selectedFilter: BackgroundRefreshType
+    var onSelectDevice: (BLEDevice) -> Void
+
+    var body: some View {
+        VStack {
+            Text("Scanning for \(selectedFilter.rawValue)")
+                .font(.headline)
+
+            List {
+                ForEach(bleManager.devices.filter { selectedFilter.matches($0) && !isSelected($0) }, id: \.id) { device in
+                    HStack {
+                        VStack(alignment: .leading) {
+                            Text(device.name ?? "Unknown")
+                            Text("\(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
+    }
+}

+ 148 - 0
LoopFollow/BackgroundRefresh/BT/BLEManager.swift

@@ -0,0 +1,148 @@
+//
+//  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 {
+            connect(device: device)
+        }
+    }
+
+    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
+        }
+        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
+
+            switch matchedType {
+            case .dexcomG7:
+                activeDevice = DexcomG7HeartbeatBluetoothDevice(bluetoothDeviceDelegate: self)
+                activeDevice?.connect()
+            case .rileyLink:
+                activeDevice = RileyLinkHeartbeatBluetoothDevice(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()
+    }
+
+    private func addOrUpdateDevice(_ device: BLEDevice) {
+        if let idx = devices.firstIndex(where: { $0.id == device.id }) {
+            devices[idx] = device.updateLastSeen()
+        } 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:
+            print("[BLE] Central poweredOn.")
+        default:
+            print("[BLE] Central state = \(central.state.rawValue), not powered on.")
+        }
+    }
+
+    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)
+    }
+}
+
+extension BLEManager: BluetoothDeviceDelegate {
+    func didConnectTo(bluetoothDevice: BluetoothDevice) {
+        LogManager.shared.log(category: .bluetooth, message: "Connected to: \(String(describing: bluetoothDevice.deviceName))")
+        if var device = Storage.shared.selectedBLEDevice.value {
+            device.isConnected = true
+            Storage.shared.selectedBLEDevice.value = device.updateLastConnected()
+        }
+    }
+
+    func didDisconnectFrom(bluetoothDevice: BluetoothDevice) {
+        LogManager.shared.log(category: .bluetooth, message: "Disconnect from: \(String(describing: bluetoothDevice.deviceName))")
+        if var device = Storage.shared.selectedBLEDevice.value {
+            device.isConnected = false
+            Storage.shared.selectedBLEDevice.value = device
+        }
+    }
+
+    func heartBeat() {
+        LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered")
+        TaskScheduler.shared.checkTasksNow()
+    }
+}

+ 323 - 0
LoopFollow/BackgroundRefresh/BT/BluetoothDevice.swift

@@ -0,0 +1,323 @@
+//
+//  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?
+
+    init(address:String, name:String?, CBUUID_Advertisement:String?, servicesCBUUIDs:[CBUUID]?, CBUUID_ReceiveCharacteristic:String, bluetoothDeviceDelegate: BluetoothDeviceDelegate) {
+        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 {
+        print("[BLE] startScanning")
+
+        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) {
+        print("[BLE] stopScanAndconnect")
+
+        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) {
+        print("[BLE] centralManagerDidUpdateState")
+
+        timeStampLastStatusUpdate = Date()
+
+        if central.state == .poweredOn {
+            if (deviceAddress != nil) {
+                _ = 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"
+            }
+        }
+    }
+}

+ 18 - 0
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()
+}

+ 45 - 0
LoopFollow/BackgroundRefresh/BT/Devices/DexcomG7HeartbeatBluetoothDevice.swift

@@ -0,0 +1,45 @@
+//
+//  DexcomG7HeartbeatBluetoothDevice.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 DexcomG7HeartbeatBluetoothDevice: 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"
+
+    private var timeStampOfLastHeartBeat: Date
+
+    init(bluetoothDeviceDelegate: BluetoothDeviceDelegate) {
+        guard let selectedDevice = Storage.shared.selectedBLEDevice.value else {
+            fatalError("No selected BLE device found in storage.")
+        }
+
+        let address = selectedDevice.id.uuidString
+        let name = selectedDevice.name
+
+        self.timeStampOfLastHeartBeat = Date(timeIntervalSince1970: 0)
+
+        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()
+    }
+}

+ 50 - 0
LoopFollow/BackgroundRefresh/BT/Devices/RileyLinkHeartbeatBluetoothDevice.swift

@@ -0,0 +1,50 @@
+//
+//  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(bluetoothDeviceDelegate: BluetoothDeviceDelegate) {
+        guard let selectedDevice = Storage.shared.selectedBLEDevice.value else {
+            fatalError("No selected BLE device found in storage.")
+        }
+
+        let address = selectedDevice.id.uuidString
+        let deviceName = selectedDevice.name
+
+        super.init(
+            address: address,
+            name: deviceName,
+            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()
+    }
+}

+ 37 - 0
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
+        )
+    }
+}

+ 108 - 0
LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift

@@ -0,0 +1,108 @@
+//
+//  BackgroundRefreshSettingsView.swift
+//  LoopFollow
+//
+
+import SwiftUI
+
+struct BackgroundRefreshSettingsView: View {
+    @ObservedObject var viewModel: BackgroundRefreshSettingsViewModel
+    @Environment(\.presentationMode) var presentationMode
+
+    @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()
+                    }
+                }
+            }
+        }
+    }
+
+    // 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())
+
+            Text("Adjust the background refresh type.")
+                .font(.footnote)
+                .foregroundColor(.secondary)
+        }
+    }
+
+    @ViewBuilder
+    private var selectedDeviceSection: some View {
+        if let storedDevice = Storage.shared.selectedBLEDevice.value {
+            Section(header: Text("Selected Device")) {
+                VStack(alignment: .leading, spacing: 4) {
+                    Text(storedDevice.name ?? "Unknown Device")
+                        .font(.headline)
+
+                    deviceConnectionStatus(for: storedDevice)
+
+                    HStack {
+                        Spacer()
+                        Button(action: {
+                            bleManager.disconnect()
+                        }) {
+                            Text("Disconnect")
+                                .foregroundColor(.blue)
+                        }
+                        .buttonStyle(BorderlessButtonStyle())
+                        Spacer()
+                    }
+                }
+                .padding(.vertical, 8)
+            }
+        }
+    }
+
+    private var availableDevicesSection: some View {
+        Section(header: Text("Available Devices")) {
+            BLEDeviceSelectionView(
+                bleManager: bleManager,
+                selectedFilter: viewModel.backgroundRefreshType,
+                onSelectDevice: { device in
+                    bleManager.connect(device: device)
+                }
+            )
+        }
+    }
+
+    private func deviceConnectionStatus(for device: BLEDevice) -> some View {
+        if device.isConnected {
+            return Text("Connected")
+                .foregroundColor(.green)
+        } else if let lastConnected = device.lastConnected {
+            let date = dateTimeUtils.formattedDate(from: lastConnected)
+            return Text("Last connection: \(date)")
+                .foregroundColor(.orange)
+        } else if let item = bleManager.devices.first(where: { $0.id == device.id }) {
+            let date = dateTimeUtils.formattedDate(from: item.lastSeen)
+            return Text("Last seen: \(date)")
+                .foregroundColor(.orange)
+        } else {
+            return Text("Not found")
+                .foregroundColor(.red)
+        }
+    }
+}

+ 42 - 0
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<AnyCancellable>()
+
+    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) {
+        print("Background refresh type changed to: \(newValue.rawValue)")
+
+        BLEManager.shared.disconnect()
+    }
+}

+ 47 - 0
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 dexcomG7 = "Dexcom G7/ONE+"
+
+    /// Indicates if the device type uses Bluetooth
+    var isBluetooth: Bool {
+        switch self {
+        case .rileyLink, .dexcomG7:
+            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 .dexcomG7:
+            if let name = device.name {
+                return name.hasPrefix("DXCM") || name.hasPrefix("DX02")
+            }
+            return false
+
+        case .silentTune, .none:
+            return false
+        }
+    }
+}

+ 3 - 3
LoopFollow/Contact/ContactImageUpdater.swift

@@ -39,7 +39,7 @@ class ContactImageUpdater {
                     let saveRequest = CNSaveRequest()
                     let saveRequest = CNSaveRequest()
                     saveRequest.update(mutableContact)
                     saveRequest.update(mutableContact)
                     try self.contactStore.execute(saveRequest)
                     try self.contactStore.execute(saveRequest)
-                    print("Contact image updated successfully.")
+                    LogManager.shared.log(category: .contact, message: "Contact image updated successfully")
                 } else {
                 } else {
                     let newContact = CNMutableContact()
                     let newContact = CNMutableContact()
                     newContact.givenName = contactName
                     newContact.givenName = contactName
@@ -47,10 +47,10 @@ class ContactImageUpdater {
                     let saveRequest = CNSaveRequest()
                     let saveRequest = CNSaveRequest()
                     saveRequest.add(newContact, toContainerWithIdentifier: nil)
                     saveRequest.add(newContact, toContainerWithIdentifier: nil)
                     try self.contactStore.execute(saveRequest)
                     try self.contactStore.execute(saveRequest)
-                    print("New contact created with updated image.")
+                    LogManager.shared.log(category: .contact, message: "New contact created")
                 }
                 }
             } catch {
             } catch {
-                print("Failed to update or create contact: \(error)")
+                LogManager.shared.log(category: .contact, message: "Failed to update or create contact: \(error)")
             }
             }
         }
         }
     }
     }

+ 0 - 2
LoopFollow/Controllers/AppStateController.swift

@@ -38,8 +38,6 @@ enum ChartSettingsChangeEnum: Int {
 enum GeneralSettingsChangeEnum: Int { 
 enum GeneralSettingsChangeEnum: Int { 
     case colorBGTextChange = 1
     case colorBGTextChange = 1
     case speakBGChange = 2
     case speakBGChange = 2
-    case backgroundRefreshFrequencyChange = 4
-    case backgroundRefreshChange = 8
     case appBadgeChange = 16
     case appBadgeChange = 16
     case dimScreenWhenIdleChange = 32
     case dimScreenWhenIdleChange = 32
     case forceDarkModeChang = 64
     case forceDarkModeChang = 64

+ 91 - 23
LoopFollow/Controllers/BackgroundAlertManager.swift

@@ -9,44 +9,112 @@
 import Foundation
 import Foundation
 import UserNotifications
 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 {
 class BackgroundAlertManager {
     static let shared = BackgroundAlertManager()
     static let shared = BackgroundAlertManager()
-    
+
     private init() {}
     private init() {}
-    
+
+    /// Flag indicating whether background alerts are currently scheduled.
     private var isAlertScheduled: Bool = false
     private var isAlertScheduled: Bool = false
-    
+
+    /// Title prefix for all background refresh notifications.
+    private let notificationTitlePrefix = "LoopFollow Background Refresh"
+
+    /// Start scheduling background alerts.
     func startBackgroundAlert() {
     func startBackgroundAlert() {
         isAlertScheduled = true
         isAlertScheduled = true
         scheduleBackgroundAlert()
         scheduleBackgroundAlert()
     }
     }
-    
+
+    /// Stop all scheduled background alerts.
     func stopBackgroundAlert() {
     func stopBackgroundAlert() {
         isAlertScheduled = false
         isAlertScheduled = false
-        cancelBackgroundAlert()
+        cancelBackgroundAlerts()
     }
     }
-    
+
+    /// Schedule all background alerts based on predefined durations.
     func scheduleBackgroundAlert() {
     func scheduleBackgroundAlert() {
-        guard isAlertScheduled, UserDefaultsRepository.backgroundRefresh.value else { return }
-        
+        guard isAlertScheduled, Storage.shared.backgroundRefreshType.value != .none else { return }
+
+        let isBluetoothActive = Storage.shared.backgroundRefreshType.value.isBluetooth
+
+        // Define all alerts in an array for scalability.
+        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."
+            )
+        ]
+
+        // Schedule each alert.
+        for alert in alerts {
+            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 {
+                    print("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()
         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.sound = .defaultCritical
         content.categoryIdentifier = "loopfollow.background.alert"
         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)
     }
     }
 }
 }
+
+/// Struct representing a single background alert.
+struct BackgroundAlert {
+    let identifier: String
+    let timeInterval: TimeInterval
+    let body: String
+}

+ 42 - 51
LoopFollow/Controllers/Nightscout/BGData.swift

@@ -18,17 +18,14 @@ extension MainViewController {
         dexShare?.fetchData(count) { (err, result) -> () in
         dexShare?.fetchData(count) { (err, result) -> () in
             
             
             if let error = err {
             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
                 return
             }
             }
             
             
             guard let data = result else {
             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
                 return
             }
             }
             
             
@@ -52,14 +49,11 @@ extension MainViewController {
 
 
     // NS BG Data Web call
     // NS BG Data Web call
     func webLoadNSBGData(dexData: [ShareGlucoseData] = []) {
     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 &&
         // This kicks it out in the instance where dexcom fails but they aren't using NS &&
         if !IsNightscoutEnabled() {
         if !IsNightscoutEnabled() {
-            self.startBGTimer(time: 10)
             return
             return
         }
         }
-        
+
         var parameters: [String: String] = [:]
         var parameters: [String: String] = [:]
         let utcISODateFormatter = ISO8601DateFormatter()
         let utcISODateFormatter = ISO8601DateFormatter()
         let date = Calendar.current.date(byAdding: .day, value: -1 * UserDefaultsRepository.downloadDays.value, to: Date())!
         let date = Calendar.current.date(byAdding: .day, value: -1 * UserDefaultsRepository.downloadDays.value, to: Date())!
@@ -117,10 +111,10 @@ extension MainViewController {
             case .failure(let error):
             case .failure(let error):
                 print("Failed to fetch data: \(error)")
                 print("Failed to fetch data: \(error)")
                 DispatchQueue.main.async {
                 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 we have Dex data, use it
                 if !dexData.isEmpty {
                 if !dexData.isEmpty {
@@ -133,8 +127,6 @@ extension MainViewController {
     
     
     // Dexcom BG Data Response processor
     // Dexcom BG Data Response processor
     func ProcessDexBGData(data: [ShareGlucoseData], sourceName: String){
     func ProcessDexBGData(data: [ShareGlucoseData], sourceName: String){
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: BG") }
-        
         let graphHours = 24 * UserDefaultsRepository.downloadDays.value
         let graphHours = 24 * UserDefaultsRepository.downloadDays.value
         
         
         if data.count == 0 {
         if data.count == 0 {
@@ -148,34 +140,41 @@ extension MainViewController {
         let secondsAgo = now - latestDate
         let secondsAgo = now - latestDate
         
         
         DispatchQueue.main.async {
         DispatchQueue.main.async {
-            // if reading is overdue over: 20:00, re-attempt every 5 minutes
             if secondsAgo >= (20 * 60) {
             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)
+                )
+                print("##### scheduled BG task in 5 minutes")
+
             } else if secondsAgo >= (10 * 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)
+                )
+                print("##### scheduled BG task in 1 minute")
+
             } else if secondsAgo >= (7 * 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)
+                )
+                print("##### scheduled BG task in 30 seconds")
+
             } else if secondsAgo >= (5 * 60) {
             } 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)
+                )
+                print("##### scheduled BG task in 10 seconds")
+
             } else {
             } else {
-                self.startBGTimer(time: 300 - secondsAgo + Double(UserDefaultsRepository.bgUpdateDelay.value))
-                let timerVal = 310 - secondsAgo
-                print("##### started 5:10 bg timer: \(timerVal)")
-                if data.count > 1 {
-                    self.evaluateSpeakConditions(currentValue: data[0].sgv, previousValue: data[1].sgv)
-                }
+                let delay = (300 - secondsAgo + Double(UserDefaultsRepository.bgUpdateDelay.value))
+                TaskScheduler.shared.rescheduleTask(
+                    id: .fetchBG,
+                    to: Date().addingTimeInterval(delay)
+                )
+                print("##### scheduled BG task in \(delay) seconds")
             }
             }
         }
         }
         
         
@@ -211,12 +210,9 @@ extension MainViewController {
     
     
     // NS BG Data Front end updater
     // NS BG Data Front end updater
     func viewUpdateNSBG(sourceName: String) {
     func viewUpdateNSBG(sourceName: String) {
+        TaskScheduler.shared.rescheduleTask(id: .minAgoUpdate, to: Date())
+        
         DispatchQueue.main.async {
         DispatchQueue.main.async {
-            if UserDefaultsRepository.debugLog.value {
-                self.writeDebugLog(value: "Display: BG")
-                self.writeDebugLog(value: "Num BG: " + self.bgData.count.description)
-            }
-            
             let entries = self.bgData
             let entries = self.bgData
             if entries.count < 2 { return } // Protect index out of bounds
             if entries.count < 2 { return } // Protect index out of bounds
             
             
@@ -229,12 +225,7 @@ extension MainViewController {
             let deltaBG = latestBG - priorBG
             let deltaBG = latestBG - priorBG
             let lastBGTime = entries[latestEntryIndex].date
             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)
             self.updateServerText(with: sourceName)
             
             
             var snoozerBG = ""
             var snoozerBG = ""

+ 28 - 29
LoopFollow/Controllers/Nightscout/DeviceStatus.swift

@@ -13,10 +13,6 @@ import Charts
 extension MainViewController {
 extension MainViewController {
     // NS Device Status Web Call
     // NS Device Status Web Call
     func webLoadNSDeviceStatus() {
     func webLoadNSDeviceStatus() {
-        if UserDefaultsRepository.debugLog.value {
-            self.writeDebugLog(value: "Download: device status")
-        }
-        
         let parameters: [String: String] = ["count": "1"]
         let parameters: [String: String] = ["count": "1"]
         NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters) { result in
         NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters) { result in
             switch result {
             switch result {
@@ -37,10 +33,10 @@ extension MainViewController {
     
     
     private func handleDeviceStatusError() {
     private func handleDeviceStatusError() {
         DispatchQueue.main.async {
         DispatchQueue.main.async {
-            if self.deviceStatusTimer.isValid {
-                self.deviceStatusTimer.invalidate()
-            }
-            self.startDeviceStatusTimer(time: 10)
+            TaskScheduler.shared.rescheduleTask(
+                id: .deviceStatus,
+                to: Date().addingTimeInterval(10)
+            )
         }
         }
     }
     }
     
     
@@ -85,7 +81,6 @@ extension MainViewController {
     func updateDeviceStatusDisplay(jsonDeviceStatus: [[String:AnyObject]]) {
     func updateDeviceStatusDisplay(jsonDeviceStatus: [[String:AnyObject]]) {
         infoManager.clearInfoData(types: [.iob, .cob, .override, .battery, .pump, .target, .isf, .carbRatio, .updated, .recBolus, .tdd])
         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 {
         if jsonDeviceStatus.count == 0 {
             return
             return
         }
         }
@@ -156,31 +151,35 @@ extension MainViewController {
         let secondsAgo = now - latestLoopTime
         let secondsAgo = now - latestLoopTime
         
         
         DispatchQueue.main.async {
         DispatchQueue.main.async {
-            // if Loop is overdue over: 20:00, re-attempt every 5 minutes
             if secondsAgo >= (20 * 60) {
             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) {
             } 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) {
             } 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) {
             } 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 {
             } 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)
+                )
             }
             }
         }
         }
     }
     }

+ 2 - 248
LoopFollow/Controllers/Timers.swift

@@ -9,94 +9,8 @@
 import Foundation
 import Foundation
 import UIKit
 import UIKit
 
 
-
 extension MainViewController {
 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
     // Runs a 60 second timer when an alarm is snoozed
     // Prevents the alarm from triggering again while saving the snooze time to settings
     // Prevents the alarm from triggering again while saving the snooze time to settings
     // End function needs nothing done
     // End function needs nothing done
@@ -127,109 +41,7 @@ extension MainViewController {
     
     
     @objc func checkAlarmTimerDidEnd(_ timer:Timer) {
     @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.
     // 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
     // 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) {
     func startAlarmPlayingTimer(time: TimeInterval = 290) {
@@ -245,62 +57,4 @@ extension MainViewController {
             stopAlarmAtNextReading()
             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) {
-        
-    }
-    
 }
 }

+ 13 - 2
LoopFollow/Info.plist

@@ -2,6 +2,8 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <plist version="1.0">
 <dict>
 <dict>
+	<key>NSHumanReadableCopyright</key>
+	<string></string>
 	<key>AppGroupIdentifier</key>
 	<key>AppGroupIdentifier</key>
 	<string>group.com.$(unique_id).LoopFollow$(app_suffix)</string>
 	<string>group.com.$(unique_id).LoopFollow$(app_suffix)</string>
 	<key>BGTaskSchedulerPermittedIdentifiers</key>
 	<key>BGTaskSchedulerPermittedIdentifiers</key>
@@ -14,6 +16,8 @@
 	<string>$(display_name)</string>
 	<string>$(display_name)</string>
 	<key>CFBundleExecutable</key>
 	<key>CFBundleExecutable</key>
 	<string>$(EXECUTABLE_NAME)</string>
 	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleGetInfoString</key>
+	<string></string>
 	<key>CFBundleIdentifier</key>
 	<key>CFBundleIdentifier</key>
 	<string>com.$(unique_id).LoopFollow$(app_suffix)</string>
 	<string>com.$(unique_id).LoopFollow$(app_suffix)</string>
 	<key>CFBundleInfoDictionaryVersion</key>
 	<key>CFBundleInfoDictionaryVersion</key>
@@ -43,10 +47,14 @@
 	<string>13.0</string>
 	<string>13.0</string>
 	<key>LSRequiresIPhoneOS</key>
 	<key>LSRequiresIPhoneOS</key>
 	<true/>
 	<true/>
+	<key>NSBluetoothAlwaysUsageDescription</key>
+	<string>This app uses Bluetooth to connect to devices for managing background operations.</string>
 	<key>NSCalendarsFullAccessUsageDescription</key>
 	<key>NSCalendarsFullAccessUsageDescription</key>
 	<string>Loop Follow would like to access your calendar to update BG readings</string>
 	<string>Loop Follow would like to access your calendar to update BG readings</string>
 	<key>NSCalendarsUsageDescription</key>
 	<key>NSCalendarsUsageDescription</key>
 	<string>Loop Follow would like to access your calendar to save BG readings</string>
 	<string>Loop Follow would like to access your calendar to save BG readings</string>
+	<key>NSContactsUsageDescription</key>
+	<string>This app requires access to contacts to update a contact image with real-time blood glucose information.</string>
 	<key>NSFaceIDUsageDescription</key>
 	<key>NSFaceIDUsageDescription</key>
 	<string>This app requires Face ID for secure authentication.</string>
 	<string>This app requires Face ID for secure authentication.</string>
 	<key>UIApplicationSceneManifest</key>
 	<key>UIApplicationSceneManifest</key>
@@ -72,6 +80,7 @@
 	<array>
 	<array>
 		<string>audio</string>
 		<string>audio</string>
 		<string>processing</string>
 		<string>processing</string>
+		<string>bluetooth-central</string>
 	</array>
 	</array>
 	<key>UILaunchStoryboardName</key>
 	<key>UILaunchStoryboardName</key>
 	<string>LaunchScreen</string>
 	<string>LaunchScreen</string>
@@ -95,8 +104,6 @@
 	<array>
 	<array>
 		<string>UIInterfaceOrientationPortrait</string>
 		<string>UIInterfaceOrientationPortrait</string>
 	</array>
 	</array>
-	<key>NSContactsUsageDescription</key>
-	<string>This app requires access to contacts to update a contact image with real-time blood glucose information.</string>
 	<key>UISupportedInterfaceOrientations~ipad</key>
 	<key>UISupportedInterfaceOrientations~ipad</key>
 	<array>
 	<array>
 		<string>UIInterfaceOrientationPortrait</string>
 		<string>UIInterfaceOrientationPortrait</string>
@@ -104,5 +111,9 @@
 		<string>UIInterfaceOrientationLandscapeLeft</string>
 		<string>UIInterfaceOrientationLandscapeLeft</string>
 		<string>UIInterfaceOrientationLandscapeRight</string>
 		<string>UIInterfaceOrientationLandscapeRight</string>
 	</array>
 	</array>
+    <key>UIFileSharingEnabled</key>
+    <true/>
+    <key>LSSupportsOpeningDocumentsInPlace</key>
+    <true/>
 </dict>
 </dict>
 </plist>
 </plist>

+ 14 - 0
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
+}

+ 92 - 0
LoopFollow/Log/LogManager.swift

@@ -0,0 +1,92 @@
+//
+//  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"
+    }
+
+    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) {
+        let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
+        let logMessage = "[\(timestamp)] [\(category.rawValue)] \(message)"
+
+        consoleQueue.async {
+            print(logMessage)
+        }
+
+        let logFileURL = currentLogFileURL
+        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)")
+        }
+    }
+
+    var currentLogFileURL: URL {
+        let today = dateFormatter.string(from: Date())
+        return logDirectory.appendingPathComponent("\(today).log")
+    }
+
+    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)")
+        }
+    }
+}

+ 54 - 0
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()
+            }
+        }
+    }
+}

+ 97 - 0
LoopFollow/Log/LogViewModel.swift

@@ -0,0 +1,97 @@
+//
+//  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<AnyCancellable>()
+
+    init() {
+        Publishers.CombineLatest($selectedCategory, $searchText)
+            .sink { [weak self] category, search in
+                self?.filterLogs(category: category, searchText: search)
+            }
+            .store(in: &cancellables)
+
+        loadLogEntries()
+
+        // Optional: Set up a timer to refresh logs periodically
+        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
+            if let category = category {
+                let categoryTag = "[\(category.rawValue)]"
+                filtered = filtered.filter { $0.text.contains(categoryTag) }
+            }
+
+            // Filter by search text
+            if !searchText.isEmpty {
+                filtered = filtered.filter { $0.text.localizedCaseInsensitiveContains(searchText) }
+            }
+
+            DispatchQueue.main.async {
+                self.filteredLogEntries = filtered
+            }
+        }
+    }
+}

+ 47 - 0
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<String>) {
+            _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<SearchBar>) -> 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<SearchBar>) {
+        uiView.text = text
+    }
+}

+ 2 - 0
LoopFollow/Loop Follow.entitlements

@@ -4,6 +4,8 @@
 <dict>
 <dict>
 	<key>com.apple.security.app-sandbox</key>
 	<key>com.apple.security.app-sandbox</key>
 	<true/>
 	<true/>
+	<key>com.apple.security.device.bluetooth</key>
+	<true/>
 	<key>com.apple.security.network.client</key>
 	<key>com.apple.security.network.client</key>
 	<true/>
 	<true/>
 	<key>com.apple.security.personal-information.calendars</key>
 	<key>com.apple.security.personal-information.calendars</key>

+ 5 - 0
LoopFollow/Storage/Storage.swift

@@ -31,6 +31,11 @@ class Storage {
     var cachedJWT = StorageValue<String?>(key: "cachedJWT", defaultValue: nil)
     var cachedJWT = StorageValue<String?>(key: "cachedJWT", defaultValue: nil)
     var jwtExpirationDate = StorageValue<Date?>(key: "jwtExpirationDate", defaultValue: nil)
     var jwtExpirationDate = StorageValue<Date?>(key: "jwtExpirationDate", defaultValue: nil)
 
 
+    var backgroundRefreshType = StorageValue<BackgroundRefreshType>(key: "backgroundRefreshType", defaultValue: .silentTune)
+
+    var selectedBLEDevice = StorageValue<BLEDevice?>(key: "selectedBLEDevice", defaultValue: nil)
+
+
     static let shared = Storage()
     static let shared = Storage()
 
 
     private init() { }
     private init() { }

+ 3 - 1
LoopFollow/Storage/UserDefaults.swift

@@ -121,8 +121,10 @@ class UserDefaultsRepository {
     static let speakHighBG = UserDefaultsValue<Bool>(key: "speakHighBG", default: false)
     static let speakHighBG = UserDefaultsValue<Bool>(key: "speakHighBG", default: false)
     static let speakLanguage = UserDefaultsValue<String>(key: "speakLanguage", default: "en")
     static let speakLanguage = UserDefaultsValue<String>(key: "speakLanguage", default: "en")
     static let showDisplayName = UserDefaultsValue<Bool>(key: "showDisplayName", default: false)
     static let showDisplayName = UserDefaultsValue<Bool>(key: "showDisplayName", default: false)
-    static let backgroundRefreshFrequency = UserDefaultsValue<Double>(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<Bool>(key: "backgroundRefresh", default: true)
     static let backgroundRefresh = UserDefaultsValue<Bool>(key: "backgroundRefresh", default: true)
+
     static let appBadge = UserDefaultsValue<Bool>(key: "appBadge", default: true)
     static let appBadge = UserDefaultsValue<Bool>(key: "appBadge", default: true)
     static let dimScreenWhenIdle = UserDefaultsValue<Int>(key: "dimScreenWhenIdle", default: 0)
     static let dimScreenWhenIdle = UserDefaultsValue<Int>(key: "dimScreenWhenIdle", default: 0)
     static let forceDarkMode = UserDefaultsValue<Bool>(key: "forceDarkMode", default: true)
     static let forceDarkMode = UserDefaultsValue<Bool>(key: "forceDarkMode", default: true)

+ 35 - 0
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))
+        }
+    }
+}

+ 47 - 0
LoopFollow/Task/BGTask.swift

@@ -0,0 +1,47 @@
+//
+//  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() {
+        //This pushes forward the warning notification about the app is not active
+        BackgroundAlertManager.shared.scheduleBackgroundAlert()
+
+        // 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()
+        }
+    }
+}

+ 29 - 0
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))
+    }
+}

+ 29 - 0
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()
+    }
+}

+ 95 - 0
LoopFollow/Task/MinAgoTask.swift

@@ -0,0 +1,95 @@
+//
+//  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 {
+            nextUpdateInterval = 1.0 // Update every second when showing seconds
+        } else if secondsAgo >= 720 {
+            nextUpdateInterval = 60.0 // Update every minute when showing minutes only
+        } else {
+            // Schedule exactly at the transition point to start showing seconds
+            nextUpdateInterval = 270.0 - secondsAgo
+        }
+
+        // Ensure the nextUpdateInterval is not negative or too small
+        let safeNextInterval = max(nextUpdateInterval, 1.0)
+
+        TaskScheduler.shared.rescheduleTask(id: .minAgoUpdate, to: Date().addingTimeInterval(safeNextInterval))
+    }
+}

+ 31 - 0
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))
+    }
+}

+ 22 - 0
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()
+    }
+}

+ 142 - 0
LoopFollow/Task/TaskScheduler.swift

@@ -0,0 +1,142 @@
+//
+//  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 {
+    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)): nextRun = \(timeString)")
+            
+            self.tasks[id] = ScheduledTask(nextRun: nextRun, action: action)
+            self.rescheduleTimer()
+        }
+    }
+
+    func rescheduleTask(id: TaskID, to newRunDate: Date) {
+        queue.async {
+            guard var existingTask = self.tasks[id] else {
+                return
+            }
+            //let timeString = self.formatTime(newRunDate)
+            //LogManager.shared.log(category: .taskScheduler, message: "Reschedule Task \(id), nextRun = \(timeString)")
+            existingTask.nextRun = newRunDate
+            self.tasks[id] = existingTask
+            self.rescheduleTimer()
+        }
+    }
+
+    func checkTasksNow() {
+        queue.async {
+            LogManager.shared.log(category: .taskScheduler, message: "CheckTasksNow, Forcing immediate check.")
+            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)
+
+        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() {
+        let now = Date()
+        for (id, task) in tasks {
+            if task.nextRun <= now {
+
+//                let scheduledTimeString = formatTime(task.nextRun)
+//                let diffSeconds = Int(now.timeIntervalSince(task.nextRun))
+//                let diffInfo = diffSeconds > 0 ? "\(diffSeconds)s late" : "\(abs(diffSeconds))s early"
+//                LogManager.shared.log(category: .taskScheduler, message: "Executing \(id) scheduled for \(scheduledTimeString) (\(diffInfo))")
+
+                var updatedTask = task
+                updatedTask.nextRun = .distantFuture
+                tasks[id] = updatedTask
+
+                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)
+    }
+}
+
+private extension TaskID {
+    var description: String {
+        switch self {
+        case .profile: return "profile"
+        case .deviceStatus: return "deviceStatus"
+        case .fetchBG: return "fetchBG"
+        case .treatments: return "treatments"
+        case .calendarWrite: return "calendarWrite"
+        case .minAgoUpdate: return "minAgoUpdate"
+        case .alarmCheck: return "alarmCheck"
+        }
+    }
+}

+ 31 - 0
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))
+    }
+}

+ 0 - 8
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
         <<< SwitchRow("persistentNotification") { row in
         row.title = "Persistent Notification"
         row.title = "Persistent Notification"
         row.value = UserDefaultsRepository.persistentNotification.value
         row.value = UserDefaultsRepository.persistentNotification.value

+ 39 - 38
LoopFollow/ViewControllers/MainViewController.swift

@@ -12,6 +12,7 @@ import EventKit
 import ShareClient
 import ShareClient
 import UserNotifications
 import UserNotifications
 import AVFAudio
 import AVFAudio
+import CoreBluetooth
 
 
 func IsNightscoutEnabled() -> Bool {
 func IsNightscoutEnabled() -> Bool {
     return !ObservableUserDefaults.shared.url.value.isEmpty
     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
     // check every 30 Seconds whether new bgvalues should be retrieved
     let timeInterval: TimeInterval = 30.0
     let timeInterval: TimeInterval = 30.0
     
     
-    // Min Ago Timer
-    var minAgoTimer = Timer()
-    var minAgoTimeInterval: TimeInterval = 1.0
-    
     // Check Alarms Timer
     // Check Alarms Timer
     // Don't check within 1 minute of alarm triggering to give the snoozer time to save data
     // Don't check within 1 minute of alarm triggering to give the snoozer time to save data
     var checkAlarmTimer = Timer()
     var checkAlarmTimer = Timer()
     var checkAlarmInterval: TimeInterval = 60.0
     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 graphNowTimer = Timer()
 
 
+    var lastCalendarWriteAttemptTime: TimeInterval = 0
+
     // Info Table Setup
     // Info Table Setup
     var infoManager: InfoManager!
     var infoManager: InfoManager!
     var profileManager = ProfileManager.shared
     var profileManager = ProfileManager.shared
@@ -157,6 +147,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     override func viewDidLoad() {
     override func viewDidLoad() {
         super.viewDidLoad()
         super.viewDidLoad()
 
 
+        //Migration of UserDefaultsRepository -> Storage handling
+        if !UserDefaultsRepository.backgroundRefresh.value {
+            Storage.shared.backgroundRefreshType.value = .none
+            UserDefaultsRepository.backgroundRefresh.value = true
+        }
+
         // Synchronize info types to ensure arrays are the correct size
         // Synchronize info types to ensure arrays are the correct size
         UserDefaultsRepository.synchronizeInfoTypes()
         UserDefaultsRepository.synchronizeInfoTypes()
 
 
@@ -209,9 +205,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         // setup display for NS vs Dex
         // setup display for NS vs Dex
         showHideNSDetails()
         showHideNSDetails()
         
         
-        // Load Startup Data
-        restartAllTimers()
-        
+        scheduleAllTasks()
+
         // Set up refreshScrollView for BGText
         // Set up refreshScrollView for BGText
         refreshScrollView = UIScrollView()
         refreshScrollView = UIScrollView()
         refreshScrollView.translatesAutoresizingMaskIntoConstraints = false
         refreshScrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -233,7 +228,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         refreshScrollView.alwaysBounceVertical = true
         refreshScrollView.alwaysBounceVertical = true
         
         
         refreshScrollView.delegate = self
         refreshScrollView.delegate = self
-        
         NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil)
         NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil)
     }
     }
     
     
@@ -243,7 +237,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     
     
     // Clean all timers and start new ones when refreshing
     // Clean all timers and start new ones when refreshing
     @objc func refresh() {
     @objc func refresh() {
-        print("Refreshing")
+        LogManager.shared.log(category: .general, message: "Refreshing")
 
 
         // Clear prediction for both Loop or OpenAPS
         // Clear prediction for both Loop or OpenAPS
 
 
@@ -269,8 +263,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         }
         }
 
 
         MinAgoText.text = "Refreshing"
         MinAgoText.text = "Refreshing"
-        invalidateTimers()
-        restartAllTimers()
+        scheduleAllTasks()
+
         currentCage = nil
         currentCage = nil
         currentSage = nil
         currentSage = nil
         currentIage = nil
         currentIage = nil
@@ -387,26 +381,29 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         // We want to always come back to the home screen
         // We want to always come back to the home screen
         tabBarController?.selectedIndex = 0
         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()
             backgroundTask.startBackgroundTask()
         }
         }
-        
+
+        if Storage.shared.backgroundRefreshType.value != .none {
+            BackgroundAlertManager.shared.startBackgroundAlert()
+        }
     }
     }
     
     
     @objc func appCameToForeground() {
     @objc func appCameToForeground() {
         // reset screenlock state if needed
         // reset screenlock state if needed
         UIApplication.shared.isIdleTimerDisabled = UserDefaultsRepository.screenlockSwitchState.value;
         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()
             backgroundTask.stopBackgroundTask()
+        }
+        
+        if Storage.shared.backgroundRefreshType.value != .none {
             BackgroundAlertManager.shared.stopBackgroundAlert()
             BackgroundAlertManager.shared.stopBackgroundAlert()
         }
         }
+
+        TaskScheduler.shared.checkTasksNow()
         
         
-        restartAllTimers()
         checkAndNotifyVersionStatus()
         checkAndNotifyVersionStatus()
         checkAppExpirationStatus()
         checkAppExpirationStatus()
     }
     }
@@ -558,15 +555,19 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
 
 
     func processCalendarUpdates() {
     func processCalendarUpdates() {
         if UserDefaultsRepository.calendarIdentifier.value == "" { return }
         if UserDefaultsRepository.calendarIdentifier.value == "" { return }
-        
+
         if self.bgData.count < 1 { 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
         // 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
         // Create Event info
         var deltaBG = 0 // protect index out of bounds
         var deltaBG = 0 // protect index out of bounds
         if self.bgData.count > 1 {
         if self.bgData.count > 1 {
@@ -644,9 +645,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         event.calendar = self.store.calendar(withIdentifier: UserDefaultsRepository.calendarIdentifier.value)
         event.calendar = self.store.calendar(withIdentifier: UserDefaultsRepository.calendarIdentifier.value)
         do {
         do {
             try self.store.save(event, span: .thisEvent, commit: true)
             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
             self.lastCalDate = self.bgData[self.bgData.count - 1].date
             //if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Calendar Write: " + eventTitle) }
             //if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Calendar Write: " + eventTitle) }
             //UserDefaultsRepository.savedEventID.value = event.eventIdentifier //save event id to access this particular event later
             //UserDefaultsRepository.savedEventID.value = event.eventIdentifier //save event id to access this particular event later
@@ -665,7 +665,8 @@ 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")
             snoozer.sendNotification(self, bgVal: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)), directionVal: latestDirectionString, deltaVal: Localizer.toDisplayUnits(String(latestDeltaString)), minAgoVal: latestMinAgoString, alertLabelVal: "Latest BG")
         }
         }
     }
     }
-    
+
+    @available(*, deprecated, message: "Use LogManager instead.")
     func writeDebugLog(value: String) {
     func writeDebugLog(value: String) {
         DispatchQueue.main.async {
         DispatchQueue.main.async {
             var logText = "\n" + dateTimeUtils.printNow() + " - " + value
             var logText = "\n" + dateTimeUtils.printNow() + " - " + value

+ 72 - 1
LoopFollow/ViewControllers/SettingsViewController.swift

@@ -206,6 +206,17 @@ class SettingsViewController: FormViewController {
 
 
         +++ Section("App Settings")
         +++ Section("App Settings")
 
 
+        <<< ButtonRow("backgroundRefreshSettings") {
+            $0.title = "Background Refresh Settings"
+            $0.presentationMode = .show(
+                controllerProvider: .callback(builder: {
+                    self.presentBackgroundRefreshSettings()
+                    return UIViewController()
+                }),
+                onDismiss: nil
+            )
+        }
+
         <<< ButtonRow() {
         <<< ButtonRow() {
             $0.title = "General Settings"
             $0.title = "General Settings"
             $0.presentationMode = .show(
             $0.presentationMode = .show(
@@ -290,6 +301,26 @@ class SettingsViewController: FormViewController {
 
 
         }
         }
 
 
+        +++ 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")
         +++ Section("Build Information")
         <<< LabelRow() {
         <<< LabelRow() {
             $0.title = "Version"
             $0.title = "Version"
@@ -425,7 +456,6 @@ class SettingsViewController: FormViewController {
         present(hostingController, animated: true, completion: nil)
         present(hostingController, animated: true, completion: nil)
     }
     }
 
 
-
     func presentContactSettings() {
     func presentContactSettings() {
         let viewModel = ContactSettingsViewModel()
         let viewModel = ContactSettingsViewModel()
         let contactSettingsView = ContactSettingsView(viewModel: viewModel)
         let contactSettingsView = ContactSettingsView(viewModel: viewModel)
@@ -438,4 +468,45 @@ class SettingsViewController: FormViewController {
 
 
         present(hostingController, animated: true, completion: nil)
         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)
+    }
+
+    private func shareLogs() {
+        let logManager = LogManager.shared
+        let logFileURL = logManager.currentLogFileURL
+
+        if FileManager.default.fileExists(atPath: logFileURL.path) {
+            let activityViewController = UIActivityViewController(activityItems: [logFileURL], 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)
+        }
+    }
 }
 }