Просмотр исходного кода

Resolved conflict in ContactImageUpdater.swift by keeping mike branch changes

Jonas Björkert 1 год назад
Родитель
Сommit
cf5e01bf2b
93 измененных файлов с 3949 добавлено и 1356 удалено
  1. 1 5
      .github/workflows/add_identifiers.yml
  2. 2 2
      .github/workflows/build_LoopFollow.yml
  3. 1 5
      .github/workflows/create_certs.yml
  4. 3 3
      .github/workflows/validate_secrets.yml
  5. 1 1
      Config.xcconfig
  6. 21 17
      Gemfile.lock
  7. 226 14
      LoopFollow.xcodeproj/project.pbxproj
  8. 13 16
      LoopFollow/Application/AppDelegate.swift
  9. 0 1
      LoopFollow/Application/SceneDelegate.swift
  10. 35 0
      LoopFollow/BackgroundRefresh/BT/BLEDevice.swift
  11. 55 0
      LoopFollow/BackgroundRefresh/BT/BLEDeviceSelectionView.swift
  12. 215 0
      LoopFollow/BackgroundRefresh/BT/BLEManager.swift
  13. 327 0
      LoopFollow/BackgroundRefresh/BT/BluetoothDevice.swift
  14. 18 0
      LoopFollow/BackgroundRefresh/BT/BluetoothDeviceDelegate.swift
  15. 38 0
      LoopFollow/BackgroundRefresh/BT/Devices/DexcomHeartbeatBluetoothDevice.swift
  16. 47 0
      LoopFollow/BackgroundRefresh/BT/Devices/RileyLinkHeartbeatBluetoothDevice.swift
  17. 37 0
      LoopFollow/BackgroundRefresh/BT/DexcomG7HeartBeat.swift
  18. 189 0
      LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift
  19. 42 0
      LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift
  20. 47 0
      LoopFollow/BackgroundRefresh/BackgroundRefreshType.swift
  21. 17 6
      LoopFollow/Controllers/AlarmSound.swift
  22. 40 22
      LoopFollow/Controllers/Alarms.swift
  23. 0 2
      LoopFollow/Controllers/AppStateController.swift
  24. 105 23
      LoopFollow/Controllers/BackgroundAlertManager.swift
  25. 2 9
      LoopFollow/Controllers/Graphs.swift
  26. 54 75
      LoopFollow/Controllers/Nightscout/BGData.swift
  27. 1 2
      LoopFollow/Controllers/Nightscout/CAge.swift
  28. 72 66
      LoopFollow/Controllers/Nightscout/DeviceStatus.swift
  29. 7 12
      LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
  30. 196 176
      LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift
  31. 1 1
      LoopFollow/Controllers/Nightscout/IAge.swift
  32. 30 9
      LoopFollow/Controllers/Nightscout/NSProfile.swift
  33. 1 3
      LoopFollow/Controllers/Nightscout/Profile.swift
  34. 19 14
      LoopFollow/Controllers/Nightscout/ProfileManager.swift
  35. 1 3
      LoopFollow/Controllers/Nightscout/SAge.swift
  36. 2 3
      LoopFollow/Controllers/Nightscout/Treatments.swift
  37. 0 2
      LoopFollow/Controllers/Nightscout/Treatments/BGCheck.swift
  38. 96 149
      LoopFollow/Controllers/Nightscout/Treatments/Basals.swift
  39. 0 1
      LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift
  40. 0 2
      LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift
  41. 0 1
      LoopFollow/Controllers/Nightscout/Treatments/Notes.swift
  42. 6 5
      LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift
  43. 0 1
      LoopFollow/Controllers/Nightscout/Treatments/ResumePump.swift
  44. 0 1
      LoopFollow/Controllers/Nightscout/Treatments/SuspendPump.swift
  45. 2 248
      LoopFollow/Controllers/Timers.swift
  46. 44 0
      LoopFollow/Dexcom/DexcomSettingsView.swift
  47. 37 0
      LoopFollow/Dexcom/DexcomSettingsViewModel.swift
  48. 5 2
      LoopFollow/Helpers/BackgroundTaskAudio.swift
  49. 101 29
      LoopFollow/Helpers/NightscoutUtils.swift
  50. 13 2
      LoopFollow/Info.plist
  51. 14 0
      LoopFollow/Log/LogEntry.swift
  52. 107 0
      LoopFollow/Log/LogManager.swift
  53. 54 0
      LoopFollow/Log/LogView.swift
  54. 103 0
      LoopFollow/Log/LogViewModel.swift
  55. 47 0
      LoopFollow/Log/SearchBar.swift
  56. 2 0
      LoopFollow/Loop Follow.entitlements
  57. 70 0
      LoopFollow/Nightscout/NightscoutSettingsView.swift
  58. 148 0
      LoopFollow/Nightscout/NightscoutSettingsViewModel.swift
  59. 38 0
      LoopFollow/Remote/Loop/LoopNightscoutRemoteView.swift
  60. 226 0
      LoopFollow/Remote/Loop/LoopOverrideView.swift
  61. 82 0
      LoopFollow/Remote/Loop/LoopOverrideViewModel.swift
  62. 1 1
      LoopFollow/Remote/TrioNightscoutRemoteView.swift
  63. 23 0
      LoopFollow/Remote/NoRemoteView.swift
  64. 33 11
      LoopFollow/Remote/RemoteViewController.swift
  65. 46 38
      LoopFollow/Remote/Settings/RemoteSettingsView.swift
  66. 8 0
      LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift
  67. 0 0
      LoopFollow/Remote/TRC/PushMessage.swift
  68. 6 6
      LoopFollow/Remote/PushNotificationManager.swift
  69. 0 0
      LoopFollow/Remote/TRC/TreatmentResponse.swift
  70. 1 1
      LoopFollow/Remote/TrioNightscoutRemoteController.swift.swift
  71. 0 0
      LoopFollow/Remote/TRC/TrioRemoteControlView.swift
  72. 0 0
      LoopFollow/Remote/TRC/TrioRemoteControlViewModel.swift
  73. 45 0
      LoopFollow/Settings/AdvancedSettingsView.swift
  74. 62 0
      LoopFollow/Settings/AdvancedSettingsViewModel.swift
  75. 1 0
      LoopFollow/Storage/Observable.swift
  76. 1 0
      LoopFollow/Storage/ObservableUserDefaults.swift
  77. 7 0
      LoopFollow/Storage/Storage.swift
  78. 5 7
      LoopFollow/Storage/UserDefaults.swift
  79. 35 0
      LoopFollow/Task/AlarmTask.swift
  80. 44 0
      LoopFollow/Task/BGTask.swift
  81. 29 0
      LoopFollow/Task/CalendarTask.swift
  82. 29 0
      LoopFollow/Task/DeviceStatusTask.swift
  83. 98 0
      LoopFollow/Task/MinAgoTask.swift
  84. 31 0
      LoopFollow/Task/ProfileTask.swift
  85. 22 0
      LoopFollow/Task/Task.swift
  86. 141 0
      LoopFollow/Task/TaskScheduler.swift
  87. 31 0
      LoopFollow/Task/TreatmentsTask.swift
  88. 0 99
      LoopFollow/ViewControllers/AdvancedSettingsViewController.swift
  89. 4 4
      LoopFollow/ViewControllers/AlarmViewController.swift
  90. 0 8
      LoopFollow/ViewControllers/GeneralSettingsViewController.swift
  91. 50 66
      LoopFollow/ViewControllers/MainViewController.swift
  92. 134 181
      LoopFollow/ViewControllers/SettingsViewController.swift
  93. 1 1
      LoopFollow/ViewControllers/SnoozeViewController.swift

+ 1 - 5
.github/workflows/add_identifiers.yml

@@ -11,12 +11,8 @@ jobs:
 
   identifiers:
     needs: validate
-    runs-on: macos-14
+    runs-on: macos-15
     steps:
-      # Uncomment to manually select latest Xcode if needed
-      #- name: Select Latest Xcode
-      #  run: "sudo xcode-select --switch /Applications/Xcode_13.0.app/Contents/Developer"
-      
       # Checks-out the repo
       - name: Checkout Repo
         uses: actions/checkout@v4

+ 2 - 2
.github/workflows/build_LoopFollow.yml

@@ -20,11 +20,11 @@ jobs:
   build:
     name: Build
     needs: validate
-    runs-on: macos-14
+    runs-on: macos-15
     steps:
       # Uncomment to manually select latest Xcode if needed
       - name: Select Latest Xcode
-        run: "sudo xcode-select --switch /Applications/Xcode_15.4.app/Contents/Developer"
+        run: "sudo xcode-select --switch /Applications/Xcode_16.2.app/Contents/Developer"
 
       # Checks-out the repo
       - name: Checkout Repo

+ 1 - 5
.github/workflows/create_certs.yml

@@ -12,12 +12,8 @@ jobs:
   certificates:
     name: Create Certificates
     needs: validate
-    runs-on: macos-14
+    runs-on: macos-15
     steps:
-      # Uncomment to manually select latest Xcode if needed
-      #- name: Select Latest Xcode
-      #  run: "sudo xcode-select --switch /Applications/Xcode_13.0.app/Contents/Developer"
-      
       # Checks-out the repo
       - name: Checkout Repo
         uses: actions/checkout@v4

+ 3 - 3
.github/workflows/validate_secrets.yml

@@ -5,7 +5,7 @@ on: [workflow_call, workflow_dispatch]
 jobs:
   validate-access-token:
     name: Access
-    runs-on: macos-14
+    runs-on: macos-15
     env:
       GH_PAT: ${{ secrets.GH_PAT }}
       GH_TOKEN: ${{ secrets.GH_PAT }}
@@ -74,7 +74,7 @@ jobs:
   validate-match-secrets:
     name: Match-Secrets
     needs: validate-access-token
-    runs-on: macos-14
+    runs-on: macos-15
     env:
       GH_TOKEN: ${{ secrets.GH_PAT }}
     steps:
@@ -112,7 +112,7 @@ jobs:
   validate-fastlane-secrets:
     name: Fastlane
     needs: [validate-access-token, validate-match-secrets]
-    runs-on: macos-14
+    runs-on: macos-15
     env:
       GH_PAT: ${{ secrets.GH_PAT }}
       GH_TOKEN: ${{ secrets.GH_PAT }}

+ 1 - 1
Config.xcconfig

@@ -6,4 +6,4 @@
 unique_id = ${DEVELOPMENT_TEAM}
 
 //Version (DEFAULT)
-LOOP_FOLLOW_MARKETING_VERSION = 2.2.8
+LOOP_FOLLOW_MARKETING_VERSION = 2.2.10

+ 21 - 17
Gemfile.lock

@@ -10,20 +10,20 @@ GEM
     artifactory (3.0.17)
     atomos (0.1.3)
     aws-eventstream (1.3.0)
-    aws-partitions (1.981.0)
-    aws-sdk-core (3.209.1)
+    aws-partitions (1.1007.0)
+    aws-sdk-core (3.213.0)
       aws-eventstream (~> 1, >= 1.3.0)
-      aws-partitions (~> 1, >= 1.651.0)
+      aws-partitions (~> 1, >= 1.992.0)
       aws-sigv4 (~> 1.9)
       jmespath (~> 1, >= 1.6.1)
-    aws-sdk-kms (1.94.0)
-      aws-sdk-core (~> 3, >= 3.207.0)
+    aws-sdk-kms (1.95.0)
+      aws-sdk-core (~> 3, >= 3.210.0)
       aws-sigv4 (~> 1.5)
-    aws-sdk-s3 (1.166.0)
-      aws-sdk-core (~> 3, >= 3.207.0)
+    aws-sdk-s3 (1.171.0)
+      aws-sdk-core (~> 3, >= 3.210.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.5)
-    aws-sigv4 (1.10.0)
+    aws-sigv4 (1.10.1)
       aws-eventstream (~> 1, >= 1.0.2)
     babosa (1.0.4)
     base64 (0.2.0)
@@ -69,7 +69,7 @@ GEM
     faraday_middleware (1.2.1)
       faraday (~> 1.0)
     fastimage (2.3.1)
-    fastlane (2.223.1)
+    fastlane (2.225.0)
       CFPropertyList (>= 2.3, < 4.0.0)
       addressable (>= 2.8, < 3.0.0)
       artifactory (~> 3.0)
@@ -85,6 +85,7 @@ GEM
       faraday-cookie_jar (~> 0.0.6)
       faraday_middleware (~> 1.0)
       fastimage (>= 2.1.0, < 3.0.0)
+      fastlane-sirp (>= 1.0.0)
       gh_inspector (>= 1.1.2, < 2.0.0)
       google-apis-androidpublisher_v3 (~> 0.3)
       google-apis-playcustomapp_v1 (~> 0.1)
@@ -110,6 +111,8 @@ GEM
       xcodeproj (>= 1.13.0, < 2.0.0)
       xcpretty (~> 0.3.0)
       xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
+    fastlane-sirp (1.0.0)
+      sysrandom (~> 1.0)
     gh_inspector (1.1.3)
     google-apis-androidpublisher_v3 (0.54.0)
       google-apis-core (>= 0.11.0, < 2.a)
@@ -152,17 +155,17 @@ GEM
       domain_name (~> 0.5)
     httpclient (2.8.3)
     jmespath (1.6.2)
-    json (2.7.2)
-    jwt (2.9.1)
+    json (2.7.6)
+    jwt (2.9.3)
       base64
     mini_magick (4.13.2)
     mini_mime (1.1.5)
     multi_json (1.15.0)
     multipart-post (2.4.1)
-    nanaimo (0.3.0)
+    nanaimo (0.4.0)
     naturally (2.2.1)
     nkf (0.2.0)
-    optparse (0.5.0)
+    optparse (0.6.0)
     os (1.1.4)
     plist (3.7.1)
     public_suffix (5.1.1)
@@ -172,7 +175,7 @@ GEM
       trailblazer-option (>= 0.1.1, < 0.2.0)
       uber (< 0.2.0)
     retriable (3.1.2)
-    rexml (3.3.7)
+    rexml (3.3.9)
     rouge (2.0.7)
     ruby2_keywords (0.0.5)
     rubyzip (2.3.2)
@@ -185,6 +188,7 @@ GEM
     simctl (1.6.10)
       CFPropertyList
       naturally
+    sysrandom (1.0.5)
     terminal-notifier (2.0.0)
     terminal-table (3.0.2)
       unicode-display_width (>= 1.1.1, < 3)
@@ -197,13 +201,13 @@ GEM
     unf (0.2.0)
     unicode-display_width (2.6.0)
     word_wrap (1.0.0)
-    xcodeproj (1.25.0)
+    xcodeproj (1.27.0)
       CFPropertyList (>= 2.3.3, < 4.0)
       atomos (~> 0.1.3)
       claide (>= 1.0.2, < 2.0)
       colored2 (~> 3.1)
-      nanaimo (~> 0.3.0)
-      rexml (>= 3.3.2, < 4.0)
+      nanaimo (~> 0.4.0)
+      rexml (>= 3.3.6, < 4.0)
     xcpretty (0.3.0)
       rouge (~> 2.0.7)
     xcpretty-travis-formatter (1.0.1)

+ 226 - 14
LoopFollow.xcodeproj/project.pbxproj

@@ -24,6 +24,12 @@
 		DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; };
 		DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; };
 		DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF102C997B4600FB655A /* LoadingButtonView.swift */; };
+		DD1A97142D4294A5000DDC11 /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1A97132D4294A4000DDC11 /* AdvancedSettingsView.swift */; };
+		DD1A97162D4294B3000DDC11 /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1A97152D4294B2000DDC11 /* AdvancedSettingsViewModel.swift */; };
+		DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; };
+		DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; };
+		DD2C2E542D3C37DC006413A5 /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */; };
+		DD2C2E562D3C3917006413A5 /* DexcomSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */; };
 		DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; };
 		DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; };
 		DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; };
@@ -33,7 +39,7 @@
 		DD4878132C7B750D0048F05C /* TempTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878122C7B750D0048F05C /* TempTargetView.swift */; };
 		DD4878152C7B75230048F05C /* MealView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878142C7B75230048F05C /* MealView.swift */; };
 		DD4878172C7B75350048F05C /* BolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878162C7B75350048F05C /* BolusView.swift */; };
-		DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift.swift */; };
+		DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */; };
 		DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */ = {isa = PBXBuildFile; productRef = DD48781B2C7DAF140048F05C /* SwiftJWT */; };
 		DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */; };
 		DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781F2C7DAF890048F05C /* PushMessage.swift */; };
@@ -55,6 +61,11 @@
 		DD5334232C60ED3600062F9D /* IAge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334222C60ED3600062F9D /* IAge.swift */; };
 		DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */; };
 		DD5334292C6166A500062F9D /* InfoDisplaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334282C6166A500062F9D /* InfoDisplaySettingsView.swift */; };
+		DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334AF2D1447C500CDD6EA /* BLEManager.swift */; };
+		DD5817172D2710E90041FB98 /* BLEDeviceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5817162D2710E50041FB98 /* BLEDeviceSelectionView.swift */; };
+		DD58171A2D299EF80041FB98 /* DexcomHeartbeatBluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5817192D299EF40041FB98 /* DexcomHeartbeatBluetoothDevice.swift */; };
+		DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD58171B2D299F8D0041FB98 /* BluetoothDevice.swift */; };
+		DD58171E2D299FCA0041FB98 /* BluetoothDeviceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD58171D2D299FC50041FB98 /* BluetoothDeviceDelegate.swift */; };
 		DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */; };
 		DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; };
 		DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; };
@@ -66,6 +77,18 @@
 		DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */; };
 		DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; };
 		DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */; };
+		DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA032D32821200415D8A /* DeviceStatusTask.swift */; };
+		DD9ACA062D32AF7900415D8A /* TreatmentsTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA052D32AF6E00415D8A /* TreatmentsTask.swift */; };
+		DD9ACA082D32F68B00415D8A /* BGTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA072D32F68900415D8A /* BGTask.swift */; };
+		DD9ACA0A2D33095600415D8A /* MinAgoTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA092D33095500415D8A /* MinAgoTask.swift */; };
+		DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA0B2D33BB8400415D8A /* CalendarTask.swift */; };
+		DD9ACA0E2D340BFF00415D8A /* AlarmTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA0D2D340BFE00415D8A /* AlarmTask.swift */; };
+		DD9ACA102D34129200415D8A /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA0F2D34128600415D8A /* Task.swift */; };
+		DD9ED0C82D355244000D2A63 /* LogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0C72D35523F000D2A63 /* LogViewModel.swift */; };
+		DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0C92D355256000D2A63 /* LogView.swift */; };
+		DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */; };
+		DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CD2D355879000D2A63 /* LogEntry.swift */; };
+		DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */; };
 		DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB0AF512BB1A8BE00AFA48B /* BuildDetails.swift */; };
 		DDB0AF552BB1B24A00AFA48B /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */; };
 		DDBE3ABD2CB5A961006B37DC /* OverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDBE3ABC2CB5A961006B37DC /* OverrideView.swift */; };
@@ -73,7 +96,6 @@
 		DDCF979624C1443C002C9752 /* GeneralSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979524C1443C002C9752 /* GeneralSettingsViewController.swift */; };
 		DDCF979824C1489C002C9752 /* GraphSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979724C1489C002C9752 /* GraphSettingsViewController.swift */; };
 		DDCF979A24C14DB4002C9752 /* WatchSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979924C14DB4002C9752 /* WatchSettingsViewController.swift */; };
-		DDCF979C24C14EFB002C9752 /* AdvancedSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979B24C14EFB002C9752 /* AdvancedSettingsViewController.swift */; };
 		DDCF979E24C2382A002C9752 /* AppStateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979D24C2382A002C9752 /* AppStateController.swift */; };
 		DDCFCAF22B17273200BE5751 /* LoopFollowDisplayNameConfig.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = DDCFCAF12B17273200BE5751 /* LoopFollowDisplayNameConfig.xcconfig */; };
 		DDD10EFF2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10EFE2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift */; };
@@ -82,7 +104,14 @@
 		DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F042C529DA200D76A8E /* ObservableValue.swift */; };
 		DDD10F072C529DE800D76A8E /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F062C529DE800D76A8E /* Observable.swift */; };
 		DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; };
+		DDDF6F432D479A9900884336 /* LoopNightscoutRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F422D479A9800884336 /* LoopNightscoutRemoteView.swift */; };
+		DDDF6F452D479AB100884336 /* LoopOverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F442D479AB000884336 /* LoopOverrideView.swift */; };
+		DDDF6F472D479AD200884336 /* LoopOverrideViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F462D479AD100884336 /* LoopOverrideViewModel.swift */; };
+		DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; };
 		DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE69ED12C7256260013EAEC /* RemoteType.swift */; };
+		DDEF503A2D31615000999A5D /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF50392D31614200999A5D /* LogManager.swift */; };
+		DDEF503C2D31BE2D00999A5D /* TaskScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF503B2D31BE2A00999A5D /* TaskScheduler.swift */; };
+		DDEF503F2D32754F00999A5D /* ProfileTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF503E2D32754A00999A5D /* ProfileTask.swift */; };
 		DDF2C0102BEFA991007A20E6 /* GitHubService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF2C00F2BEFA991007A20E6 /* GitHubService.swift */; };
 		DDF2C0122BEFB733007A20E6 /* AppVersionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF2C0112BEFB733007A20E6 /* AppVersionManager.swift */; };
 		DDF2C0142BEFD468007A20E6 /* blacklisted-versions.json in Resources */ = {isa = PBXBuildFile; fileRef = DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */; };
@@ -93,6 +122,10 @@
 		DDF6999E2C5AAA640058A8D9 /* ErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6999D2C5AAA640058A8D9 /* ErrorMessageView.swift */; };
 		DDF9676E2AD08C6E00C5EB95 /* SiteChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF9676D2AD08C6E00C5EB95 /* SiteChange.swift */; };
 		DDFD5C532CB167DA00D3FD68 /* TRCCommandType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFD5C522CB167DA00D3FD68 /* TRCCommandType.swift */; };
+		DDFF3D7F2D1414A200BF9D9E /* BLEDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D7E2D1414A200BF9D9E /* BLEDevice.swift */; };
+		DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */; };
+		DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */; };
+		DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */; };
 		FC16A97A24996673003D6245 /* NightScout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97924996673003D6245 /* NightScout.swift */; };
 		FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7CE589248ABEA3001F83B8 /* AlarmSound.swift */; };
 		FC16A97D24996747003D6245 /* Alarms.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97C24996747003D6245 /* Alarms.swift */; };
@@ -266,6 +299,12 @@
 		DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = "<group>"; };
 		DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = "<group>"; };
 		DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.swift; sourceTree = "<group>"; };
+		DD1A97132D4294A4000DDC11 /* AdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsView.swift; sourceTree = "<group>"; };
+		DD1A97152D4294B2000DDC11 /* AdvancedSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsViewModel.swift; sourceTree = "<group>"; };
+		DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = "<group>"; };
+		DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = "<group>"; };
+		DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = "<group>"; };
+		DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = "<group>"; };
 		DD4878022C7B297E0048F05C /* StorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageValue.swift; sourceTree = "<group>"; };
 		DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
 		DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = "<group>"; };
@@ -275,7 +314,7 @@
 		DD4878122C7B750D0048F05C /* TempTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetView.swift; sourceTree = "<group>"; };
 		DD4878142C7B75230048F05C /* MealView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealView.swift; sourceTree = "<group>"; };
 		DD4878162C7B75350048F05C /* BolusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusView.swift; sourceTree = "<group>"; };
-		DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioNightscoutRemoteController.swift.swift; sourceTree = "<group>"; };
+		DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioNightscoutRemoteController.swift; sourceTree = "<group>"; };
 		DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationManager.swift; sourceTree = "<group>"; };
 		DD48781F2C7DAF890048F05C /* PushMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessage.swift; sourceTree = "<group>"; };
 		DD493AD42ACF2109009A6922 /* ResumePump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumePump.swift; sourceTree = "<group>"; };
@@ -296,6 +335,11 @@
 		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>"; };
 		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 /* DexcomHeartbeatBluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomHeartbeatBluetoothDevice.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>"; };
 		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>"; };
@@ -307,6 +351,18 @@
 		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>"; };
 		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>"; };
 		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>"; };
@@ -315,7 +371,6 @@
 		DDCF979524C1443C002C9752 /* GeneralSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsViewController.swift; sourceTree = "<group>"; };
 		DDCF979724C1489C002C9752 /* GraphSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsViewController.swift; sourceTree = "<group>"; };
 		DDCF979924C14DB4002C9752 /* WatchSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSettingsViewController.swift; sourceTree = "<group>"; };
-		DDCF979B24C14EFB002C9752 /* AdvancedSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsViewController.swift; sourceTree = "<group>"; };
 		DDCF979D24C2382A002C9752 /* AppStateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateController.swift; sourceTree = "<group>"; };
 		DDCFCAF12B17273200BE5751 /* LoopFollowDisplayNameConfig.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = LoopFollowDisplayNameConfig.xcconfig; sourceTree = "<group>"; };
 		DDD10EFE2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableUserDefaultsValue.swift; sourceTree = "<group>"; };
@@ -324,7 +379,14 @@
 		DDD10F042C529DA200D76A8E /* ObservableValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableValue.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>"; };
+		DDDF6F422D479A9800884336 /* LoopNightscoutRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopNightscoutRemoteView.swift; sourceTree = "<group>"; };
+		DDDF6F442D479AB000884336 /* LoopOverrideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopOverrideView.swift; sourceTree = "<group>"; };
+		DDDF6F462D479AD100884336 /* LoopOverrideViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopOverrideViewModel.swift; sourceTree = "<group>"; };
+		DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.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>"; };
 		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>"; };
@@ -335,6 +397,10 @@
 		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>"; };
 		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>"; };
 		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>"; };
@@ -540,18 +606,14 @@
 		DD0C0C6E2C4AFFB800DBADDF /* Remote */ = {
 			isa = PBXGroup;
 			children = (
+				DDDF6F4A2D479B6A00884336 /* Nightscout */,
+				DDDF6F482D479AEF00884336 /* NoRemoteView.swift */,
+				DDDF6F412D479A8E00884336 /* Loop */,
 				DD4878112C7B74F90048F05C /* TRC */,
 				DD4878062C7B2E9E0048F05C /* Settings */,
 				DDF699972C5AA2E50058A8D9 /* TempTargetPreset */,
 				DD0C0C6F2C4AFFE800DBADDF /* RemoteViewController.swift */,
-				DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */,
-				DDD10F022C518A6500D76A8E /* TreatmentResponse.swift */,
 				DDE69ED12C7256260013EAEC /* RemoteType.swift */,
-				DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */,
-				DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */,
-				DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift.swift */,
-				DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */,
-				DD48781F2C7DAF890048F05C /* PushMessage.swift */,
 			);
 			path = Remote;
 			sourceTree = "<group>";
@@ -567,6 +629,33 @@
 			path = InfoTable;
 			sourceTree = "<group>";
 		};
+		DD1A97122D429495000DDC11 /* Settings */ = {
+			isa = PBXGroup;
+			children = (
+				DD1A97152D4294B2000DDC11 /* AdvancedSettingsViewModel.swift */,
+				DD1A97132D4294A4000DDC11 /* AdvancedSettingsView.swift */,
+			);
+			path = Settings;
+			sourceTree = "<group>";
+		};
+		DD2C2E4D2D3B8ACF006413A5 /* Nightscout */ = {
+			isa = PBXGroup;
+			children = (
+				DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */,
+				DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */,
+			);
+			path = Nightscout;
+			sourceTree = "<group>";
+		};
+		DD2C2E522D3C36A8006413A5 /* Dexcom */ = {
+			isa = PBXGroup;
+			children = (
+				DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */,
+				DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */,
+			);
+			path = Dexcom;
+			sourceTree = "<group>";
+		};
 		DD4878062C7B2E9E0048F05C /* Settings */ = {
 			isa = PBXGroup;
 			children = (
@@ -579,9 +668,15 @@
 		DD4878112C7B74F90048F05C /* TRC */ = {
 			isa = PBXGroup;
 			children = (
+				DD48781F2C7DAF890048F05C /* PushMessage.swift */,
 				DD4878122C7B750D0048F05C /* TempTargetView.swift */,
+				DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */,
 				DD4878142C7B75230048F05C /* MealView.swift */,
+				DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */,
+				DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */,
 				DD4878162C7B75350048F05C /* BolusView.swift */,
+				DDD10F022C518A6500D76A8E /* TreatmentResponse.swift */,
+				DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */,
 				DDFD5C522CB167DA00D3FD68 /* TRCCommandType.swift */,
 				DDBE3ABC2CB5A961006B37DC /* OverrideView.swift */,
 			);
@@ -665,6 +760,27 @@
 			path = Extensions;
 			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 /* DexcomHeartbeatBluetoothDevice.swift */,
+			);
+			path = Devices;
+			sourceTree = "<group>";
+		};
 		DDB0AF4F2BB1A81F00AFA48B /* Scripts */ = {
 			isa = PBXGroup;
 			children = (
@@ -673,6 +789,40 @@
 			path = Scripts;
 			sourceTree = "<group>";
 		};
+		DDDF6F412D479A8E00884336 /* Loop */ = {
+			isa = PBXGroup;
+			children = (
+				DDDF6F462D479AD100884336 /* LoopOverrideViewModel.swift */,
+				DDDF6F442D479AB000884336 /* LoopOverrideView.swift */,
+				DDDF6F422D479A9800884336 /* LoopNightscoutRemoteView.swift */,
+			);
+			path = Loop;
+			sourceTree = "<group>";
+		};
+		DDDF6F4A2D479B6A00884336 /* Nightscout */ = {
+			isa = PBXGroup;
+			children = (
+				DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */,
+			);
+			path = Nightscout;
+			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 */ = {
 			isa = PBXGroup;
 			children = (
@@ -692,6 +842,30 @@
 			path = Views;
 			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 */ = {
 			isa = PBXGroup;
 			children = (
@@ -856,6 +1030,12 @@
 		FC8DEEE32485D1680075863F /* LoopFollow */ = {
 			isa = PBXGroup;
 			children = (
+				DD1A97122D429495000DDC11 /* Settings */,
+				DD2C2E522D3C36A8006413A5 /* Dexcom */,
+				DD2C2E4D2D3B8ACF006413A5 /* Nightscout */,
+				DD9ED0C62D355225000D2A63 /* Log */,
+				DDEF503D2D32753A00999A5D /* Task */,
+				DDFF3D792D140F1800BF9D9E /* BackgroundRefresh */,
 				DD50C74D2D0828250057AE6F /* Contact */,
 				DD5334252C61667700062F9D /* InfoDisplaySettings */,
 				DD0C0C6E2C4AFFB800DBADDF /* Remote */,
@@ -953,7 +1133,6 @@
 				DDCF979524C1443C002C9752 /* GeneralSettingsViewController.swift */,
 				DDCF979724C1489C002C9752 /* GraphSettingsViewController.swift */,
 				DDCF979924C14DB4002C9752 /* WatchSettingsViewController.swift */,
-				DDCF979B24C14EFB002C9752 /* AdvancedSettingsViewController.swift */,
 			);
 			path = ViewControllers;
 			sourceTree = "<group>";
@@ -1221,18 +1400,25 @@
 				DD5334292C6166A500062F9D /* InfoDisplaySettingsView.swift in Sources */,
 				FCC68850248935D800A0279D /* AlarmViewController.swift in Sources */,
 				DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */,
+				DD2C2E562D3C3917006413A5 /* DexcomSettingsView.swift in Sources */,
+				DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */,
+				DD9ACA082D32F68B00415D8A /* BGTask.swift in Sources */,
+				DD9ACA102D34129200415D8A /* Task.swift in Sources */,
 				DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */,
 				FC7CE59F248D8D23001F83B8 /* SnoozeViewController.swift in Sources */,
 				DDF6999B2C5AA32E0058A8D9 /* TempTargetPreset.swift in Sources */,
+				DD9ACA0E2D340BFF00415D8A /* AlarmTask.swift in Sources */,
 				DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */,
 				FCC6886724898F8000A0279D /* UserDefaultsValue.swift in Sources */,
 				DDF699942C555B310058A8D9 /* ViewControllerManager.swift in Sources */,
 				DDCF979E24C2382A002C9752 /* AppStateController.swift in Sources */,
+				DDEF503F2D32754F00999A5D /* ProfileTask.swift in Sources */,
 				DD5334212C60EBEE00062F9D /* InsulinCartridgeChange.swift in Sources */,
 				FC97881E2485969B00A7906C /* NightScoutViewController.swift in Sources */,
 				DD608A0A2C23593900F91132 /* SMB.swift in Sources */,
 				DDCF979824C1489C002C9752 /* GraphSettingsViewController.swift in Sources */,
 				FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */,
+				DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */,
 				FCC6886F2489A53800A0279D /* AppConstants.swift in Sources */,
 				DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */,
 				DDF2C0102BEFA991007A20E6 /* GitHubService.swift in Sources */,
@@ -1242,21 +1428,28 @@
 				DDF2C0122BEFB733007A20E6 /* AppVersionManager.swift in Sources */,
 				DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */,
 				DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */,
+				DDFF3D7F2D1414A200BF9D9E /* BLEDevice.swift in Sources */,
+				DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */,
 				FC16A97D24996747003D6245 /* Alarms.swift in Sources */,
 				DDFD5C532CB167DA00D3FD68 /* TRCCommandType.swift in Sources */,
 				DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */,
+				DD5817172D2710E90041FB98 /* BLEDeviceSelectionView.swift in Sources */,
 				FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */,
 				DDBE3ABD2CB5A961006B37DC /* OverrideView.swift in Sources */,
 				DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */,
 				DD0C0C622C4175FD00DBADDF /* NSProfile.swift in Sources */,
+				DD58171E2D299FCA0041FB98 /* BluetoothDeviceDelegate.swift in Sources */,
 				DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */,
 				DDD10F032C518A6500D76A8E /* TreatmentResponse.swift in Sources */,
 				DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */,
+				DDDF6F472D479AD200884336 /* LoopOverrideViewModel.swift in Sources */,
 				DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */,
 				DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */,
+				DDEF503A2D31615000999A5D /* LogManager.swift in Sources */,
 				DD4878172C7B75350048F05C /* BolusView.swift in Sources */,
 				DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */,
 				FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
+				DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */,
 				DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */,
 				DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */,
 				DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */,
@@ -1280,9 +1473,14 @@
 				DDF9676E2AD08C6E00C5EB95 /* SiteChange.swift in Sources */,
 				DD13BC772C3FD64E0062313B /* InfoData.swift in Sources */,
 				DD13BC752C3FD6210062313B /* InfoType.swift in Sources */,
-				DDCF979C24C14EFB002C9752 /* AdvancedSettingsViewController.swift in Sources */,
+				DD1A97142D4294A5000DDC11 /* AdvancedSettingsView.swift in Sources */,
+				DD9ACA0A2D33095600415D8A /* MinAgoTask.swift in Sources */,
+				DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */,
 				DDD10EFF2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift in Sources */,
+				DD58171A2D299EF80041FB98 /* DexcomHeartbeatBluetoothDevice.swift in Sources */,
+				DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */,
 				DD50C7502D0828800057AE6F /* ContactSettingsViewModel.swift in Sources */,
+				DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */,
 				FC97881C2485969B00A7906C /* MainViewController.swift in Sources */,
 				DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */,
 				DD493AD52ACF2109009A6922 /* ResumePump.swift in Sources */,
@@ -1291,31 +1489,40 @@
 				DD493AD72ACF2139009A6922 /* SuspendPump.swift in Sources */,
 				FC9788182485969B00A7906C /* AppDelegate.swift in Sources */,
 				DDD10F072C529DE800D76A8E /* Observable.swift in Sources */,
+				DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */,
 				DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */,
 				DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */,
 				DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */,
+				DDEF503C2D31BE2D00999A5D /* TaskScheduler.swift in Sources */,
 				DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */,
 				DD493AD92ACF2171009A6922 /* Carbs.swift in Sources */,
 				DD493AE92ACF2445009A6922 /* BGData.swift in Sources */,
 				FCC6886B24898FD800A0279D /* ObservationToken.swift in Sources */,
 				DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */,
+				DD9ACA062D32AF7900415D8A /* TreatmentsTask.swift in Sources */,
 				DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */,
+				DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */,
 				DD0C0C602C415B9D00DBADDF /* ProfileManager.swift in Sources */,
 				DDF699962C5582290058A8D9 /* TextFieldWithToolBar.swift in Sources */,
 				DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */,
+				DDDF6F452D479AB100884336 /* LoopOverrideView.swift in Sources */,
 				DD4878132C7B750D0048F05C /* TempTargetView.swift in Sources */,
 				DD0C0C682C48529400DBADDF /* Metric.swift in Sources */,
 				FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */,
 				FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */,
 				DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */,
+				DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */,
 				DD7E198A2ACDA62600DBD158 /* SensorStart.swift in Sources */,
 				FCD49B6C24AA536E007879DC /* DebugViewController.swift in Sources */,
+				DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */,
 				DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */,
-				DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift.swift in Sources */,
+				DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */,
 				FC1BDD2B24A22650001B652C /* Stats.swift in Sources */,
+				DDDF6F432D479A9900884336 /* LoopNightscoutRemoteView.swift in Sources */,
 				DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */,
 				FC1BDD2D24A23204001B652C /* StatsView.swift in Sources */,
 				DD4878102C7B74BF0048F05C /* TrioRemoteControlView.swift in Sources */,
+				DD2C2E542D3C37DC006413A5 /* DexcomSettingsViewModel.swift in Sources */,
 				FCE537BC249A4D7D00F80BF8 /* carbBolusArrays.swift in Sources */,
 				DD5334232C60ED3600062F9D /* IAge.swift in Sources */,
 				FCD2A27D24C9D044009F7B7B /* Globals.swift in Sources */,
@@ -1325,7 +1532,12 @@
 				DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */,
 				DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */,
 				DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */,
+				DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */,
+				DD1A97162D4294B3000DDC11 /* AdvancedSettingsViewModel.swift in Sources */,
 				FCA2DDE62501095000254A8C /* Timers.swift in Sources */,
+				DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */,
+				DD9ED0C82D355244000D2A63 /* LogViewModel.swift in Sources */,
+				DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */,
 				DD493AE32ACF2358009A6922 /* CAge.swift in Sources */,
 				DD493ADD2ACF21E0009A6922 /* Basals.swift in Sources */,
 				FC16A98124996C07003D6245 /* DateTime.swift in Sources */,

+ 13 - 16
LoopFollow/Application/AppDelegate.swift

@@ -18,24 +18,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
     let notificationCenter = UNUserNotificationCenter.current()
 
     func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
-        // Override point for customization after application launch.
-        
+        LogManager.shared.log(category: .general, message: "App started")
+        LogManager.shared.cleanupOldLogs()
+
         let options: UNAuthorizationOptions = [.alert, .sound, .badge]
         notificationCenter.requestAuthorization(options: options) {
             (didAllow, error) in
             if !didAllow {
-                print("User has declined notifications")
+                LogManager.shared.log(category: .general, message: "User has declined notifications")
             }
         }
-        
+
         let store = EKEventStore()
         store.requestCalendarAccess { (granted, error) in
             if !granted {
-                print("Failed to get calendar access: \(String(describing: error))")
+                LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))")
                 return
             }
         }
-        
+
         let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
         let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: [])
         UNUserNotificationCenter.current().setNotificationCategories([category])
@@ -44,7 +45,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
         // Ensure ViewControllerManager is initialized
         _ = ViewControllerManager.shared
-        
+
+        _ = BLEManager.shared
+
         return true
     }
 
@@ -56,23 +59,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
     }
 
     // MARK: UISceneSession Lifecycle
-    
+
     func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
-        
-        // This application should be called in background every X Minutes
-        UIApplication.shared.setMinimumBackgroundFetchInterval(
-            TimeInterval(UserDefaultsRepository.backgroundRefreshFrequency.value * 60)
-        )
-        
         // set "prevent screen lock" to ON when the app is started for the first time
         if !UserDefaultsRepository.screenlockSwitchState.exists {
             UserDefaultsRepository.screenlockSwitchState.value = true
         }
-        
+
         // set the "prevent screen lock" option when the app is started
         // This method doesn't seem to be working anymore. Added to view controllers as solution offered on SO
         UIApplication.shared.isIdleTimerDisabled = UserDefaultsRepository.screenlockSwitchState.value
-        
+
         return true
     }
 

+ 0 - 1
LoopFollow/Application/SceneDelegate.swift

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

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

@@ -0,0 +1,35 @@
+//
+//  BLEDevice.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-02.
+//
+
+import Foundation
+
+struct BLEDevice: Identifiable, Codable, Equatable {
+    let id: UUID
+
+    var name: String?
+    var rssi: Int
+    var isConnected: Bool
+    var advertisedServices: [String]?
+    var lastSeen: Date
+    var lastConnected: Date?
+
+    init(id: UUID,
+         name: String? = nil,
+         rssi: Int,
+         isConnected: Bool = false,
+         advertisedServices: [String]? = nil,
+         lastSeen: Date = Date(),
+         lastConnected: Date? = nil) {
+        self.id = id
+        self.name = name
+        self.rssi = rssi
+        self.isConnected = isConnected
+        self.advertisedServices = advertisedServices
+        self.lastSeen = lastSeen
+        self.lastConnected = lastConnected
+    }
+}

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

@@ -0,0 +1,55 @@
+//
+//  BLEDeviceSelectionView.swift
+//  LoopFollow
+//
+
+import SwiftUI
+
+struct BLEDeviceSelectionView: View {
+    @ObservedObject var bleManager: BLEManager
+    var selectedFilter: BackgroundRefreshType
+    var onSelectDevice: (BLEDevice) -> Void
+
+    var body: some View {
+        VStack {
+            List {
+                if bleManager.devices.filter({ selectedFilter.matches($0) && !isSelected($0) }).isEmpty {
+                    Text("No devices found yet. They'll appear here when discovered.")
+                        .foregroundColor(.secondary)
+                        .multilineTextAlignment(.center)
+                        .padding()
+                } else {
+                    ForEach(bleManager.devices.filter { selectedFilter.matches($0) && !isSelected($0) }, id: \.id) { device in
+                        HStack {
+                            VStack(alignment: .leading) {
+                                Text(device.name ?? "Unknown")
+
+                                Text("RSSI: \(device.rssi) dBm")
+                                    .foregroundColor(.secondary)
+                                    .font(.footnote)
+                            }
+                            Spacer()
+                        }
+                        .contentShape(Rectangle())
+                        .onTapGesture {
+                            onSelectDevice(device)
+                        }
+                    }
+                }
+            }
+        }
+        .onAppear {
+            bleManager.startScanning()
+        }
+        .onDisappear {
+            bleManager.stopScanning()
+        }
+    }
+
+    private func isSelected(_ device: BLEDevice) -> Bool {
+        guard let selectedDevice = Storage.shared.selectedBLEDevice.value else {
+            return false
+        }
+        return selectedDevice.id == device.id
+    }
+}

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

@@ -0,0 +1,215 @@
+//
+//  BLEManager.swift
+//  LoopFollow
+//
+
+import Foundation
+import CoreBluetooth
+import Combine
+
+class BLEManager: NSObject, ObservableObject {
+    static let shared = BLEManager()
+
+    @Published private(set) var devices: [BLEDevice] = []
+
+    private var centralManager: CBCentralManager!
+    private var activeDevice: BluetoothDevice?
+
+    private override init() {
+        super.init()
+
+        centralManager = CBCentralManager(
+            delegate: self,
+            queue: .main
+        )
+        if let device = Storage.shared.selectedBLEDevice.value {
+            devices.append(device)
+            findAndUpdateDevice(with: device.id.uuidString) { device in
+                device.rssi = 0
+            }
+            connect(device: device)
+        }
+    }
+
+    func getSelectedDevice() -> BLEDevice? {
+        return devices.first { $0.id == Storage.shared.selectedBLEDevice.value?.id }
+    }
+
+    func startScanning() {
+        guard centralManager.state == .poweredOn else {
+            LogManager.shared.log(category: .bluetooth, message: "Not powered on, cannot start scan.")
+            return
+        }
+        centralManager.scanForPeripherals(withServices: nil, options: nil)
+
+        cleanupOldDevices()
+    }
+
+    func disconnect() {
+        if let device = activeDevice {
+            device.disconnect()
+            activeDevice = nil
+            device.lastHeartbeatTime = nil
+        }
+        Storage.shared.selectedBLEDevice.value = nil
+    }
+
+    func connect(device: BLEDevice) {
+        disconnect()
+
+        if let matchedType = BackgroundRefreshType.allCases.first(where: { $0.matches(device) }) {
+            Storage.shared.backgroundRefreshType.value = matchedType
+            Storage.shared.selectedBLEDevice.value = device
+
+            findAndUpdateDevice(with: device.id.uuidString) { device in
+                device.isConnected = false
+                device.lastConnected = nil
+            }
+
+            switch matchedType {
+            case .dexcom:
+                activeDevice = DexcomHeartbeatBluetoothDevice(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self)
+                activeDevice?.connect()
+            case .rileyLink:
+                activeDevice = RileyLinkHeartbeatBluetoothDevice(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self)
+                activeDevice?.connect()
+            case .silentTune, .none:
+                return
+            }
+        } else {
+            LogManager.shared.log(category: .bluetooth, message: "No matching BackgroundRefreshType found for this device.")
+        }
+    }
+
+    func stopScanning() {
+        centralManager.stopScan()
+    }
+
+    func expectedHeartbeatInterval() -> TimeInterval? {
+        guard let device = activeDevice else {
+            return nil
+        }
+
+        return device.expectedHeartbeatInterval()
+    }
+
+    private func addOrUpdateDevice(_ device: BLEDevice) {
+        if let idx = devices.firstIndex(where: { $0.id == device.id }) {
+            var updatedDevice = devices[idx]
+            updatedDevice.rssi = device.rssi
+            updatedDevice.lastSeen = Date()
+            devices[idx] = updatedDevice
+        } else {
+            var newDevice = device
+            newDevice.lastSeen = Date()
+            devices.append(newDevice)
+        }
+
+        devices = devices
+    }
+
+    private func cleanupOldDevices() {
+        let expirationDate = Date().addingTimeInterval(-600) // 10 minutes ago
+
+        // Get the selected device's ID (if any)
+        let selectedDeviceID = Storage.shared.selectedBLEDevice.value?.id
+
+        // Filter devices, keeping those seen within the last 10 minutes or the selected device
+        devices = devices.filter { $0.lastSeen > expirationDate || $0.id == selectedDeviceID }
+    }
+}
+
+// MARK: - CBCentralManagerDelegate
+extension BLEManager: CBCentralManagerDelegate {
+    func centralManagerDidUpdateState(_ central: CBCentralManager) {
+        switch central.state {
+        case .poweredOn:
+            LogManager.shared.log(category: .bluetooth, message: "Central poweredOn", isDebug: true)
+        default:
+            LogManager.shared.log(category: .bluetooth, message: "Central state = \(central.state.rawValue), not powered on.", isDebug: true)
+        }
+    }
+
+    func centralManager(_ central: CBCentralManager,
+                        didDiscover peripheral: CBPeripheral,
+                        advertisementData: [String: Any],
+                        rssi RSSI: NSNumber) {
+        let uuid = peripheral.identifier
+        let services = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?
+            .map { $0.uuidString }
+
+        let device = BLEDevice(
+            id: uuid,
+            name: peripheral.name,
+            rssi: RSSI.intValue,
+            advertisedServices: services,
+            lastSeen: Date()
+        )
+
+        addOrUpdateDevice(device)
+    }
+
+    func findAndUpdateDevice(with deviceAddress: String, update: (inout BLEDevice) -> Void) {
+        if let idx = devices.firstIndex(where: { $0.id.uuidString == deviceAddress }) {
+            var device = devices[idx]
+            update(&device)
+            devices[idx] = device
+
+            devices = devices
+        } else {
+            LogManager.shared.log(category: .bluetooth, message: "Device not found in devices array for update")
+        }
+    }
+}
+
+extension BLEManager: BluetoothDeviceDelegate {
+    func didConnectTo(bluetoothDevice: BluetoothDevice) {
+        LogManager.shared.log(category: .bluetooth, message: "Connected to: \(bluetoothDevice.deviceName ?? "Unknown")", isDebug: true)
+
+        findAndUpdateDevice(with: bluetoothDevice.deviceAddress) { device in
+            device.isConnected = true
+            device.lastConnected = Date()
+        }
+    }
+
+    func didDisconnectFrom(bluetoothDevice: BluetoothDevice) {
+        LogManager.shared.log(category: .bluetooth, message: "Disconnect from: \(bluetoothDevice.deviceName ?? "Unknown")", isDebug: true)
+
+        findAndUpdateDevice(with: bluetoothDevice.deviceAddress) { device in
+            device.isConnected = false
+            device.lastConnected = Date()
+        }
+    }
+
+    func heartBeat() {
+        guard let device = activeDevice else {
+            return
+        }
+
+        let now = Date()
+        guard let expectedInterval = device.expectedHeartbeatInterval() else {
+            LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered")
+            device.lastHeartbeatTime = now
+            TaskScheduler.shared.checkTasksNow()
+            return
+        }
+
+        let marginPercentage: Double = 0.15 // 15% margin
+        let margin = expectedInterval * marginPercentage
+        let threshold = expectedInterval + margin
+
+        if let last = device.lastHeartbeatTime {
+            let elapsedTime = now.timeIntervalSince(last)
+            if elapsedTime > threshold {
+                let delay = elapsedTime - expectedInterval
+                LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)")
+            }
+        } else {
+            LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)")
+        }
+
+        device.lastHeartbeatTime = now
+
+        TaskScheduler.shared.checkTasksNow()
+    }
+}

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

@@ -0,0 +1,327 @@
+//
+//  BluetoothDevice.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-04.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import CoreBluetooth
+import os
+import UIKit
+
+class BluetoothDevice: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
+    public weak var bluetoothDeviceDelegate: BluetoothDeviceDelegate?
+    private(set) var deviceAddress:String
+    private(set) var deviceName:String?
+    private let CBUUID_Advertisement:String?
+    private let servicesCBUUIDs:[CBUUID]?
+    private let CBUUID_ReceiveCharacteristic:String
+    private var centralManager: CBCentralManager?
+    private var peripheral: CBPeripheral?
+    private var timeStampLastStatusUpdate:Date
+    private var receiveCharacteristic:CBCharacteristic?
+    private let maxTimeToWaitForPeripheralResponse = 5.0
+    private var connectTimeOutTimer: Timer?
+    var lastHeartbeatTime: Date?
+
+    init(address:String, name:String?, CBUUID_Advertisement:String?, servicesCBUUIDs:[CBUUID]?, CBUUID_ReceiveCharacteristic:String, bluetoothDeviceDelegate: BluetoothDeviceDelegate) {
+        self.lastHeartbeatTime = nil
+        self.deviceAddress = address
+        self.deviceName = name
+
+        self.servicesCBUUIDs = servicesCBUUIDs
+        self.CBUUID_Advertisement = CBUUID_Advertisement
+        self.CBUUID_ReceiveCharacteristic = CBUUID_ReceiveCharacteristic
+
+        timeStampLastStatusUpdate = Date()
+
+        self.bluetoothDeviceDelegate = bluetoothDeviceDelegate
+
+        super.init()
+
+        initialize()
+    }
+
+    deinit {
+        disconnect()
+    }
+
+    func connect() {
+        if let centralManager = centralManager, !retrievePeripherals(centralManager) {
+            _ = startScanning()
+        }
+    }
+
+    func disconnect() {
+        if let peripheral = peripheral {
+            if let centralManager = centralManager {
+                centralManager.cancelPeripheralConnection(peripheral)
+            }
+        }
+    }
+
+    func disconnectAndForget() {
+        disconnect()
+
+        peripheral = nil
+        deviceName = nil
+        //deviceAddress = nil
+    }
+
+    func stopScanning() {
+        self.centralManager?.stopScan()
+    }
+
+    func isScanning() -> Bool {
+        if let centralManager = centralManager {
+            return centralManager.isScanning
+        }
+        return false
+    }
+
+    func startScanning() -> BluetoothDevice.startScanningResult {
+        LogManager.shared.log(category: .bluetooth, message: "Start Scanning", isDebug: true)
+
+        var returnValue = BluetoothDevice.startScanningResult.unknown
+
+        if let peripheral = peripheral {
+            switch peripheral.state {
+            case .connected:
+                return .alreadyConnected
+            case .connecting:
+                if Date() > Date(timeInterval: maxTimeToWaitForPeripheralResponse, since: timeStampLastStatusUpdate) {
+                    disconnect()
+                }
+                return .connecting
+            default:()
+            }
+        }
+
+        var services:[CBUUID]?
+        if let CBUUID_Advertisement = CBUUID_Advertisement {
+            services = [CBUUID(string: CBUUID_Advertisement)]
+        }
+
+        if let centralManager = centralManager {
+            if centralManager.isScanning {
+                return .alreadyScanning
+            }
+            switch centralManager.state {
+            case .poweredOn:
+                centralManager.scanForPeripherals(withServices: services, options: nil)
+                returnValue = .success
+            case .poweredOff:
+                return .poweredOff
+            case .unknown:
+                return .unknown
+            case .unauthorized:
+                return .unauthorized
+            default:
+                return returnValue
+            }
+        } else {
+            returnValue = .other(reason:"centralManager is nil, can not start scanning")
+        }
+
+        return returnValue
+    }
+
+    func readValueForCharacteristic(for characteristic: CBCharacteristic) {
+        peripheral?.readValue(for: characteristic)
+    }
+
+    func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic) {
+        if let peripheral = peripheral {
+            peripheral.setNotifyValue(enabled, for: characteristic)
+        }
+    }
+
+    fileprivate func stopScanAndconnect(to peripheral: CBPeripheral) {
+        LogManager.shared.log(category: .bluetooth, message: "Stop Scan And Connect", isDebug: true)
+
+        self.centralManager?.stopScan()
+        self.deviceAddress = peripheral.identifier.uuidString
+        self.deviceName = peripheral.name
+        peripheral.delegate = self
+        self.peripheral = peripheral
+
+        if peripheral.state == .disconnected {
+            connectTimeOutTimer = Timer.scheduledTimer(timeInterval: 5.0, target: self, selector: #selector(stopConnectAndRestartScanning), userInfo: nil, repeats: false)
+            centralManager?.connect(peripheral, options: nil)
+        } else {
+            if let newCentralManager = centralManager {
+                centralManager(newCentralManager, didConnect: peripheral)
+            }
+        }
+    }
+
+    @objc fileprivate func stopConnectAndRestartScanning() {
+        disconnectAndForget()
+        _ = startScanning()
+    }
+
+    public func cancelConnectionTimer() {
+        if let connectTimeOutTimer = connectTimeOutTimer {
+            connectTimeOutTimer.invalidate()
+            self.connectTimeOutTimer = nil
+        }
+
+    }
+
+    fileprivate func retrievePeripherals(_ central:CBCentralManager) -> Bool {
+        if let uuid = UUID(uuidString: deviceAddress) {
+            //trace("    uuid is not nil", log: log, category: ConstantsLog.categoryBlueToothTransmitter, type: .info)
+            let peripheralArr = central.retrievePeripherals(withIdentifiers: [uuid])
+            if peripheralArr.count > 0 {
+                peripheral = peripheralArr[0]
+                if let peripheral = peripheral {
+                    peripheral.delegate = self
+                    central.connect(peripheral, options: nil)
+                    return true
+                }
+            }
+        }
+        return false
+    }
+
+    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
+        print("[BLE] didDiscover")
+
+        timeStampLastStatusUpdate = Date()
+
+        if peripheral.identifier.uuidString == deviceAddress {
+            stopScanAndconnect(to: peripheral)
+        }
+    }
+
+    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
+        cancelConnectionTimer()
+
+        timeStampLastStatusUpdate = Date()
+
+        bluetoothDeviceDelegate?.didConnectTo(bluetoothDevice: self)
+
+        peripheral.discoverServices(servicesCBUUIDs)
+    }
+
+    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
+        timeStampLastStatusUpdate = Date()
+
+        let peripheralName = peripheral.name ?? "Unknown"
+        let errorMessage = error?.localizedDescription ?? "No error details provided"
+
+        LogManager.shared.log(category: .bluetooth, message: "Failed to connect to peripheral '\(peripheralName)' (UUID: \(peripheral.identifier.uuidString)). Error: \(errorMessage). Retrying...")
+
+        centralManager?.connect(peripheral, options: nil)
+    }
+
+    func centralManagerDidUpdateState(_ central: CBCentralManager) {
+        LogManager.shared.log(category: .bluetooth, message: "Central Manager Did Update State", isDebug: true)
+
+        timeStampLastStatusUpdate = Date()
+
+        if central.state == .poweredOn {
+            _ = retrievePeripherals(central)
+        }
+    }
+
+    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
+        timeStampLastStatusUpdate = Date()
+
+        bluetoothDeviceDelegate?.didDisconnectFrom(bluetoothDevice: self)
+
+        if let ownPeripheral = self.peripheral {
+            centralManager?.connect(ownPeripheral, options: nil)
+        }
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
+        timeStampLastStatusUpdate = Date()
+
+        if let services = peripheral.services {
+            for service in services {
+                peripheral.discoverCharacteristics(nil, for: service)
+            }
+        } else {
+            disconnect()
+        }
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
+        timeStampLastStatusUpdate = Date()
+
+        if let characteristics = service.characteristics {
+            for characteristic in characteristics {
+                if characteristic.uuid == CBUUID(string: CBUUID_ReceiveCharacteristic) {
+                    receiveCharacteristic = characteristic
+                    peripheral.setNotifyValue(true, for: characteristic)
+                }
+            }
+        }
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
+        timeStampLastStatusUpdate = Date()
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
+        timeStampLastStatusUpdate = Date()
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
+        timeStampLastStatusUpdate = Date()
+    }
+
+    func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
+        LogManager.shared.log(category: .bluetooth, message: "Restoring BLE after crash/kill")
+    }
+
+    private func initialize() {
+        var cBCentralManagerOptionRestoreIdentifierKeyToUse: String?
+
+        cBCentralManagerOptionRestoreIdentifierKeyToUse = "LoopFollow-" + deviceAddress
+
+        centralManager = CBCentralManager(delegate: self, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: true, CBCentralManagerOptionRestoreIdentifierKey: cBCentralManagerOptionRestoreIdentifierKeyToUse!])
+    }
+
+    enum startScanningResult: Equatable {
+        case success
+        case alreadyScanning
+        case poweredOff
+        case alreadyConnected
+        case connecting
+        case unknown
+        case unauthorized
+        case nfcScanNeeded
+        case other(reason:String)
+
+        func description() -> String {
+            switch self {
+            case .success:
+                return "success"
+            case .alreadyScanning:
+                return "alreadyScanning"
+            case .poweredOff:
+                return "poweredOff"
+            case .alreadyConnected:
+                return "alreadyConnected"
+            case .connecting:
+                return "connecting"
+            case .other(let reason):
+                return "other reason : " + reason
+            case .unknown:
+                return "unknown"
+            case .unauthorized:
+                return "unauthorized"
+            case .nfcScanNeeded:
+                return "nfcScanNeeded"
+            }
+        }
+    }
+
+    func expectedHeartbeatInterval() -> TimeInterval? {
+        return nil
+    }
+}

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

+ 38 - 0
LoopFollow/BackgroundRefresh/BT/Devices/DexcomHeartbeatBluetoothDevice.swift

@@ -0,0 +1,38 @@
+//
+//  DexcomHeartbeatBluetoothDevice.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-04.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import os
+import CoreBluetooth
+import AVFoundation
+
+class DexcomHeartbeatBluetoothDevice: BluetoothDevice {
+    private let CBUUID_Service_G7 = "F8083532-849E-531C-C594-30F1F86A4EA5"
+    private let CBUUID_Advertisement_G7 = "FEBC"
+    private let CBUUID_ReceiveCharacteristic_G7 = "F8083535-849E-531C-C594-30F1F86A4EA5"
+
+    init(address:String, name:String?, bluetoothDeviceDelegate: BluetoothDeviceDelegate) {
+        super.init(
+            address: address,
+            name: name,
+            CBUUID_Advertisement: CBUUID_Advertisement_G7,
+            servicesCBUUIDs: [CBUUID(string: CBUUID_Service_G7)],
+            CBUUID_ReceiveCharacteristic: CBUUID_ReceiveCharacteristic_G7,
+            bluetoothDeviceDelegate: bluetoothDeviceDelegate
+        )
+    }
+
+    override func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
+        super.centralManager(central, didDisconnectPeripheral: peripheral, error: error)
+        self.bluetoothDeviceDelegate?.heartBeat()
+    }
+
+    override func expectedHeartbeatInterval() -> TimeInterval? {
+        return 5 * 60 // 5 minutes in seconds
+    }
+}

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

@@ -0,0 +1,47 @@
+//
+//  RileyLinkHeartbeatBluetoothTransmitter.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-08.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import CoreBluetooth
+
+class RileyLinkHeartbeatBluetoothDevice: BluetoothDevice {
+    private let CBUUID_Service_RileyLink: String = "0235733B-99C5-4197-B856-69219C2A3845"
+    private let CBUUID_ReceiveCharacteristic_TimerTick: String = "6E6C7910-B89E-43A5-78AF-50C5E2B86F7E"
+    private let CBUUID_ReceiveCharacteristic_Data: String = "C842E849-5028-42E2-867C-016ADADA9155"
+
+    init(address:String, name:String?, bluetoothDeviceDelegate: BluetoothDeviceDelegate) {
+        super.init(
+            address: address,
+            name: name,
+            CBUUID_Advertisement: nil,
+            servicesCBUUIDs: [CBUUID(string: CBUUID_Service_RileyLink)],
+            CBUUID_ReceiveCharacteristic: CBUUID_ReceiveCharacteristic_TimerTick,
+            bluetoothDeviceDelegate: bluetoothDeviceDelegate
+        )
+    }
+
+    override func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
+        super.centralManager(central, didConnect: peripheral)
+
+        self.bluetoothDeviceDelegate?.heartBeat()
+    }
+
+    override func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
+        super.peripheral(peripheral, didUpdateValueFor: characteristic, error: error)
+
+        guard characteristic.uuid == CBUUID(string: CBUUID_ReceiveCharacteristic_TimerTick) else {
+            return
+        }
+
+        self.bluetoothDeviceDelegate?.heartBeat()
+    }
+
+    override func expectedHeartbeatInterval() -> TimeInterval? {
+        return 60
+    }
+}

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

+ 189 - 0
LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift

@@ -0,0 +1,189 @@
+//
+//  BackgroundRefreshSettingsView.swift
+//  LoopFollow
+//
+
+import SwiftUI
+
+struct BackgroundRefreshSettingsView: View {
+    @ObservedObject var viewModel: BackgroundRefreshSettingsViewModel
+    @Environment(\.presentationMode) var presentationMode
+    @State private var forceRefresh = false
+    @State private var timer: Timer?
+
+    @ObservedObject var bleManager = BLEManager.shared
+
+    var body: some View {
+        NavigationView {
+            Form {
+                refreshTypeSection
+
+                if viewModel.backgroundRefreshType.isBluetooth {
+                    selectedDeviceSection
+                    availableDevicesSection
+                }
+            }
+            .navigationBarTitle("Background Refresh Settings", displayMode: .inline)
+            .toolbar {
+                ToolbarItem(placement: .navigationBarTrailing) {
+                    Button("Done") {
+                        presentationMode.wrappedValue.dismiss()
+                    }
+                }
+            }
+            .onAppear {
+                startTimer()
+            }
+            .onDisappear {
+                stopTimer()
+            }
+        }
+    }
+
+    // MARK: - Subviews / Computed Properties
+
+    private var refreshTypeSection: some View {
+        Section {
+            Picker("Background Refresh Type", selection: $viewModel.backgroundRefreshType) {
+                ForEach(BackgroundRefreshType.allCases, id: \.self) { type in
+                    Text(type.rawValue).tag(type)
+                }
+            }
+            .pickerStyle(MenuPickerStyle())
+
+            VStack(alignment: .leading, spacing: 4) {
+                Text("Adjust the background refresh type.")
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+
+                switch viewModel.backgroundRefreshType {
+                case .none:
+                    Text("No background refresh. Alarms and updates will not work unless the app is open in the foreground.")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+
+                case .silentTune:
+                    Text("A silent tune will play in the background, keeping the app active. May be interrupted by other apps. Allows continuous updates but consumes more battery.")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+
+                case .rileyLink:
+                    Text("Requires a RileyLink-compatible device within Bluetooth range. Provides updates once per minute and uses less battery than the silent tune method.")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+
+                case .dexcom:
+                    Text("Requires a Dexcom G6/ONE/G7/ONE+ transmitter within Bluetooth range. Provides updates every 5 minutes and uses less battery than the silent tune method.")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }
+        }
+    }
+
+    @ViewBuilder
+    private var selectedDeviceSection: some View {
+        if let storedDevice = bleManager.getSelectedDevice() {
+            Section(header: Text("Selected Device")) {
+                VStack(alignment: .leading, spacing: 4) {
+                    Text(storedDevice.name ?? "Unknown Device")
+                        .font(.headline)
+
+                    deviceConnectionStatus(for: storedDevice)
+
+                    if(storedDevice.rssi != 0)
+                    {
+                        Text("RSSI: \(storedDevice.rssi) dBm")
+                            .foregroundColor(.secondary)
+                            .font(.footnote)
+                    }
+
+                    HStack {
+                        Spacer()
+                        Button(action: {
+                            bleManager.disconnect()
+                        }) {
+                            Text("Disconnect")
+                                .foregroundColor(.blue)
+                        }
+                        .buttonStyle(BorderlessButtonStyle())
+                        Spacer()
+                    }
+                }
+                .padding(.vertical, 8)
+            }
+            .id(forceRefresh)
+        }
+    }
+
+    private func formattedTimeString(from seconds: TimeInterval) -> String {
+        if seconds < 60 {
+            return "\(Int(seconds)) seconds"
+        } else {
+            let minutes = Int(seconds / 60)
+            let seconds = Int(seconds.truncatingRemainder(dividingBy: 60))
+            return "\(minutes):\(String(format: "%02d", seconds)) minutes"
+        }
+    }
+
+    private var availableDevicesSection: some View {
+        Section(header: scanningStatusHeader) {
+            BLEDeviceSelectionView(
+                bleManager: bleManager,
+                selectedFilter: viewModel.backgroundRefreshType,
+                onSelectDevice: { device in
+                    bleManager.connect(device: device)
+                }
+            )
+        }
+    }
+
+    private var scanningStatusHeader: some View {
+        Text("Scanning for \(viewModel.backgroundRefreshType.rawValue)...")
+            .font(.subheadline)
+            .foregroundColor(.secondary)
+    }
+
+    private func deviceConnectionStatus(for device: BLEDevice) -> some View {
+        let expectedConnectionTime: TimeInterval = bleManager.expectedHeartbeatInterval() ?? 300
+        let now = Date()
+        let timeSinceLastConnection = device.isConnected ? 0 : now.timeIntervalSince(device.lastConnected ?? now)
+
+        if device.isConnected {
+            return Text("Connected")
+                .foregroundColor(.green)
+        } else if let lastConnected = device.lastConnected {
+            let timeRatio = timeSinceLastConnection / expectedConnectionTime
+            let timeString = formattedTimeString(from: timeSinceLastConnection)
+
+            if timeRatio < 1.0 {
+                return Text("Disconnected for \(timeString)")
+                    .foregroundColor(.green)
+            } else if timeRatio <= 1.15 {
+                return Text("Disconnected for \(timeString)")
+                    .foregroundColor(.orange)
+            } else if timeRatio <= 3.0 {
+                return Text("Disconnected for \(timeString)")
+                    .foregroundColor(.red)
+            } else {
+                let date = dateTimeUtils.formattedDate(from: lastConnected)
+                return Text("Last connection: \(date)")
+                    .foregroundColor(.red)
+            }
+        } else {
+            return Text("Reconnecting...")
+                .foregroundColor(.orange)
+        }
+    }
+
+    private func startTimer() {
+        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+            self.forceRefresh.toggle()
+        }
+    }
+
+    private func stopTimer() {
+        timer?.invalidate()
+        timer = nil
+    }
+}

+ 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) {
+        LogManager.shared.log(category: .general, message: "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 dexcom = "Dexcom"
+
+    /// Indicates if the device type uses Bluetooth
+    var isBluetooth: Bool {
+        switch self {
+        case .rileyLink, .dexcom:
+            return true
+        case .silentTune, .none:
+            return false
+        }
+    }
+
+    /// Determines if a BLEDevice matches the specific device type
+    func matches(_ device: BLEDevice) -> Bool {
+        switch self {
+        case .rileyLink:
+            let rileyUUIDString = "0235733b-99c5-4197-b856-69219c2a3845"
+            if let services = device.advertisedServices {
+                return services.map { $0.lowercased() }
+                    .contains(rileyUUIDString.lowercased())
+            }
+            return false
+
+        case .dexcom:
+            if let name = device.name {
+                return name.hasPrefix("DXCM") || name.hasPrefix("DX02") || name.hasPrefix("Dexcom")
+            }
+            return false
+
+        case .silentTune, .none:
+            return false
+        }
+    }
+}

+ 17 - 6
LoopFollow/Controllers/AlarmSound.swift

@@ -135,7 +135,9 @@ class AlarmSound {
         guard !self.isPlaying else {
             return
         }
-        
+
+        enableAudio()
+
         do {
             self.audioPlayer = try AVAudioPlayer(contentsOf: self.soundURL)
             self.audioPlayer!.delegate = self.audioPlayerDelegate
@@ -244,21 +246,30 @@ class AlarmSound {
         
         self.systemOutputVolumeBeforeOverride = nil
     }
+
+    fileprivate static func enableAudio() {
+        do {
+            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers)
+            try AVAudioSession.sharedInstance().setActive(true)
+        } catch {
+            LogManager.shared.log(category: .general, message: "Enable audio error: \(error)")
+        }
+    }
 }
 
 class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {
 
     /* audioPlayerDidFinishPlaying:successfully: is called when a sound has finished playing. This method is NOT called if the player is stopped due to an interruption. */
     func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
-        NSLog("AlarmRule - audioPlayerDidFinishPlaying (\(flag))")
+        LogManager.shared.log(category: .general, message: "AlarmRule - audioPlayerDidFinishPlaying (\(flag))", isDebug: true)
     }
     
     /* if an error occurs while decoding it will be reported to the delegate. */
     func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
         if let error = error {
-            NSLog("AlarmRule - audioPlayerDecodeErrorDidOccur: \(error)")
+            LogManager.shared.log(category: .general, message: "AlarmRule - audioPlayerDecodeErrorDidOccur: \(error)")
         } else {
-            NSLog("AlarmRule - audioPlayerDecodeErrorDidOccur")
+            LogManager.shared.log(category: .general, message: "AlarmRule - audioPlayerDecodeErrorDidOccur")
         }
     }
     
@@ -266,14 +277,14 @@ class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {
     
     /* audioPlayerBeginInterruption: is called when the audio session has been interrupted while the player was playing. The player will have been paused. */
     func audioPlayerBeginInterruption(_ player: AVAudioPlayer) {
-        NSLog("AlarmRule - audioPlayerBeginInterruption")
+        LogManager.shared.log(category: .general, message: "AlarmRule - audioPlayerBeginInterruption")
     }
     
     
     /* audioPlayerEndInterruption:withOptions: is called when the audio session interruption has ended and this player had been interrupted while playing. */
     /* Currently the only flag is AVAudioSessionInterruptionFlags_ShouldResume. */
     func audioPlayerEndInterruption(_ player: AVAudioPlayer, withOptions flags: Int) {
-        NSLog("AlarmRule - audioPlayerEndInterruption withOptions: \(flags)")
+        LogManager.shared.log(category: .general, message: "AlarmRule - audioPlayerEndInterruption withOptions: \(flags)")
     }
 }
 

+ 40 - 22
LoopFollow/Controllers/Alarms.swift

@@ -20,15 +20,13 @@ extension MainViewController {
     
     
     func checkAlarms(bgs: [ShareGlucoseData]) {
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Checking Alarms") }
         // Don't check or fire alarms within 1 minute of prior alarm
         if checkAlarmTimer.isValid {  return }
         
         let date = Date()
         let now = date.timeIntervalSince1970
         let currentBG = bgs[bgs.count - 1].sgv
-        //let lastBG = bgs[bgs.count - 2].sgv // not used, protect index out of bounds
-        
+
         var skipZero = false
         if UserDefaultsRepository.alertIgnoreZero.value && currentBG == 0 {
             skipZero = true
@@ -339,12 +337,12 @@ extension MainViewController {
         
         //check for not looping alert
         if IsNightscoutEnabled() {
+            LogManager.shared.log(category: .alarm, message: "Checking NotLooping LastLoopTime was \(UserDefaultsRepository.alertLastLoopTime.value) that gives a diff of: \(Double(dateTimeUtils.getNowTimeIntervalUTC() - UserDefaultsRepository.alertLastLoopTime.value))", isDebug: true)
             if UserDefaultsRepository.alertNotLoopingActive.value
                 && !UserDefaultsRepository.alertNotLoopingIsSnoozed.value
                 && (Double(dateTimeUtils.getNowTimeIntervalUTC() - UserDefaultsRepository.alertLastLoopTime.value) >= Double(UserDefaultsRepository.alertNotLooping.value * 60))
                 && UserDefaultsRepository.alertLastLoopTime.value > 0 {
                 
-                var trigger = true
                 if (UserDefaultsRepository.alertNotLoopingUseLimits.value
                     && (
                         (Float(currentBG) >= UserDefaultsRepository.alertNotLoopingUpperLimit.value
@@ -363,6 +361,7 @@ extension MainViewController {
                         if !UserDefaultsRepository.alertNotLoopingDayTimeAudible.value { playSound = false }
                     }
                     triggerAlarm(sound: UserDefaultsRepository.alertNotLoopingSound.value, snooozedBGReadingTime: nil, overrideVolume: UserDefaultsRepository.overrideSystemOutputVolume.value, numLoops: numLoops, snoozeTime: UserDefaultsRepository.alertNotLoopingSnooze.value, audio: playSound)
+                    LogManager.shared.log(category: .alarm, message: "!!!Not Looping!!!")
                     return
                 }
             }
@@ -508,16 +507,35 @@ extension MainViewController {
             }
         }
 
-        if UserDefaultsRepository.alertRecBolusActive.value && !UserDefaultsRepository.alertRecBolusIsSnoozed.value {
+        if UserDefaultsRepository.alertRecBolusActive.value,
+           !UserDefaultsRepository.alertRecBolusIsSnoozed.value
+        {
             let currentRecBolus = UserDefaultsRepository.deviceRecBolus.value
-            let alertAtRecBolus = Double(UserDefaultsRepository.alertRecBolusLevel.value)
+            let alertAtRecBolus = UserDefaultsRepository.alertRecBolusLevel.value
 
             if currentRecBolus >= alertAtRecBolus {
-                AlarmSound.whichAlarm = "Rec. Bolus"
+                if currentRecBolus > (Observable.shared.lastRecBolusTriggered.value ?? 0) {
+                    AlarmSound.whichAlarm = "Rec. Bolus"
 
-                if UserDefaultsRepository.alertRecBolusRepeat.value { numLoops = -1 }
-                triggerAlarm(sound: UserDefaultsRepository.alertRecBolusSound.value, snooozedBGReadingTime: nil, overrideVolume: UserDefaultsRepository.overrideSystemOutputVolume.value, numLoops: numLoops, snoozeTime: UserDefaultsRepository.alertRecBolusSnooze.value, snoozeIncrement: 5, audio: true)
-                return
+                    if UserDefaultsRepository.alertRecBolusRepeat.value {
+                        numLoops = -1
+                    }
+
+                    triggerAlarm(
+                        sound: UserDefaultsRepository.alertRecBolusSound.value,
+                        snooozedBGReadingTime: nil,
+                        overrideVolume: UserDefaultsRepository.overrideSystemOutputVolume.value,
+                        numLoops: numLoops,
+                        snoozeTime: UserDefaultsRepository.alertRecBolusSnooze.value,
+                        snoozeIncrement: 5,
+                        audio: true
+                    )
+
+                    Observable.shared.lastRecBolusTriggered.value = currentRecBolus
+                    return
+                }
+            } else {
+                Observable.shared.lastRecBolusTriggered.value = nil
             }
         }
 
@@ -641,7 +659,7 @@ extension MainViewController {
         if !UserDefaultsRepository.alertAudioDuringPhone.value && isOnPhoneCall() { audioDuringCall = false }
         
         guard let snoozer = self.tabBarController!.viewControllers?[2] as? SnoozeViewController else { return }
-        snoozer.updateDisplayWhenTriggered(bgVal: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)), directionVal: latestDirectionString ?? "", deltaVal: Localizer.toDisplayUnits(latestDeltaString) ?? "", minAgoVal: latestMinAgoString ?? "", alertLabelVal: AlarmSound.whichAlarm)
+        snoozer.updateDisplayWhenTriggered(bgVal: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)), directionVal: latestDirectionString ?? "", deltaVal: latestDeltaString, minAgoVal: latestMinAgoString ?? "", alertLabelVal: AlarmSound.whichAlarm)
         if audio && !UserDefaultsRepository.alertMuteAllIsMuted.value && audioDuringCall{
             AlarmSound.setSoundFile(str: sound)
             AlarmSound.play(overrideVolume: overrideVolume, numLoops: numLoops)
@@ -655,7 +673,7 @@ extension MainViewController {
         if !UserDefaultsRepository.alertAudioDuringPhone.value && isOnPhoneCall() { audioDuringCall = false }
         
         guard let snoozer = self.tabBarController!.viewControllers?[2] as? SnoozeViewController else { return }
-        snoozer.updateDisplayWhenTriggered(bgVal: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)), directionVal: latestDirectionString ?? "", deltaVal: Localizer.toDisplayUnits(latestDeltaString) ?? "", minAgoVal: latestMinAgoString ?? "", alertLabelVal: AlarmSound.whichAlarm)
+        snoozer.updateDisplayWhenTriggered(bgVal: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)), directionVal: latestDirectionString ?? "", deltaVal: latestDeltaString, minAgoVal: latestMinAgoString ?? "", alertLabelVal: AlarmSound.whichAlarm)
         snoozer.SnoozeButton.isHidden = false
         snoozer.AlertLabel.isHidden = false
         snoozer.clockLabel.isHidden = true
@@ -688,7 +706,7 @@ extension MainViewController {
         
         AlarmSound.whichAlarm = "none"
         guard let snoozer = self.tabBarController!.viewControllers?[2] as? SnoozeViewController else { return }
-        snoozer.updateDisplayWhenTriggered(bgVal: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)), directionVal: latestDirectionString ?? "", deltaVal: Localizer.toDisplayUnits(latestDeltaString) ?? "", minAgoVal: latestMinAgoString ?? "", alertLabelVal: AlarmSound.whichAlarm)
+        snoozer.updateDisplayWhenTriggered(bgVal: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)), directionVal: latestDirectionString ?? "", deltaVal: latestDeltaString, minAgoVal: latestMinAgoString ?? "", alertLabelVal: AlarmSound.whichAlarm)
         snoozer.SnoozeButton.isHidden = true
         snoozer.AlertLabel.isHidden = true
         if AlarmSound.isPlaying {
@@ -698,7 +716,6 @@ extension MainViewController {
     
     func clearOldSnoozes(){
         let date = Date()
-        let now = date.timeIntervalSince1970
         guard let alarms = ViewControllerManager.shared.alarmViewController else { return }
 
         if date > UserDefaultsRepository.alertSnoozeAllTime.value ?? date {
@@ -961,7 +978,8 @@ extension MainViewController {
         // Speak always
         if always {
             speakBG(currentValue: currentValue, previousValue: previousValue)
-            print("Speaking because 'Always' is enabled.")
+            LogManager.shared.log(category: .general, message: "Speaking because 'Always' is enabled.", isDebug: true)
+
             return
         }
         
@@ -969,7 +987,7 @@ extension MainViewController {
         if speakLowBG {
             if currentValue <= Int(lowThreshold) || previousValue <= Int(lowThreshold) {
                 speakBG(currentValue: currentValue, previousValue: previousValue)
-                print("Speaking because of 'Low' condition.")
+                LogManager.shared.log(category: .general, message: "Speaking because of 'Low' condition.", isDebug: true)
                 return
             }
         }
@@ -985,7 +1003,7 @@ extension MainViewController {
                 currentValue <= Int(lowThreshold) || previousValue <= Int(lowThreshold) ||
                 ((currentValue <= Int(highThreshold) && (previousValue - currentValue) >= Int(fastDropDelta))) {
                 speakBG(currentValue: currentValue, previousValue: previousValue)
-                print("Speaking because of 'Proactive Low' condition. Predictive trigger: \(predictiveTrigger)")
+                LogManager.shared.log(category: .general, message: "Speaking because of 'Proactive Low' condition. Predictive trigger: \(predictiveTrigger)", isDebug: true)
                 return
             }
         }
@@ -994,12 +1012,12 @@ extension MainViewController {
         if speakHighBG {
             if currentValue >= Int(highThreshold) || previousValue >= Int(highThreshold) {
                 speakBG(currentValue: currentValue, previousValue: previousValue)
-                print("Speaking because of 'High' condition.")
+                LogManager.shared.log(category: .general, message: "Speaking because of 'High' condition.", isDebug: true)
                 return
             }
         }
-        
-        print("No condition met for speaking.")
+
+        LogManager.shared.log(category: .general, message: "No condition met for speaking.", isDebug: true)
     }
     
     struct AnnouncementTexts {
@@ -1065,7 +1083,7 @@ extension MainViewController {
             try audioSession.setCategory(.playback, mode: .default)
             try audioSession.setActive(true)
         } catch {
-            print("Failed to set up audio session: \(error)")
+            LogManager.shared.log(category: .alarm, message: "speakBG, Failed to set up audio session: \(error)")
         }
         
         // Get the current time
@@ -1075,7 +1093,7 @@ extension MainViewController {
         // If `lastSpeechTime` is `nil` (i.e., this is the first time `speakBG` is being called), use `Date.distantPast` as the default
         // value to ensure that the `guard` statement passes and the announcement is made.
         guard currentTime.timeIntervalSince(lastSpeechTime ?? .distantPast) >= 30 else {
-            print("Repeated calls to speakBG detected!")
+            LogManager.shared.log(category: .general, message: "Repeated calls to speakBG detected!", isDebug: true)
             return
         }
 

+ 0 - 2
LoopFollow/Controllers/AppStateController.swift

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

+ 105 - 23
LoopFollow/Controllers/BackgroundAlertManager.swift

@@ -9,44 +9,126 @@
 import Foundation
 import UserNotifications
 
+/// Enum representing different background alert durations.
+enum BackgroundAlertDuration: TimeInterval, CaseIterable {
+    case sixMinutes = 360    // 6 minutes in seconds
+    case twelveMinutes = 720 // 12 minutes in seconds
+    case eighteenMinutes = 1080 // 18 minutes in seconds
+}
+
+/// Enum representing unique identifiers for each background alert.
+enum BackgroundAlertIdentifier: String, CaseIterable {
+    case sixMin = "loopfollow.background.alert.6min"
+    case twelveMin = "loopfollow.background.alert.12min"
+    case eighteenMin = "loopfollow.background.alert.18min"
+}
+
 class BackgroundAlertManager {
     static let shared = BackgroundAlertManager()
-    
+
     private init() {}
-    
+
+    /// Flag indicating whether background alerts are currently scheduled.
     private var isAlertScheduled: Bool = false
-    
+
+    /// Title prefix for all background refresh notifications.
+    private let notificationTitlePrefix = "LoopFollow Background Refresh"
+
+    /// Start scheduling background alerts.
     func startBackgroundAlert() {
         isAlertScheduled = true
         scheduleBackgroundAlert()
     }
-    
+
+    /// Stop all scheduled background alerts.
     func stopBackgroundAlert() {
         isAlertScheduled = false
-        cancelBackgroundAlert()
+        removeDeliveredNotifications()
+        cancelBackgroundAlerts()
     }
-    
+
+    /// (Re)schedule all background alerts based on predefined durations.
     func scheduleBackgroundAlert() {
-        guard isAlertScheduled, UserDefaultsRepository.backgroundRefresh.value else { return }
-        
+        removeDeliveredNotifications()
+
+        guard isAlertScheduled, Storage.shared.backgroundRefreshType.value != .none else { return }
+
+        let isBluetoothActive = Storage.shared.backgroundRefreshType.value.isBluetooth
+        let expectedHeartbeat = BLEManager.shared.expectedHeartbeatInterval()
+
+        // Define alerts
+        let alerts: [BackgroundAlert] = [
+            BackgroundAlert(
+                identifier: BackgroundAlertIdentifier.sixMin.rawValue,
+                timeInterval: BackgroundAlertDuration.sixMinutes.rawValue,
+                body: isBluetoothActive
+                ? "App inactive for 6 minutes. Verify Bluetooth connectivity."
+                : "App inactive for 6 minutes. Open to resume."
+            ),
+            BackgroundAlert(
+                identifier: BackgroundAlertIdentifier.twelveMin.rawValue,
+                timeInterval: BackgroundAlertDuration.twelveMinutes.rawValue,
+                body: isBluetoothActive
+                ? "App inactive for 12 minutes. Verify Bluetooth connectivity."
+                : "App inactive for 12 minutes. Open to resume."
+            ),
+            BackgroundAlert(
+                identifier: BackgroundAlertIdentifier.eighteenMin.rawValue,
+                timeInterval: BackgroundAlertDuration.eighteenMinutes.rawValue,
+                body: isBluetoothActive
+                ? "App inactive for 18 minutes. Verify Bluetooth connectivity."
+                : "App inactive for 18 minutes. Open to resume."
+            )
+        ]
+
+        for alert in alerts {
+            // Skip if the expected heartbeat interval matches or exceeds 1.2x the alert time interval
+            if let heartbeat = expectedHeartbeat, heartbeat * 1.2 >= alert.timeInterval {
+                continue
+            }
+
+            let content = createNotificationContent(for: notificationTitlePrefix, body: alert.body)
+            let trigger = UNTimeIntervalNotificationTrigger(timeInterval: alert.timeInterval, repeats: false)
+            let request = UNNotificationRequest(identifier: alert.identifier, content: content, trigger: trigger)
+
+            UNUserNotificationCenter.current().add(request) { error in
+                if let error = error {
+                    LogManager.shared.log(category: .general, message: "Error scheduling \(alert.timeInterval / 60)-minute background alert: \(error)")
+                }
+            }
+        }
+    }
+
+    /// Create notification content with a given title and body.
+    /// - Parameters:
+    ///   - title: The title of the notification.
+    ///   - body: The body text of the notification.
+    /// - Returns: Configured `UNMutableNotificationContent` object.
+    private func createNotificationContent(for title: String, body: String) -> UNMutableNotificationContent {
         let content = UNMutableNotificationContent()
-        content.title = "LoopFollow Background Refresh"
-        content.body = "The app is not active, open the app to resume."
+        content.title = title
+        content.body = body
         content.sound = .defaultCritical
         content.categoryIdentifier = "loopfollow.background.alert"
-        
-        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 360, repeats: false)
-        
-        let request = UNNotificationRequest(identifier: "loopfollow.background.alert", content: content, trigger: trigger)
-        
-        UNUserNotificationCenter.current().add(request) { error in
-            if let error = error {
-                print("Error scheduling background alert: \(error)")
-            }
-        }
+        return content
     }
-    
-    private func cancelBackgroundAlert() {
-        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["loopfollow.background.alert"])
+
+    /// Cancel all scheduled background alerts.
+    private func cancelBackgroundAlerts() {
+        let identifiers = BackgroundAlertIdentifier.allCases.map { $0.rawValue }
+        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers)
     }
+
+    /// Remove all delivered notifications
+    private func removeDeliveredNotifications() {
+        let identifiers = BackgroundAlertIdentifier.allCases.map { $0.rawValue }
+        UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
+    }
+}
+
+/// Struct representing a single background alert.
+struct BackgroundAlert {
+    let identifier: String
+    let timeInterval: TimeInterval
+    let body: String
 }

+ 2 - 9
LoopFollow/Controllers/Graphs.swift

@@ -234,8 +234,6 @@ extension MainViewController {
     }
     
     func chartScaled(_ chartView: ChartViewBase, scaleX: CGFloat, scaleY: CGFloat) {
-        print("Chart Scaled: \(BGChart.scaleX), \(BGChart.scaleY)")
-      
         // dont store huge values
         var scale: Float = Float(BGChart.scaleX)
         if(scale > ScaleXMax ) {
@@ -801,7 +799,6 @@ extension MainViewController {
     }
     
     func updateBGGraph() {
-        if UserDefaultsRepository.debugLog.value { writeDebugLog(value: "##### Start BG Graph #####") }
         let dataIndex = 0
         let entries = bgData
         guard !entries.isEmpty else {
@@ -821,7 +818,6 @@ extension MainViewController {
                 topBG = Float(entries[i].sgv) + maxBGOffset
             }
             let value = ChartDataEntry(x: Double(entries[i].date), y: Double(entries[i].sgv), data: formatPillText(line1: Localizer.toDisplayUnits(String(entries[i].sgv)), time: entries[i].date))
-            if UserDefaultsRepository.debugLog.value { writeDebugLog(value: "BG: " + value.description) }
             mainChart.append(value)
             smallChart.append(value)
             
@@ -834,8 +830,7 @@ extension MainViewController {
             }
         }
         
-        if UserDefaultsRepository.debugLog.value { writeDebugLog(value: "Total Graph BGs: " + mainChart.entries.count.description) }        
-        
+
         // Set Colors
         let lineBG = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet
 
@@ -854,7 +849,6 @@ extension MainViewController {
             }
         }
         
-        if UserDefaultsRepository.debugLog.value { writeDebugLog(value: "Total Colors: " + mainChart.colors.count.description) }
         
         BGChart.rightAxis.axisMaximum = Double(calculateMaxBgGraphValue())
         BGChart.setVisibleXRangeMinimum(600)
@@ -868,7 +862,6 @@ extension MainViewController {
         
         if firstGraphLoad {
             var scaleX = CGFloat(UserDefaultsRepository.chartScaleX.value)
-            print("Scale: \(scaleX)")
             if( scaleX > CGFloat(ScaleXMax) ) {
                 scaleX = CGFloat(ScaleXMax)
                 UserDefaultsRepository.chartScaleX.value = ScaleXMax
@@ -1717,7 +1710,7 @@ extension MainViewController {
               index.rawValue < chart.dataSets.count,
               let smallChartData = BGChartFull.lineData,
               index.rawValue < smallChartData.dataSets.count else {
-            print("Warning: Invalid GraphDataIndex \(index.description) or lineData is nil.")
+            //print("Warning: Invalid GraphDataIndex \(index.description) or lineData is nil.")
             return (nil, nil)
         }
 

+ 54 - 75
LoopFollow/Controllers/Nightscout/BGData.swift

@@ -18,17 +18,14 @@ extension MainViewController {
         dexShare?.fetchData(count) { (err, result) -> () in
             
             if let error = err {
-                print("Error fetching Dexcom data: \(error.localizedDescription)")
-                
-                // If we get an error, immediately try to pull NS BG Data
-                if IsNightscoutEnabled() {
-                    self.webLoadNSBGData()
-                }
+                LogManager.shared.log(category: .dexcom, message: "Error fetching Dexcom data: \(error.localizedDescription)")
+                self.webLoadNSBGData()
                 return
             }
             
             guard let data = result else {
-                print("Received nil data from Dexcom")
+                LogManager.shared.log(category: .dexcom, message: "Received nil data from Dexcom")
+                self.webLoadNSBGData()
                 return
             }
             
@@ -36,8 +33,8 @@ extension MainViewController {
             let latestDate = data[0].date
             let now = dateTimeUtils.getNowTimeIntervalUTC()
             if (latestDate + 330) < now && IsNightscoutEnabled() {
+                LogManager.shared.log(category: .dexcom, message: "Dexcom data is old, loading from NS instead")
                 self.webLoadNSBGData()
-                print("Dex data is old, loading from NS instead")
                 return
             }
             
@@ -52,14 +49,11 @@ extension MainViewController {
 
     // NS BG Data Web call
     func webLoadNSBGData(dexData: [ShareGlucoseData] = []) {
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Download: BG") }
-        
         // This kicks it out in the instance where dexcom fails but they aren't using NS &&
         if !IsNightscoutEnabled() {
-            self.startBGTimer(time: 10)
             return
         }
-        
+
         var parameters: [String: String] = [:]
         let utcISODateFormatter = ISO8601DateFormatter()
         let date = Calendar.current.date(byAdding: .day, value: -1 * UserDefaultsRepository.downloadDays.value, to: Date())!
@@ -80,25 +74,20 @@ extension MainViewController {
                         nsData[i].date /= 1000
                         nsData[i].date.round(FloatingPointRoundingRule.toNearestOrEven)
                     }
-                    print(nsData.count)
-                    
-                    //Avoid duplicate entries messing up the graph, only use one reading per 5 minutes.
-                    let graphHours = 24 * UserDefaultsRepository.downloadDays.value
-                    let points = graphHours * 12 + 1
-                    var nsData2 = [ShareGlucoseData]()
-                    let timestamp = Date().timeIntervalSince1970
-                    for i in 0..<points {
-                        //Starting with "now" and then step 5 minutes back in time
-                        let target = timestamp - Double(i) * 60 * 5
-                        //Find the reading closest to the target, but not too far away
-                        let closest = nsData.filter{ abs($0.date - target) < 3 * 60 }.min { abs($0.date - target) < abs($1.date - target) }
-                        //If a reading is found, add it to the new array
-                        if let item = closest {
-                            nsData2.append(item)
+
+                    var nsData2: [ShareGlucoseData] = []
+                    var lastAddedTime = Double.infinity
+                    var lastAddedSGV: Int? = nil
+                    let minInterval: Double = 30
+
+                    for reading in nsData {
+                        if (lastAddedSGV == nil || lastAddedSGV != reading.sgv) || (lastAddedTime - reading.date >= minInterval) {
+                            nsData2.append(reading)
+                            lastAddedTime = reading.date
+                            lastAddedSGV = reading.sgv
                         }
                     }
-                    print(nsData2.count)
-                    
+
                     // merge NS and Dex data if needed; use recent Dex data and older NS data
                     var sourceName = "Nightscout"
                     if !dexData.isEmpty {
@@ -115,12 +104,12 @@ extension MainViewController {
                     self.ProcessDexBGData(data: nsData2, sourceName: sourceName)
                 }
             case .failure(let error):
-                print("Failed to fetch data: \(error)")
+                LogManager.shared.log(category: .nightscout, message: "Failed to fetch data: \(error)")
                 DispatchQueue.main.async {
-                    if self.bgTimer.isValid {
-                        self.bgTimer.invalidate()
-                    }
-                    self.startBGTimer(time: 10)
+                    TaskScheduler.shared.rescheduleTask(
+                        id: .fetchBG,
+                        to: Date().addingTimeInterval(10)
+                    )
                 }
                 // if we have Dex data, use it
                 if !dexData.isEmpty {
@@ -133,14 +122,11 @@ extension MainViewController {
     
     // Dexcom BG Data Response processor
     func ProcessDexBGData(data: [ShareGlucoseData], sourceName: String){
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: BG") }
-        
         let graphHours = 24 * UserDefaultsRepository.downloadDays.value
         
         if data.count == 0 {
             return
         }
-        let pullDate = data[data.count - 1].date
         let latestDate = data[0].date
         let now = dateTimeUtils.getNowTimeIntervalUTC()
         
@@ -148,31 +134,33 @@ extension MainViewController {
         let secondsAgo = now - latestDate
         
         DispatchQueue.main.async {
-            // if reading is overdue over: 20:00, re-attempt every 5 minutes
             if secondsAgo >= (20 * 60) {
-                self.startBGTimer(time: (5 * 60))
-                print("##### started 5 minute bg timer")
-                
-                // if the reading is overdue: 10:00-19:59, re-attempt every minute
+                TaskScheduler.shared.rescheduleTask(
+                    id: .fetchBG,
+                    to: Date().addingTimeInterval(5 * 60)
+                )
             } else if secondsAgo >= (10 * 60) {
-                self.startBGTimer(time: 60)
-                print("##### started 1 minute bg timer")
-                
-                // if the reading is overdue: 7:00-9:59, re-attempt every 30 seconds
+                TaskScheduler.shared.rescheduleTask(
+                    id: .fetchBG,
+                    to: Date().addingTimeInterval(60)
+                )
             } else if secondsAgo >= (7 * 60) {
-                self.startBGTimer(time: 30)
-                print("##### started 30 second bg timer")
-                
-                // if the reading is overdue: 5:00-6:59 re-attempt every 10 seconds
+                TaskScheduler.shared.rescheduleTask(
+                    id: .fetchBG,
+                    to: Date().addingTimeInterval(30)
+                )
             } else if secondsAgo >= (5 * 60) {
-                self.startBGTimer(time: 10)
-                print("##### started 10 second bg timer")
-                
-                // We have a current reading. Set timer to 5:10 from last reading
+                TaskScheduler.shared.rescheduleTask(
+                    id: .fetchBG,
+                    to: Date().addingTimeInterval(10)
+                )
             } else {
-                self.startBGTimer(time: 300 - secondsAgo + Double(UserDefaultsRepository.bgUpdateDelay.value))
-                let timerVal = 310 - secondsAgo
-                print("##### started 5:10 bg timer: \(timerVal)")
+                let delay = (300 - secondsAgo + Double(UserDefaultsRepository.bgUpdateDelay.value))
+                TaskScheduler.shared.rescheduleTask(
+                    id: .fetchBG,
+                    to: Date().addingTimeInterval(delay)
+                )
+
                 if data.count > 1 {
                     self.evaluateSpeakConditions(currentValue: data[0].sgv, previousValue: data[1].sgv)
                 }
@@ -212,11 +200,8 @@ extension MainViewController {
     // NS BG Data Front end updater
     func viewUpdateNSBG(sourceName: String) {
         DispatchQueue.main.async {
-            if UserDefaultsRepository.debugLog.value {
-                self.writeDebugLog(value: "Display: BG")
-                self.writeDebugLog(value: "Num BG: " + self.bgData.count.description)
-            }
-            
+            TaskScheduler.shared.rescheduleTask(id: .minAgoUpdate, to: Date())
+
             let entries = self.bgData
             if entries.count < 2 { return } // Protect index out of bounds
             
@@ -229,12 +214,7 @@ extension MainViewController {
             let deltaBG = latestBG - priorBG
             let lastBGTime = entries[latestEntryIndex].date
             
-            let deltaTime = (TimeInterval(Date().timeIntervalSince1970) - lastBGTime) / 60
-            var userUnit = " mg/dL"
-            if self.mmol {
-                userUnit = " mmol/L"
-            }
-            
+            let deltaTime = (TimeInterval(Date().timeIntervalSince1970) - lastBGTime) / 60            
             self.updateServerText(with: sourceName)
             
             var snoozerBG = ""
@@ -259,15 +239,14 @@ extension MainViewController {
             
             // Delta handling
             if deltaBG < 0 {
-                self.DeltaText.text = Localizer.toDisplayUnits(String(deltaBG))
-                snoozerDelta = Localizer.toDisplayUnits(String(deltaBG))
-                self.latestDeltaString = String(deltaBG)
+                self.latestDeltaString = Localizer.toDisplayUnits(String(deltaBG))
+
             } else {
-                self.DeltaText.text = "+" + Localizer.toDisplayUnits(String(deltaBG))
-                snoozerDelta = "+" + Localizer.toDisplayUnits(String(deltaBG))
-                self.latestDeltaString = "+" + String(deltaBG)
+                self.latestDeltaString = "+" + Localizer.toDisplayUnits(String(deltaBG))
             }
-            
+            self.DeltaText.text = self.latestDeltaString
+            snoozerDelta = self.latestDeltaString
+
             // Apply strikethrough to BGText based on the staleness of the data
             let bgTextStr = self.BGText.text ?? ""
             let attributeString = NSMutableAttributedString(string: bgTextStr)

+ 1 - 2
LoopFollow/Controllers/Nightscout/CAge.swift

@@ -23,7 +23,7 @@ extension MainViewController {
             case .success(let data):
                 self.updateCage(data: data)
             case .failure(let error):
-                print("Error: \(error.localizedDescription)")
+                LogManager.shared.log(category: .nightscout, message: "webLoadNSCage, error: \(error.localizedDescription)")
             }
         }
     }
@@ -31,7 +31,6 @@ extension MainViewController {
     // NS Cage Response Processor
     func updateCage(data: [cageData]) {
         infoManager.clearInfoData(type: .cage)
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: CAGE") }
         if data.count == 0 {
             return
         }

+ 72 - 66
LoopFollow/Controllers/Nightscout/DeviceStatus.swift

@@ -11,12 +11,7 @@ import UIKit
 import Charts
 
 extension MainViewController {
-    // NS Device Status Web Call
     func webLoadNSDeviceStatus() {
-        if UserDefaultsRepository.debugLog.value {
-            self.writeDebugLog(value: "Download: device status")
-        }
-        
         let parameters: [String: String] = ["count": "1"]
         NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters) { result in
             switch result {
@@ -28,65 +23,69 @@ extension MainViewController {
                 } else {
                     self.handleDeviceStatusError()
                 }
-                
             case .failure:
                 self.handleDeviceStatusError()
             }
         }
     }
-    
+
     private func handleDeviceStatusError() {
+        LogManager.shared.log(category: .deviceStatus, message: "Device status fetch failed!")
         DispatchQueue.main.async {
-            if self.deviceStatusTimer.isValid {
-                self.deviceStatusTimer.invalidate()
-            }
-            self.startDeviceStatusTimer(time: 10)
+            TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date().addingTimeInterval(10))
         }
+
+        evaluateNotLooping()
     }
     
-    func evaluateNotLooping(lastLoopTime: TimeInterval) {
-        if let statusStackView = LoopStatusLabel.superview as? UIStackView {
-            if ((TimeInterval(Date().timeIntervalSince1970) - lastLoopTime) / 60) > 15 {
-                IsNotLooping = true
-                // Change the distribution to 'fill' to allow manual resizing of arranged subviews
-                statusStackView.distribution = .fill
-                
-                // Hide PredictionLabel and expand LoopStatusLabel to fill the entire stack view
-                PredictionLabel.isHidden = true
-                LoopStatusLabel.frame = CGRect(x: 0, y: 0, width: statusStackView.frame.width, height: statusStackView.frame.height)
-                
-                // Update LoopStatusLabel's properties to display Not Looping
-                LoopStatusLabel.textAlignment = .center
-                LoopStatusLabel.text = "⚠️ Not Looping!"
-                LoopStatusLabel.textColor = UIColor.systemYellow
-                LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18)
-                
+    func evaluateNotLooping() {
+        guard let statusStackView = LoopStatusLabel.superview as? UIStackView else { return }
+
+        let now = TimeInterval(Date().timeIntervalSince1970)
+        let lastLoopTime = UserDefaultsRepository.alertLastLoopTime.value
+        let isAlarmEnabled = UserDefaultsRepository.alertNotLoopingActive.value
+        let nonLoopingTimeThreshold: TimeInterval
+
+        if isAlarmEnabled {
+            nonLoopingTimeThreshold = Double(UserDefaultsRepository.alertNotLooping.value * 60)
+        } else {
+            nonLoopingTimeThreshold = 15 * 60
+        }
+
+        if IsNightscoutEnabled(), (now - lastLoopTime) >= nonLoopingTimeThreshold, lastLoopTime > 0 {
+            IsNotLooping = true
+            statusStackView.distribution = .fill
+
+            PredictionLabel.isHidden = true
+            LoopStatusLabel.frame = CGRect(x: 0, y: 0, width: statusStackView.frame.width, height: statusStackView.frame.height)
+
+            LoopStatusLabel.textAlignment = .center
+            LoopStatusLabel.text = "⚠️ Not Looping!"
+            LoopStatusLabel.textColor = UIColor.systemYellow
+            LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18)
+
+        } else {
+            IsNotLooping = false
+            statusStackView.distribution = .fillEqually
+            PredictionLabel.isHidden = false
+
+            LoopStatusLabel.textAlignment = .right
+            LoopStatusLabel.font = UIFont.systemFont(ofSize: 17)
+
+            if UserDefaultsRepository.forceDarkMode.value {
+                LoopStatusLabel.textColor = UIColor.white
             } else {
-                IsNotLooping = false
-                // Restore the original distribution and visibility of labels
-                statusStackView.distribution = .fillEqually
-                PredictionLabel.isHidden = false
-                
-                // Reset LoopStatusLabel's properties
-                LoopStatusLabel.textAlignment = .right
-                LoopStatusLabel.font = UIFont.systemFont(ofSize: 17)
-
-                if UserDefaultsRepository.forceDarkMode.value {
-                    LoopStatusLabel.textColor = UIColor.white
-                } else {
-                    LoopStatusLabel.textColor = UIColor.black
-                }
+                LoopStatusLabel.textColor = UIColor.black
             }
         }
-        latestLoopTime = lastLoopTime
     }
-        
+
     // NS Device Status Response Processor
     func updateDeviceStatusDisplay(jsonDeviceStatus: [[String:AnyObject]]) {
         infoManager.clearInfoData(types: [.iob, .cob, .override, .battery, .pump, .target, .isf, .carbRatio, .updated, .recBolus, .tdd])
 
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: device status") }
         if jsonDeviceStatus.count == 0 {
+            LogManager.shared.log(category: .deviceStatus, message: "Device status is empty")
             return
         }
         
@@ -153,35 +152,42 @@ extension MainViewController {
 
         // Start the timer based on the timestamp
         let now = dateTimeUtils.getNowTimeIntervalUTC()
-        let secondsAgo = now - latestLoopTime
+        let secondsAgo = now - UserDefaultsRepository.alertLastLoopTime.value
         
         DispatchQueue.main.async {
-            // if Loop is overdue over: 20:00, re-attempt every 5 minutes
             if secondsAgo >= (20 * 60) {
-                self.startDeviceStatusTimer(time: (5 * 60))
-                print("started 5 minute device status timer")
-                
-                // if the Loop is overdue: 10:00-19:59, re-attempt every minute
+                TaskScheduler.shared.rescheduleTask(
+                    id: .deviceStatus,
+                    to: Date().addingTimeInterval(5 * 60)
+                )
+
             } else if secondsAgo >= (10 * 60) {
-                self.startDeviceStatusTimer(time: 60)
-                print("started 1 minute device status timer")
-                
-                // if the Loop is overdue: 7:00-9:59, re-attempt every 30 seconds
+                TaskScheduler.shared.rescheduleTask(
+                    id: .deviceStatus,
+                    to: Date().addingTimeInterval(60)
+                )
+
             } else if secondsAgo >= (7 * 60) {
-                self.startDeviceStatusTimer(time: 30)
-                print("started 30 second device status timer")
-                
-                // if the Loop is overdue: 5:00-6:59 re-attempt every 10 seconds
+                TaskScheduler.shared.rescheduleTask(
+                    id: .deviceStatus,
+                    to: Date().addingTimeInterval(30)
+                )
+
             } else if secondsAgo >= (5 * 60) {
-                self.startDeviceStatusTimer(time: 10)
-                print("started 10 second device status timer")
-                
-                // We have a current Loop. Set timer to 5:10 from last reading
+                TaskScheduler.shared.rescheduleTask(
+                    id: .deviceStatus,
+                    to: Date().addingTimeInterval(10)
+                )
             } else {
-                self.startDeviceStatusTimer(time: 310 - secondsAgo)
-                let timerVal = 310 - secondsAgo
-                print("started 5:10 device status timer: \(timerVal)")
+                let interval = (310 - secondsAgo)
+                TaskScheduler.shared.rescheduleTask(
+                    id: .deviceStatus,
+                    to: Date().addingTimeInterval(interval)
+                )
             }
         }
+
+        evaluateNotLooping()
+        LogManager.shared.log(category: .deviceStatus, message: "Update Device Status done", isDebug: true)
     }
 }

+ 7 - 12
LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift

@@ -14,17 +14,20 @@ import HealthKit
 extension MainViewController {
     func DeviceStatusLoop(formatter: ISO8601DateFormatter, lastLoopRecord: [String: AnyObject]) {
         ObservableUserDefaults.shared.device.value = "Loop"
+
+        if Storage.shared.remoteType.value == .trc {
+            Storage.shared.remoteType.value = .none
+        }
+
         if let lastLoopTime = formatter.date(from: (lastLoopRecord["timestamp"] as! String))?.timeIntervalSince1970  {
+            let previousLastLoopTime = UserDefaultsRepository.alertLastLoopTime.value
             UserDefaultsRepository.alertLastLoopTime.value = lastLoopTime
-            if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "lastLoopTime: " + String(lastLoopTime)) }
             if let failure = lastLoopRecord["failureReason"] {
                 LoopStatusLabel.text = "X"
                 latestLoopStatusString = "X"
-                if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Loop Failure: X") }
             } else {
                 var wasEnacted = false
                 if let enacted = lastLoopRecord["enacted"] as? [String:AnyObject] {
-                    if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Loop: Was Enacted") }
                     wasEnacted = true
                     if let lastTempBasal = enacted["rate"] as? Double {
                         
@@ -69,7 +72,7 @@ extension MainViewController {
                     let prediction = predictdata["values"] as! [Double]
                     PredictionLabel.text = Localizer.toDisplayUnits(String(Int(prediction.last!)))
                     PredictionLabel.textColor = UIColor.systemPurple
-                    if UserDefaultsRepository.downloadPrediction.value && latestLoopTime < lastLoopTime {
+                    if UserDefaultsRepository.downloadPrediction.value && previousLastLoopTime < lastLoopTime {
                         predictionData.removeAll()
                         var predictionTime = lastLoopTime
                         let toLoad = Int(UserDefaultsRepository.predictionToLoad.value * 12)
@@ -112,28 +115,20 @@ extension MainViewController {
                         if bgData.count > 0 {
                             lastBGTime = bgData[bgData.count - 1].date
                         }
-                        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "tempBasalTime: " + String(tempBasalTime)) }
-                        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "lastBGTime: " + String(lastBGTime)) }
-                        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "wasEnacted: " + String(wasEnacted)) }
                         if tempBasalTime > lastBGTime && !wasEnacted {
                             LoopStatusLabel.text = "⏀"
                             latestLoopStatusString = "⏀"
-                            if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Open Loop: recommended temp. temp time > bg time, was not enacted") }
                         } else {
                             LoopStatusLabel.text = "↻"
                             latestLoopStatusString = "↻"
-                            if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Looping: recommended temp, but temp time is < bg time and/or was enacted") }
                         }
                     }
                 } else {
                     LoopStatusLabel.text = "↻"
                     latestLoopStatusString = "↻"
-                    if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Looping: no recommended temp") }
                 }
                 
             }
-            
-            evaluateNotLooping(lastLoopTime: lastLoopTime)
         }
     }
 }

+ 196 - 176
LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift

@@ -9,216 +9,236 @@ import HealthKit
 
 extension MainViewController {
     func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) {
-        if let createdAtString = lastDeviceStatus?["created_at"] as? String,
-           let lastLoopTime = formatter.date(from: createdAtString)?.timeIntervalSince1970 {
-            UserDefaultsRepository.alertLastLoopTime.value = lastLoopTime
-            ObservableUserDefaults.shared.device.value = lastDeviceStatus?["device"] as? String ?? ""
-            if lastLoopRecord["failureReason"] != nil {
-                LoopStatusLabel.text = "X"
-                latestLoopStatusString = "X"
-            } else {
-                guard let enacted = lastLoopRecord["enacted"] as? [String: AnyObject] else {
-                    LoopStatusLabel.text = "↻"
-                    latestLoopStatusString = "↻"
-                    evaluateNotLooping(lastLoopTime: lastLoopTime)
-                    return
-                }
-                let wasEnacted = true
+        ObservableUserDefaults.shared.device.value = lastDeviceStatus?["device"] as? String ?? ""
+        if lastLoopRecord["failureReason"] != nil {
+            LoopStatusLabel.text = "X"
+            latestLoopStatusString = "X"
+        } else {
+            guard let enactedOrSuggested = lastLoopRecord["suggested"] as? [String: AnyObject] ?? lastLoopRecord["enacted"] as? [String: AnyObject] else {
+                LoopStatusLabel.text = "↻"
+                latestLoopStatusString = "↻"
+                return
+            }
 
-                // Updated
-                if let enactedTimestamp = enacted["timestamp"] as? String,
-                   let enactedTime = formatter.date(from: enactedTimestamp)?.timeIntervalSince1970 {
-                    let formattedTime = Localizer.formatTimestampToLocalString(enactedTime)
-                    infoManager.updateInfoData(type: .updated, value: formattedTime)
+            var wasEnacted : Bool
+            if let enacted = lastLoopRecord["enacted"] as? [String: AnyObject] {
+                wasEnacted = true
+                if let timestampString = enacted["timestamp"] as? String,
+                   let lastLoopTime = formatter.date(from: timestampString)?.timeIntervalSince1970 {
+                    UserDefaultsRepository.alertLastLoopTime.value = lastLoopTime
+                    LogManager.shared.log(category: .deviceStatus, message: "New LastLoopTime: \(lastLoopTime)", isDebug: true)
                 }
+            } else {
+                wasEnacted = false
+                LogManager.shared.log(category: .deviceStatus, message: "Last devicestatus is missing enacted")
+            }
 
-                // ISF
-                let profileISF = profileManager.currentISF()
-                var enactedISF: HKQuantity?
-                if let enactedISFValue = enacted["ISF"] as? Double {
-                    var determinedISFUnit: HKUnit = .milligramsPerDeciliter
-                    if enactedISFValue < 15 {
-                        determinedISFUnit = .millimolesPerLiter
-                    }
-                    enactedISF = HKQuantity(unit: determinedISFUnit, doubleValue: enactedISFValue)
-                }
-                if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF {
-                    infoManager.updateInfoData(type: .isf, firstValue: profileISF, secondValue: enactedISF, separator: .arrow)
-                } else if let profileISF = profileISF {
-                    infoManager.updateInfoData(type: .isf, value: profileISF)
+            var updatedTime: TimeInterval?
+
+            if let timestamp = enactedOrSuggested["timestamp"] as? String,
+               let parsedTime = formatter.date(from: timestamp)?.timeIntervalSince1970 {
+                updatedTime = parsedTime
+                let formattedTime = Localizer.formatTimestampToLocalString(parsedTime)
+                infoManager.updateInfoData(type: .updated, value: formattedTime)
+            }
+
+            // ISF
+            let profileISF = profileManager.currentISF()
+            var enactedISF: HKQuantity?
+            if let enactedISFValue = enactedOrSuggested["ISF"] as? Double {
+                var determinedISFUnit: HKUnit = .milligramsPerDeciliter
+                if enactedISFValue < 15 {
+                    determinedISFUnit = .millimolesPerLiter
                 }
+                enactedISF = HKQuantity(unit: determinedISFUnit, doubleValue: enactedISFValue)
+            }
+            if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF {
+                infoManager.updateInfoData(type: .isf, firstValue: profileISF, secondValue: enactedISF, separator: .arrow)
+            } else if let profileISF = profileISF {
+                infoManager.updateInfoData(type: .isf, value: profileISF)
+            }
 
-                // Carb Ratio (CR)
-                let profileCR = profileManager.currentCarbRatio()
-                var enactedCR: Double?
-                if let reasonString = enacted["reason"] as? String {
-                    let pattern = "CR: (\\d+(?:\\.\\d+)?)"
-                    if let regex = try? NSRegularExpression(pattern: pattern) {
-                        let nsString = reasonString as NSString
-                        if let match = regex.firstMatch(in: reasonString, range: NSRange(location: 0, length: nsString.length)) {
-                            let crString = nsString.substring(with: match.range(at: 1))
-                            enactedCR = Double(crString)
-                        }
+            // Carb Ratio (CR)
+            let profileCR = profileManager.currentCarbRatio()
+            var enactedCR: Double?
+            if let reasonString = enactedOrSuggested["reason"] as? String {
+                let pattern = "CR: (\\d+(?:\\.\\d+)?)"
+                if let regex = try? NSRegularExpression(pattern: pattern) {
+                    let nsString = reasonString as NSString
+                    if let match = regex.firstMatch(in: reasonString, range: NSRange(location: 0, length: nsString.length)) {
+                        let crString = nsString.substring(with: match.range(at: 1))
+                        enactedCR = Double(crString)
                     }
                 }
+            }
 
-                if let profileCR = profileCR, let enactedCR = enactedCR, profileCR != enactedCR {
-                    infoManager.updateInfoData(type: .carbRatio, value: profileCR, enactedValue: enactedCR, separator: .arrow)
-                } else if let profileCR = profileCR {
-                    infoManager.updateInfoData(type: .carbRatio, value: profileCR)
-                }
+            if let profileCR = profileCR, let enactedCR = enactedCR, profileCR != enactedCR {
+                infoManager.updateInfoData(type: .carbRatio, value: profileCR, enactedValue: enactedCR, separator: .arrow)
+            } else if let profileCR = profileCR {
+                infoManager.updateInfoData(type: .carbRatio, value: profileCR)
+            }
 
-                // IOB
-                if let iobMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") {
-                    infoManager.updateInfoData(type: .iob, value: iobMetric)
-                    latestIOB = iobMetric
-                }
+            // IOB
+            if let iobMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") {
+                infoManager.updateInfoData(type: .iob, value: iobMetric)
+                latestIOB = iobMetric
+            }
 
-                // COB
-                if let cobMetric = CarbMetric(from: enacted, key: "COB") {
-                    infoManager.updateInfoData(type: .cob, value: cobMetric)
-                    latestCOB = cobMetric
-                } else if let reasonString = enacted["reason"] as? String {
-                    // Fallback: Extract COB from reason string
-                    let cobPattern = "COB: (\\d+(?:\\.\\d+)?)"
-                    if let cobRegex = try? NSRegularExpression(pattern: cobPattern),
-                       let cobMatch = cobRegex.firstMatch(in: reasonString, range: NSRange(location: 0, length: reasonString.utf16.count)) {
-                        let cobValueString = (reasonString as NSString).substring(with: cobMatch.range(at: 1))
-                        if let cobValue = Double(cobValueString) {
-                            let tempDict: [String: AnyObject] = ["COB": cobValue as AnyObject]
-                            if let fallbackCobMetric = CarbMetric(from: tempDict, key: "COB") {
-                                infoManager.updateInfoData(type: .cob, value: fallbackCobMetric)
-                                latestCOB = fallbackCobMetric
-                            } else {
-                                print("Failed to create CarbMetric from extracted COB value: \(cobValue)")
-                            }
+            // COB
+            if let cobMetric = CarbMetric(from: enactedOrSuggested, key: "COB") {
+                infoManager.updateInfoData(type: .cob, value: cobMetric)
+                latestCOB = cobMetric
+            } else if let reasonString = enactedOrSuggested["reason"] as? String {
+                // Fallback: Extract COB from reason string
+                let cobPattern = "COB: (\\d+(?:\\.\\d+)?)"
+                if let cobRegex = try? NSRegularExpression(pattern: cobPattern),
+                   let cobMatch = cobRegex.firstMatch(in: reasonString, range: NSRange(location: 0, length: reasonString.utf16.count)) {
+                    let cobValueString = (reasonString as NSString).substring(with: cobMatch.range(at: 1))
+                    if let cobValue = Double(cobValueString) {
+                        let tempDict: [String: AnyObject] = ["COB": cobValue as AnyObject]
+                        if let fallbackCobMetric = CarbMetric(from: tempDict, key: "COB") {
+                            infoManager.updateInfoData(type: .cob, value: fallbackCobMetric)
+                            latestCOB = fallbackCobMetric
                         } else {
-                            print("Invalid COB value extracted from reason string: \(cobValueString)")
+                            print("Failed to create CarbMetric from extracted COB value: \(cobValue)")
                         }
                     } else {
-                        print("COB pattern not found in reason string.")
+                        print("Invalid COB value extracted from reason string: \(cobValueString)")
                     }
-                }
-
-                // Insulin Required
-                if let insulinReqMetric = InsulinMetric(from: enacted, key: "insulinReq") {
-                    infoManager.updateInfoData(type: .recBolus, value: insulinReqMetric)
-                    UserDefaultsRepository.deviceRecBolus.value = insulinReqMetric.value
                 } else {
-                    UserDefaultsRepository.deviceRecBolus.value = 0
+                    print("COB pattern not found in reason string.")
                 }
+            }
 
-                // Autosens
-                if let sens = enacted["sensitivityRatio"] as? Double {
-                    let formattedSens = String(format: "%.0f", sens * 100.0) + "%"
-                    infoManager.updateInfoData(type: .autosens, value: formattedSens)
-                }
+            // Insulin Required
+            if let insulinReqMetric = InsulinMetric(from: enactedOrSuggested, key: "insulinReq") {
+                infoManager.updateInfoData(type: .recBolus, value: insulinReqMetric)
+                UserDefaultsRepository.deviceRecBolus.value = insulinReqMetric.value
+            } else {
+                UserDefaultsRepository.deviceRecBolus.value = 0
+            }
 
-                // Eventual BG
-                if let eventualBGValue = enacted["eventualBG"] as? Double {
-                    let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue)
-                    PredictionLabel.text = Localizer.formatQuantity(eventualBGQuantity)
-                }
+            // Autosens
+            if let sens = enactedOrSuggested["sensitivityRatio"] as? Double {
+                let formattedSens = String(format: "%.0f", sens * 100.0) + "%"
+                infoManager.updateInfoData(type: .autosens, value: formattedSens)
+            }
 
-                // Target
-                let profileTargetHigh = profileManager.currentTargetHigh()
-                var enactedTarget: HKQuantity?
-                if let enactedTargetValue = enacted["current_target"] as? Double {
-                    var targetUnit = HKUnit.milligramsPerDeciliter
-                    if enactedTargetValue < 40 {
-                        targetUnit = .millimolesPerLiter
-                    }
-                    enactedTarget = HKQuantity(unit: targetUnit, doubleValue: enactedTargetValue)
+            // Eventual BG
+            if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double {
+                let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue)
+                PredictionLabel.text = Localizer.formatQuantity(eventualBGQuantity)
+            }
+
+            // Target
+            let profileTargetHigh = profileManager.currentTargetHigh()
+            var enactedTarget: HKQuantity?
+            if let enactedTargetValue = enactedOrSuggested["current_target"] as? Double {
+                var targetUnit = HKUnit.milligramsPerDeciliter
+                if enactedTargetValue < 40 {
+                    targetUnit = .millimolesPerLiter
                 }
+                enactedTarget = HKQuantity(unit: targetUnit, doubleValue: enactedTargetValue)
+            }
 
-                if let profileTargetHigh = profileTargetHigh, let enactedTarget = enactedTarget {
-                    let profileTargetHighFormatted = Localizer.formatQuantity(profileTargetHigh)
-                    let enactedTargetFormatted = Localizer.formatQuantity(enactedTarget)
+            if let profileTargetHigh = profileTargetHigh, let enactedTarget = enactedTarget {
+                let profileTargetHighFormatted = Localizer.formatQuantity(profileTargetHigh)
+                let enactedTargetFormatted = Localizer.formatQuantity(enactedTarget)
 
-                    // Compare formatted values to avoid issues with minor floating-point differences
-                    // Profile target could be in another unit than enacted target
-                    if profileTargetHighFormatted != enactedTargetFormatted {
-                        infoManager.updateInfoData(type: .target, firstValue: profileTargetHigh, secondValue: enactedTarget, separator: .arrow)
-                    } else {
-                        infoManager.updateInfoData(type: .target, value: profileTargetHigh)
-                    }
+                // Compare formatted values to avoid issues with minor floating-point differences
+                // Profile target could be in another unit than enacted target
+                if profileTargetHighFormatted != enactedTargetFormatted {
+                    infoManager.updateInfoData(type: .target, firstValue: profileTargetHigh, secondValue: enactedTarget, separator: .arrow)
+                } else {
+                    infoManager.updateInfoData(type: .target, value: profileTargetHigh)
                 }
+            }
+
+            // TDD
+            if let tddMetric = InsulinMetric(from: enactedOrSuggested, key: "TDD") {
+                infoManager.updateInfoData(type: .tdd, value: tddMetric)
+            }
 
-                // TDD
-                if let tddMetric = InsulinMetric(from: enacted, key: "TDD") {
-                    infoManager.updateInfoData(type: .tdd, value: tddMetric)
-                }
 
-                let predictioncolor = UIColor.systemGray
-                PredictionLabel.textColor = predictioncolor
-                topPredictionBG = UserDefaultsRepository.minBGScale.value
-                if let predbgdata = enacted["predBGs"] as? [String: AnyObject] {
-                    let predictionTypes: [(type: String, colorName: String, dataIndex: Int)] = [
-                        ("ZT", "ZT", 12),
-                        ("IOB", "Insulin", 13),
-                        ("COB", "LoopYellow", 14),
-                        ("UAM", "UAM", 15)
-                    ]
-
-                    var minPredBG = Double.infinity
-                    var maxPredBG = -Double.infinity
-
-                    for (type, colorName, dataIndex) in predictionTypes {
-                        var predictionData = [ShareGlucoseData]()
-                        if let graphdata = predbgdata[type] as? [Double] {
-                            var predictionTime = lastLoopTime
-                            let toLoad = Int(UserDefaultsRepository.predictionToLoad.value * 12)
-
-                            for i in 0...toLoad {
-                                if i < graphdata.count {
-                                    let predictionValue = graphdata[i]
-                                    minPredBG = min(minPredBG, predictionValue)
-                                    maxPredBG = max(maxPredBG, predictionValue)
-
-                                    let prediction = ShareGlucoseData(sgv: Int(round(predictionValue)), date: predictionTime, direction: "flat")
-                                    predictionData.append(prediction)
-                                    predictionTime += 300
-                                }
+            let predBGsData: [String: AnyObject]? = {
+                if let enacted = lastLoopRecord["suggested"] as? [String: AnyObject],
+                   let predBGs = enacted["predBGs"] as? [String: AnyObject] {
+                    return predBGs
+                } else if let suggested = lastLoopRecord["enacted"] as? [String: AnyObject],
+                          let predBGs = suggested["predBGs"] as? [String: AnyObject] {
+                    return predBGs
+                }
+                return nil
+            }()
+
+            let predictioncolor = UIColor.systemGray
+            PredictionLabel.textColor = predictioncolor
+            topPredictionBG = UserDefaultsRepository.minBGScale.value
+            if let predbgdata = predBGsData {
+                let predictionTypes: [(type: String, colorName: String, dataIndex: Int)] = [
+                    ("ZT", "ZT", 12),
+                    ("IOB", "Insulin", 13),
+                    ("COB", "LoopYellow", 14),
+                    ("UAM", "UAM", 15)
+                ]
+
+                var minPredBG = Double.infinity
+                var maxPredBG = -Double.infinity
+
+                for (type, colorName, dataIndex) in predictionTypes {
+                    var predictionData = [ShareGlucoseData]()
+                    if let graphdata = predbgdata[type] as? [Double] {
+                        var predictionTime = updatedTime ?? Date().timeIntervalSince1970
+                        let toLoad = Int(UserDefaultsRepository.predictionToLoad.value * 12)
+
+                        for i in 0...toLoad {
+                            if i < graphdata.count {
+                                let predictionValue = graphdata[i]
+                                minPredBG = min(minPredBG, predictionValue)
+                                maxPredBG = max(maxPredBG, predictionValue)
+
+                                let prediction = ShareGlucoseData(sgv: Int(round(predictionValue)), date: predictionTime, direction: "flat")
+                                predictionData.append(prediction)
+                                predictionTime += 300
                             }
                         }
-
-                        let color = UIColor(named: colorName) ?? UIColor.systemPurple
-                        updatePredictionGraphGeneric(
-                            dataIndex: dataIndex,
-                            predictionData: predictionData,
-                            chartLabel: type,
-                            color: color
-                        )
                     }
 
-                    if minPredBG != Double.infinity && maxPredBG != -Double.infinity {
-                        let value = "\(Localizer.toDisplayUnits(String(minPredBG)))/\(Localizer.toDisplayUnits(String(maxPredBG)))"
-                        infoManager.updateInfoData(type: .minMax, value: value)
-                    } else {
-                        infoManager.updateInfoData(type: .minMax, value: "N/A")
-                    }
+                    let color = UIColor(named: colorName) ?? UIColor.systemPurple
+                    updatePredictionGraphGeneric(
+                        dataIndex: dataIndex,
+                        predictionData: predictionData,
+                        chartLabel: type,
+                        color: color
+                    )
                 }
 
-                if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {
-                    if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 {
-                        var lastBGTime = lastLoopTime
-                        if bgData.count > 0 {
-                            lastBGTime = bgData[bgData.count - 1].date
-                        }
-                        if tempBasalTime > lastBGTime && !wasEnacted {
-                            LoopStatusLabel.text = "⏀"
-                            latestLoopStatusString = "⏀"
-                        } else {
-                            LoopStatusLabel.text = "↻"
-                            latestLoopStatusString = "↻"
-                        }
-                    }
+                if minPredBG != Double.infinity && maxPredBG != -Double.infinity {
+                    let value = "\(Localizer.toDisplayUnits(String(minPredBG)))/\(Localizer.toDisplayUnits(String(maxPredBG)))"
+                    infoManager.updateInfoData(type: .minMax, value: value)
                 } else {
-                    LoopStatusLabel.text = "↻"
-                    latestLoopStatusString = "↻"
+                    infoManager.updateInfoData(type: .minMax, value: "N/A")
                 }
             }
-            evaluateNotLooping(lastLoopTime: lastLoopTime)
+
+            if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {
+                if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 {
+                    var lastBGTime = updatedTime ?? Date().timeIntervalSince1970
+                    if bgData.count > 0 {
+                        lastBGTime = bgData[bgData.count - 1].date
+                    }
+                    if tempBasalTime > lastBGTime && !wasEnacted {
+                        LoopStatusLabel.text = "⏀"
+                        latestLoopStatusString = "⏀"
+                    } else {
+                        LoopStatusLabel.text = "↻"
+                        latestLoopStatusString = "↻"
+                    }
+                }
+            } else {
+                LoopStatusLabel.text = "↻"
+                latestLoopStatusString = "↻"
+            }
         }
     }
 }

+ 1 - 1
LoopFollow/Controllers/Nightscout/IAge.swift

@@ -27,7 +27,7 @@ extension MainViewController {
                     self.updateIage(data: data)
                 }
             case .failure(let error):
-                print("Failed to fetch data: \(error.localizedDescription)")
+                LogManager.shared.log(category: .nightscout, message: "webLoadNSIage, failed to fetch data: \(error.localizedDescription)")
             }
         }
     }

+ 30 - 9
LoopFollow/Controllers/Nightscout/NSProfile.swift

@@ -22,13 +22,6 @@ struct NSProfile: Decodable {
             let time: String
             let timeAsSeconds: Double
         }
-        struct OverrideEntry: Decodable {
-            let name: String?
-            let targetRange: [Double]?
-            let duration: Int?
-            let insulinNeedsScaleFactor: Double?
-            let symbol: String?
-        }
         struct TargetEntry: Decodable {
             let value: Double
             let time: String
@@ -38,7 +31,6 @@ struct NSProfile: Decodable {
         let basal: [BasalEntry]
         let sens: [SensEntry]
         let carbratio: [CarbRatioEntry]
-        let overrides: [OverrideEntry]?
         let target_high: [TargetEntry]?
         let target_low: [TargetEntry]?
         let timezone: String
@@ -61,8 +53,36 @@ struct NSProfile: Decodable {
         let percentage: Double?
         let target: Double?
     }
-    
+
+    struct LoopSettings: Decodable {
+        struct LoopOverridePreset: Decodable {
+            let name: String
+            let duration: Int?
+            let targetRange: [Double]?
+            let insulinNeedsScaleFactor: Double?
+            let symbol: String?
+        }
+
+        let deviceToken: String?
+        let bundleIdentifier: String?
+        let minimumBGGuard: Double?
+        let maximumBolus: Double?
+        let maximumBasalRatePerHour: Double?
+        let scheduleOverride: ScheduleOverride?
+        let dosingStrategy: String?
+        let overridePresets: [LoopOverridePreset]?
+        let dosingEnabled: Bool?
+
+        struct ScheduleOverride: Decodable {
+            let symbol: String?
+            let duration: Int?
+            let insulinNeedsScaleFactor: Double?
+            let name: String?
+        }
+    }
+
     let trioOverrides: [TrioOverrideEntry]?
+    let loopSettings: LoopSettings?
 
     enum CodingKeys: String, CodingKey {
         case store
@@ -72,6 +92,7 @@ struct NSProfile: Decodable {
         case isAPNSProduction
         case deviceToken
         case trioOverrides = "overridePresets"
+        case loopSettings = "loopSettings"
         case teamID
     }
 }

+ 1 - 3
LoopFollow/Controllers/Nightscout/Profile.swift

@@ -15,15 +15,13 @@ extension MainViewController {
             case .success(let profileData):
                 self.updateProfile(profileData: profileData)
             case .failure(let error):
-                print("Error fetching profile data: \(error.localizedDescription)")
+                LogManager.shared.log(category: .nightscout, message: "webLoadNSProfile, error fetching profile data: \(error.localizedDescription)")
             }
         }
     }
     
     // NS Profile Response Processor
     func updateProfile(profileData: NSProfile) {
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: profile") }
-        
         guard let store = profileData.store["default"] ?? profileData.store["Default"] else {
             return
         }

+ 19 - 14
LoopFollow/Controllers/Nightscout/ProfileManager.swift

@@ -16,7 +16,7 @@ final class ProfileManager {
     var carbRatioSchedule: [TimeValue<Double>]
     var targetLowSchedule: [TimeValue<HKQuantity>]
     var targetHighSchedule: [TimeValue<HKQuantity>]
-    var overrides: [Override]
+    var loopOverrides: [LoopOverride]
     var trioOverrides: [TrioOverride]
     var units: HKUnit
     var timezone: TimeZone
@@ -28,10 +28,10 @@ final class ProfileManager {
         let value: T
     }
 
-    struct Override {
+    struct LoopOverride {
         let name: String
         let targetRange: [HKQuantity]
-        let duration: Int
+        let duration: Int?
         let insulinNeedsScaleFactor: Double
         let symbol: String
     }
@@ -50,7 +50,7 @@ final class ProfileManager {
         self.carbRatioSchedule = []
         self.targetLowSchedule = []
         self.targetHighSchedule = []
-        self.overrides = []
+        self.loopOverrides = []
         self.trioOverrides = []
         self.units = .millimolesPerLiter
         self.timezone = TimeZone.current
@@ -74,16 +74,20 @@ final class ProfileManager {
         self.targetLowSchedule = store.target_low?.map { TimeValue(timeAsSeconds: Int($0.timeAsSeconds), value: HKQuantity(unit: self.units, doubleValue: $0.value)) } ?? []
         self.targetHighSchedule = store.target_high?.map { TimeValue(timeAsSeconds: Int($0.timeAsSeconds), value: HKQuantity(unit: self.units, doubleValue: $0.value)) } ?? []
 
-        if let overrides = store.overrides {
-            self.overrides = overrides.map { Override(
-                name: $0.name ?? "",
-                targetRange: $0.targetRange?.map { HKQuantity(unit: self.units, doubleValue: $0) } ?? [],
-                duration: $0.duration ?? 0,
-                insulinNeedsScaleFactor: $0.insulinNeedsScaleFactor ?? 1.0,
-                symbol: $0.symbol ?? ""
-            )}
+        if let loopSettings = profileData.loopSettings,
+           let overridePresets = loopSettings.overridePresets {
+            self.loopOverrides = overridePresets.map { preset in
+                let targetRangeQuantities = preset.targetRange?.map { HKQuantity(unit: self.units, doubleValue: $0) }
+                return LoopOverride(
+                    name: preset.name,
+                    targetRange: targetRangeQuantities ?? [],
+                    duration: preset.duration,
+                    insulinNeedsScaleFactor: preset.insulinNeedsScaleFactor ?? 1.0,
+                    symbol: preset.symbol ?? ""
+                )
+            }
         } else {
-            self.overrides = []
+            self.loopOverrides = []
         }
 
         if let trioOverrides = profileData.trioOverrides {
@@ -184,7 +188,8 @@ final class ProfileManager {
         self.carbRatioSchedule = []
         self.targetLowSchedule = []
         self.targetHighSchedule = []
-        self.overrides = []
+        self.loopOverrides = []
+        self.trioOverrides = []
         self.units = .millimolesPerLiter
         self.timezone = TimeZone.current
         self.defaultProfile = ""

+ 1 - 3
LoopFollow/Controllers/Nightscout/SAge.swift

@@ -27,7 +27,7 @@ extension MainViewController {
                     self.updateSage(data: data)
                 }
             case .failure(let error):
-                print("Failed to fetch data: \(error.localizedDescription)")
+                LogManager.shared.log(category: .nightscout, message: "webLoadNSSage, failed to fetch data: \(error.localizedDescription)")
             }
         }
     }
@@ -36,7 +36,6 @@ extension MainViewController {
     func updateSage(data: [sageData]) {
         infoManager.clearInfoData(type: .sage)
 
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process/Display: SAGE") }
         if data.count == 0 {
             return
         }
@@ -62,7 +61,6 @@ extension MainViewController {
         if let sageTime = formatter.date(from: (lastSageString as! String))?.timeIntervalSince1970 {
             let now = dateTimeUtils.getNowTimeIntervalUTC()
             let secondsAgo = now - sageTime
-            let days = 24 * 60 * 60
             
             let formatter = DateComponentsFormatter()
             formatter.unitsStyle = .positional // Use the appropriate positioning for the current locale

+ 2 - 3
LoopFollow/Controllers/Nightscout/Treatments.swift

@@ -11,7 +11,6 @@ extension MainViewController {
     // NS Treatments Web Call
     // Downloads Basal, Bolus, Carbs, BG Check, Notes, Overrides
     func WebLoadNSTreatments() {
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Download: Treatments") }
         if !UserDefaultsRepository.downloadTreatments.value { return }
         
         let startTimeString = dateTimeUtils.getDateTimeString(addingDays: -1 * UserDefaultsRepository.downloadDays.value)
@@ -28,10 +27,10 @@ extension MainViewController {
                         self.updateTreatments(entries: entries)
                     }
                 } else {
-                    print("Error: Unexpected data structure")
+                    LogManager.shared.log(category: .nightscout, message: "WebLoadNSTreatments, Unexpected data structure")
                 }
             case .failure(let error):
-                print("Error: \(error.localizedDescription)")
+                LogManager.shared.log(category: .nightscout, message: "WebLoadNSTreatments, error \(error.localizedDescription)")
             }
         }
     }

+ 0 - 2
LoopFollow/Controllers/Nightscout/Treatments/BGCheck.swift

@@ -12,7 +12,6 @@ import UIKit
 extension MainViewController {
     // NS BG Check Response Processor
     func processNSBGCheck(entries: [[String:AnyObject]]) {
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: BG Check") }
         bgCheckData.removeAll()
         
         entries.reversed().forEach { currentEntry in
@@ -20,7 +19,6 @@ extension MainViewController {
             
             guard let parsedDate = NightscoutUtils.parseDate(dateStr),
                   let glucose = currentEntry["glucose"] as? Double else {
-                if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "ERROR: Non-Double Glucose entry") }
                 return
             }
             

+ 96 - 149
LoopFollow/Controllers/Nightscout/Treatments/Basals.swift

@@ -7,200 +7,147 @@
 //
 
 import Foundation
+
 extension MainViewController {
+
     // NS Temp Basal Response Processor
     func processNSBasals(entries: [[String:AnyObject]]) {
         infoManager.clearInfoData(type: .basal)
 
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Basal") }
-        // due to temp basal durations, we're going to destroy the array and load everything each cycle for the time being.
         basalData.removeAll()
-        
+
         var lastEndDot = 0.0
-        
+
         var tempArray = entries
         tempArray.reverse()
+
         for i in 0..<tempArray.count {
-            let currentEntry = tempArray[i] as [String : AnyObject]?
-            var basalDate: String
-            if currentEntry?["timestamp"] != nil {
-                basalDate = currentEntry?["timestamp"] as! String
-            } else if currentEntry?["created_at"] != nil {
-                basalDate = currentEntry?["created_at"] as! String
-            } else {
+            guard let currentEntry = tempArray[i] as [String : AnyObject]? else { continue }
+
+            // Decide which field to parse
+            let dateString = currentEntry["timestamp"] as? String
+            ?? currentEntry["created_at"] as? String
+            guard let rawDateStr = dateString,
+                  let dateParsed = NightscoutUtils.parseDate(rawDateStr) else {
                 continue
             }
-            var strippedZone = String(basalDate.dropLast())
-            strippedZone = strippedZone.replacingOccurrences(of: "\\.\\d+", with: "", options: .regularExpression)
-            let dateFormatter = DateFormatter()
-            dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
-            dateFormatter.locale = Locale(identifier: "en_US")
-            dateFormatter.timeZone = TimeZone(abbreviation: "UTC")
-            guard let dateString = dateFormatter.date(from: strippedZone) else { continue }
-            let dateTimeStamp = dateString.timeIntervalSince1970
-            guard let basalRate = currentEntry?["absolute"] as? Double else {
-                if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "ERROR: Null Basal entry")}
+
+            let dateTimeStamp = dateParsed.timeIntervalSince1970
+            guard let basalRate = currentEntry["absolute"] as? Double else {
                 continue
             }
-            
-            let midnightTime = dateTimeUtils.getTimeIntervalMidnightToday()
-            // Setting end dots
-            var duration = 0.0
-            do {
-                duration = try currentEntry?["duration"] as! Double
-            } catch {
-                print("No Duration Found")
-            }
-            
-            // This adds scheduled basal wherever there is a break between temps. can't check the prior ending on the first item. it is 24 hours old, so it isn't important for display anyway
+
+            let duration = currentEntry["duration"] as? Double ?? 0.0
+
             if i > 0 {
                 let priorEntry = tempArray[i - 1] as [String : AnyObject]?
-                var priorBasalDate: String
-                if priorEntry?["timestamp"] != nil {
-                    priorBasalDate = priorEntry?["timestamp"] as! String
-                } else if currentEntry?["created_at"] != nil {
-                    priorBasalDate = priorEntry?["created_at"] as! String
-                } else {
-                    continue
-                }
-                var priorStrippedZone = String(priorBasalDate.dropLast())
-                priorStrippedZone = priorStrippedZone.replacingOccurrences(of: "\\.\\d+", with: "", options: .regularExpression)
-                let priorDateFormatter = DateFormatter()
-                priorDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
-                priorDateFormatter.locale = Locale(identifier: "en_US")
-                priorDateFormatter.timeZone = TimeZone(abbreviation: "UTC")
-                guard let priorDateString = dateFormatter.date(from: priorStrippedZone) else { continue }
-                let priorDateTimeStamp = priorDateString.timeIntervalSince1970
-                let priorDuration = priorEntry?["duration"] as? Double ?? 0.0
-                // if difference between time stamps is greater than the duration of the last entry, there is a gap. Give a 15 second leeway on the timestamp
-                if Double( dateTimeStamp - priorDateTimeStamp ) > Double( (priorDuration * 60) + 15 ) {
-                    
-                    var scheduled = 0.0
-                    var midGap = false
-                    var midGapTime: TimeInterval = 0
-                    var midGapValue: Double = 0
-                    // cycle through basal profiles.
-                    // TODO figure out how to deal with profile changes that happen mid-gap
-                    for b in 0..<self.basalScheduleData.count {
-                        
-                        if (priorDateTimeStamp + (priorDuration * 60)) >= basalScheduleData[b].date {
-                            scheduled = basalScheduleData[b].basalRate
-                            
-                            // deal with mid-gap scheduled basal change
-                            // don't do it on the last scheudled basal entry
-                            if b < self.basalScheduleData.count - 1 {
-                                if dateTimeStamp > self.basalScheduleData[b + 1].date {
-                                    // midGap = true
-                                    // TODO: finish this to handle mid-gap items without crashing from overlapping entries
-                                    midGapTime = self.basalScheduleData[b + 1].date
-                                    midGapValue = self.basalScheduleData[b + 1].basalRate
+                let priorDateStr = priorEntry?["timestamp"] as? String
+                ?? priorEntry?["created_at"] as? String
+                if let rawPrior = priorDateStr,
+                   let priorDateParsed = NightscoutUtils.parseDate(rawPrior) {
+
+                    let priorDateTimeStamp = priorDateParsed.timeIntervalSince1970
+                    let priorDuration = priorEntry?["duration"] as? Double ?? 0.0
+
+                    if (dateTimeStamp - priorDateTimeStamp) > (priorDuration * 60) + 15 {
+                        var scheduled = 0.0
+                        var midGap = false
+                        var midGapTime: TimeInterval = 0
+                        var midGapValue: Double = 0
+
+                        for b in 0..<basalScheduleData.count {
+                            let priorEnd = priorDateTimeStamp + (priorDuration * 60)
+                            if priorEnd >= basalScheduleData[b].date {
+                                scheduled = basalScheduleData[b].basalRate
+                                if b < basalScheduleData.count - 1 {
+                                    if dateTimeStamp > basalScheduleData[b + 1].date {
+                                        midGap = true
+                                        midGapTime = basalScheduleData[b + 1].date
+                                        midGapValue = basalScheduleData[b + 1].basalRate
+                                    }
                                 }
                             }
-                            
                         }
-                        
-                    }
-                    
-                    // Make the starting dot at the last ending dot
-                    let startDot = basalGraphStruct(basalRate: scheduled, date: Double(priorDateTimeStamp + (priorDuration * 60)))
-                    basalData.append(startDot)
-                    
-                    
-                    if midGap {
-                        // Make the ending dot at the new scheduled basal
-                        let endDot1 = basalGraphStruct(basalRate: scheduled, date: Double(midGapTime))
-                        basalData.append(endDot1)
-                        // Make the starting dot at the scheduled Time
-                        let startDot2 = basalGraphStruct(basalRate: midGapValue, date: Double(midGapTime))
-                        basalData.append(startDot2)
-                        // Make the ending dot at the new basal value
-                        let endDot2 = basalGraphStruct(basalRate: midGapValue, date: Double(dateTimeStamp))
-                        basalData.append(endDot2)
-                        
-                    } else {
-                        // Make the ending dot at the new starting dot
-                        let endDot = basalGraphStruct(basalRate: scheduled, date: Double(dateTimeStamp))
-                        basalData.append(endDot)
+
+                        let startDot = basalGraphStruct(basalRate: scheduled,
+                                                        date: priorDateTimeStamp + (priorDuration * 60))
+                        basalData.append(startDot)
+
+                        if midGap {
+                            let endDot1 = basalGraphStruct(basalRate: scheduled, date: midGapTime)
+                            basalData.append(endDot1)
+                            let startDot2 = basalGraphStruct(basalRate: midGapValue, date: midGapTime)
+                            basalData.append(startDot2)
+                            let endDot2 = basalGraphStruct(basalRate: midGapValue, date: dateTimeStamp)
+                            basalData.append(endDot2)
+                        } else {
+                            let endDot = basalGraphStruct(basalRate: scheduled, date: dateTimeStamp)
+                            basalData.append(endDot)
+                        }
                     }
-                    
-                    
                 }
             }
-            
-            // Make the starting dot
-            let startDot = basalGraphStruct(basalRate: basalRate, date: Double(dateTimeStamp))
+
+            // Start dot
+            let startDot = basalGraphStruct(basalRate: basalRate, date: dateTimeStamp)
             basalData.append(startDot)
-            
-            // Make the ending dot
-            // If it's the last one and has no duration, extend it for 30 minutes past the start. Otherwise set ending at duration
-            // duration is already set to 0 if there is no duration set on it.
-            //if i == tempArray.count - 1 && dateTimeStamp + duration <= dateTimeUtils.getNowTimeIntervalUTC() {
-            if i == tempArray.count - 1 && duration == 0.0 {
-                lastEndDot = dateTimeStamp + (30 * 60)
-            } else {
-                lastEndDot = dateTimeStamp + (duration * 60)
+
+            // End dot
+            var lastDot = dateTimeStamp + (duration * 60)
+            if i == tempArray.count - 1, duration == 0.0 {
+                lastDot = dateTimeStamp + (30 * 60)
             }
             latestBasal = Localizer.formatToLocalizedString(basalRate, maxFractionDigits: 2, minFractionDigits: 0)
 
-            // Double check for overlaps of incorrectly ended TBRs and sent it to end when the next one starts if it finds a discrepancy
+            // Overlap check
             if i < tempArray.count - 1 {
                 let nextEntry = tempArray[i + 1] as [String : AnyObject]?
-                var nextBasalDate: String
-                if nextEntry?["timestamp"] != nil {
-                    nextBasalDate = nextEntry?["timestamp"] as! String
-                } else if currentEntry?["created_at"] != nil {
-                    nextBasalDate = nextEntry?["created_at"] as! String
-                } else {
-                    continue
-                }
-                var nextStrippedZone = String(nextBasalDate.dropLast())
-                nextStrippedZone = nextStrippedZone.replacingOccurrences(of: "\\.\\d+", with: "", options: .regularExpression)
-                let nextDateFormatter = DateFormatter()
-                nextDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
-                nextDateFormatter.locale = Locale(identifier: "en_US")
-                nextDateFormatter.timeZone = TimeZone(abbreviation: "UTC")
-                guard let nextDateString = dateFormatter.date(from: nextStrippedZone) else { continue }
-                let nextDateTimeStamp = nextDateString.timeIntervalSince1970
-                if nextDateTimeStamp < (dateTimeStamp + (duration * 60)) {
-                    lastEndDot = nextDateTimeStamp
+                let nextDateStr = nextEntry?["timestamp"] as? String
+                ?? nextEntry?["created_at"] as? String
+                if let rawNext = nextDateStr,
+                   let nextDateParsed = NightscoutUtils.parseDate(rawNext) {
+
+                    let nextDateTimeStamp = nextDateParsed.timeIntervalSince1970
+                    if nextDateTimeStamp < (dateTimeStamp + (duration * 60)) {
+                        lastDot = nextDateTimeStamp
+                    }
                 }
             }
-            
-            let endDot = basalGraphStruct(basalRate: basalRate, date: Double(lastEndDot))
+
+            let endDot = basalGraphStruct(basalRate: basalRate, date: lastDot)
             basalData.append(endDot)
-            
-            
+            lastEndDot = lastDot
         }
-        
-        // If last  basal was prior to right now, we need to create one last scheduled entry
+
+        // If last basal was prior to right now, we need to create one last scheduled entry
         if lastEndDot <= dateTimeUtils.getNowTimeIntervalUTC() {
             var scheduled = 0.0
-            // cycle through basal profiles.
-            // TODO figure out how to deal with profile changes that happen mid-gap
-            for b in 0..<self.basalProfile.count {
-                let scheduleTimeYesterday = self.basalProfile[b].timeAsSeconds + dateTimeUtils.getTimeIntervalMidnightYesterday()
-                let scheduleTimeToday = self.basalProfile[b].timeAsSeconds + dateTimeUtils.getTimeIntervalMidnightToday()
-                // check the prior temp ending to the profile seconds from midnight
+            for b in 0..<basalProfile.count {
+                let scheduleTimeToday = basalProfile[b].timeAsSeconds
+                + dateTimeUtils.getTimeIntervalMidnightToday()
                 if lastEndDot >= scheduleTimeToday {
                     scheduled = basalProfile[b].value
                 }
             }
-            
-            latestBasal = Localizer.formatToLocalizedString(scheduled, maxFractionDigits: 2, minFractionDigits: 0)
-            // Make the starting dot at the last ending dot
-            let startDot = basalGraphStruct(basalRate: scheduled, date: Double(lastEndDot))
+
+            latestBasal = Localizer.formatToLocalizedString(scheduled,
+                                                            maxFractionDigits: 2,
+                                                            minFractionDigits: 0)
+
+            let startDot = basalGraphStruct(basalRate: scheduled, date: lastEndDot)
             basalData.append(startDot)
-            
-            // Make the ending dot 10 minutes after now
-            let endDot = basalGraphStruct(basalRate: scheduled, date: Double(Date().timeIntervalSince1970 + (60 * 10)))
+
+            let endDot = basalGraphStruct(basalRate: scheduled,
+                                          date: Date().timeIntervalSince1970 + (60 * 10))
             basalData.append(endDot)
-            
         }
+
         if UserDefaultsRepository.graphBasal.value {
             updateBasalGraph()
         }
 
-        if let profileBasal = profileManager.currentBasal(), profileBasal != latestBasal {
+        if let profileBasal = profileManager.currentBasal(),
+           profileBasal != latestBasal {
             latestBasal = "\(profileBasal) → \(latestBasal)"
         }
         infoManager.updateInfoData(type: .basal, value: latestBasal)

+ 0 - 1
LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift

@@ -10,7 +10,6 @@ import Foundation
 extension MainViewController {
     // NS Meal Bolus Response Processor
     func processNSBolus(entries: [[String:AnyObject]]) {
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Bolus") }
         // because it's a small array, we're going to destroy and reload every time.
         bolusData.removeAll()
         var lastFoundIndex = 0

+ 0 - 2
LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift

@@ -10,7 +10,6 @@ import Foundation
 extension MainViewController {
     // NS Carb Bolus Response Processor
     func processNSCarbs(entries: [[String:AnyObject]]) {
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Carbs") }
         // Because it's a small array, we're going to destroy and reload every time.
         carbData.removeAll()
         var lastFoundIndex = 0
@@ -74,7 +73,6 @@ extension MainViewController {
             }
             
             guard let date = NightscoutUtils.parseDate(carbDate) else {
-                print("Unable to parse date from: \(carbDate)")
                 continue
             }
             

+ 0 - 1
LoopFollow/Controllers/Nightscout/Treatments/Notes.swift

@@ -12,7 +12,6 @@ import UIKit
 extension MainViewController {
     // NS Note Response Processor
     func processNotes(entries: [[String:AnyObject]]) {
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Notes") }
         // because it's a small array, we're going to destroy and reload every time.
         noteGraphData.removeAll()
         var lastFoundIndex = 0

+ 6 - 5
LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift

@@ -12,7 +12,6 @@ import UIKit
 extension MainViewController {
     // NS Override Response Processor
     func processNSOverrides(entries: [[String:AnyObject]]) {
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Overrides") }
         overrideGraphData.removeAll()
         var activeOverrideNote: String? = nil
 
@@ -57,14 +56,16 @@ extension MainViewController {
                 if (low == nil && high != nil) || (low != nil && high == nil) { return }
                 range = [low ?? 0, high ?? 0]
             }
-            
+
+            // Limit displayed override duration to 'Hours of Prediction' after current time
             var endDate = dateTimeStamp + duration
-/*            if endDate > maxEndDate {
+            if endDate > maxEndDate {
                 endDate = maxEndDate
+                duration = endDate - dateTimeStamp
             }
-*/
+
             if dateTimeStamp <= now && now < endDate {
-                activeOverrideNote = currentEntry["notes"] as? String
+                activeOverrideNote = currentEntry["notes"] as? String ?? currentEntry["reason"] as? String
             }
 
             let dot = DataStructs.overrideStruct(insulNeedsScaleFactor: multiplier, date: dateTimeStamp, endDate: endDate, duration: duration, correctionRange: range, enteredBy: enteredBy, reason: reason, sgv: -20)

+ 0 - 1
LoopFollow/Controllers/Nightscout/Treatments/ResumePump.swift

@@ -10,7 +10,6 @@ import Foundation
 extension MainViewController {
     // NS Resume Pump Response Processor
     func processResumePump(entries: [[String:AnyObject]]) {
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Resume Pump") }
         resumeGraphData.removeAll()
         
         var lastFoundIndex = 0

+ 0 - 1
LoopFollow/Controllers/Nightscout/Treatments/SuspendPump.swift

@@ -10,7 +10,6 @@ import Foundation
 extension MainViewController {
     // NS Suspend Pump Response Processor
     func processSuspendPump(entries: [[String:AnyObject]]) {
-        if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Process: Suspend Pump") }
         suspendGraphData.removeAll()
         
         var lastFoundIndex = 0

+ 2 - 248
LoopFollow/Controllers/Timers.swift

@@ -9,94 +9,8 @@
 import Foundation
 import UIKit
 
-
 extension MainViewController {
-    
-    func restartAllTimers() {
-        if !bgTimer.isValid { startBGTimer(time: 2) }
-        if !profileTimer.isValid { startProfileTimer(time: 3) }
-        if !deviceStatusTimer.isValid { startDeviceStatusTimer(time: 4) }
-        if !treatmentsTimer.isValid { startTreatmentsTimer(time: 5) }
-        if !minAgoTimer.isValid { startMinAgoTimer(time: minAgoTimeInterval) }
-        if !calendarTimer.isValid { startCalendarTimer(time: 15) }
-        if !alarmTimer.isValid { startAlarmTimer(time: 30) }
-    }
-    
-    func invalidateTimers() {
-        bgTimer.invalidate()
-        profileTimer.invalidate()
-        deviceStatusTimer.invalidate()
-        treatmentsTimer.invalidate()
-        minAgoTimer.invalidate()
-        calendarTimer.invalidate()
-        alarmTimer.invalidate()
-    }
-    
-    // min Ago Timer
-    func startMinAgoTimer(time: TimeInterval) {
-        minAgoTimer = Timer.scheduledTimer(timeInterval: time,
-                                           target: self,
-                                           selector: #selector(MainViewController.minAgoTimerDidEnd(_:)),
-                                           userInfo: nil,
-                                           repeats: true)
-    }
-    
-    @objc func minAgoTimerDidEnd(_ timer: Timer) {
-        if bgData.count > 0 {
-            let bgSeconds = bgData.last!.date
-            let now = Date().timeIntervalSince1970
-            let secondsAgo = now - bgSeconds
-            
-            // Update Min Ago Displays
-            let formatter = DateComponentsFormatter()
-            formatter.unitsStyle = .positional // Use the appropriate positioning for the current locale
-            
-            if secondsAgo >= 720 { // 720 seconds = 12 minutes
-                formatter.allowedUnits = [.minute] // Only show minutes after 12 minutes have passed
-            } else if secondsAgo < 270 { // Less than 4.5 minutes
-                formatter.allowedUnits = [.minute] // Show only minutes if less than 4.5 minutes
-            } else {
-                formatter.allowedUnits = [.minute, .second] // Show minutes and seconds otherwise
-            }
-            
-            let formattedDuration = formatter.string(from: secondsAgo) ?? ""
-            let minAgoDisplayText = formattedDuration + " min ago"
-            
-            MinAgoText.text = minAgoDisplayText
-            latestMinAgoString = minAgoDisplayText
-            
-            if let snoozer = self.tabBarController!.viewControllers?[2] as? SnoozeViewController {
-                snoozer.MinAgoLabel.text = minAgoDisplayText
-                
-                // Start with the current BGLabel text
-                let bgLabelText = snoozer.BGLabel.text ?? ""
-                let attributeString = NSMutableAttributedString(string: bgLabelText)
-                
-                // Always apply the strikethrough style
-                attributeString.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: 0, length: attributeString.length))
-                
-                // Conditionally set the strikethrough color based on the freshness of the data
-                if secondsAgo >= 720 { // Data is stale
-                    attributeString.addAttribute(.strikethroughColor, value: UIColor.systemRed, range: NSRange(location: 0, length: attributeString.length))
-                } else { // Data is fresh
-                    attributeString.addAttribute(.strikethroughColor, value: UIColor.clear, range: NSRange(location: 0, length: attributeString.length))
-                }
-                
-                snoozer.BGLabel.attributedText = attributeString
-            }
-        } else {
-            MinAgoText.text = ""
-            latestMinAgoString = ""
-            
-            if let snoozer = self.tabBarController!.viewControllers?[2] as? SnoozeViewController {
-                snoozer.MinAgoLabel.text = ""
-                // Reset BGLabel to ensure no formatting is carried over
-                snoozer.BGLabel.text = ""
-                snoozer.BGLabel.attributedText = NSAttributedString(string: "")
-            }
-        }
-    }
-    
+
     // Runs a 60 second timer when an alarm is snoozed
     // Prevents the alarm from triggering again while saving the snooze time to settings
     // End function needs nothing done
@@ -127,109 +41,7 @@ extension MainViewController {
     
     @objc func checkAlarmTimerDidEnd(_ timer:Timer) {
     }
-    
-    // BG Timer
-    // Runs to 5:10 after last reading timestamp
-    // Failed or no reading re-attempts after 10 second delay
-    // Changes to 30 second increments after 7:00
-    // Changes to 1 minute increments after 10:00
-    // Changes to 5 minute increments after 20:00 stale data
-    func startBGTimer(time: TimeInterval =  60 * 5) {
-        bgTimer = Timer.scheduledTimer(timeInterval: time,
-                                               target: self,
-                                               selector: #selector(MainViewController.bgTimerDidEnd(_:)),
-                                               userInfo: nil,
-                                               repeats: false)
-    }
-    
-    @objc func bgTimerDidEnd(_ timer:Timer) {
-        // reset timer to 1 minute if settings aren't entered
-        if UserDefaultsRepository.shareUserName.value == "" && UserDefaultsRepository.sharePassword.value == "" && !IsNightscoutEnabled() {
-            startBGTimer(time: 60)
-            return
-        }
-        
-        if UserDefaultsRepository.shareUserName.value != "" && UserDefaultsRepository.sharePassword.value != "" {
-            webLoadDexShare()
-        } else {
-            webLoadNSBGData()
-        }
-        BackgroundAlertManager.shared.scheduleBackgroundAlert()
-    }
-    
-    // Device Status Timer
-    // Runs to 5:10 after last reading timestamp
-    // Failed or no update re-attempts after 10 second delay
-    // Changes to 30 second increments after 7:00
-    // Changes to 1 minute increments after 10:00
-    // Changes to 5 minute increments after 20:00 stale data
-    func startDeviceStatusTimer(time: TimeInterval =  60 * 5) {
-        deviceStatusTimer = Timer.scheduledTimer(timeInterval: time,
-                                               target: self,
-                                               selector: #selector(MainViewController.deviceStatusTimerDidEnd(_:)),
-                                               userInfo: nil,
-                                               repeats: false)
-    }
-    
-    @objc func deviceStatusTimerDidEnd(_ timer:Timer) {
-        
-        // reset timer to 1 minute if settings aren't entered
-        if !IsNightscoutEnabled() {
-            startDeviceStatusTimer(time: 60)
-            return
-        } else {
-            webLoadNSDeviceStatus()
-        }
-    }
-    
-    // Treatments Timer
-    // Runs on 2 minute intervals
-    // Pauses with stale BG data
-    func startTreatmentsTimer(time: TimeInterval =  60 * 2) {
-        treatmentsTimer = Timer.scheduledTimer(timeInterval: time,
-                                               target: self,
-                                               selector: #selector(MainViewController.treatmentsTimerDidEnd(_:)),
-                                               userInfo: nil,
-                                               repeats: false)
-    }
-    
-    @objc func treatmentsTimerDidEnd(_ timer:Timer) {
-        // reset timer to 1 minute if settings aren't entered
-        if !IsNightscoutEnabled() {
-            startTreatmentsTimer(time: 60)
-            return
-        }
-        
-        if IsNightscoutEnabled() && UserDefaultsRepository.downloadTreatments.value {
-            WebLoadNSTreatments()
-        }
-        startTreatmentsTimer()
-    }
-    
-    // Profile Timer
-    // Runs on 10 minute intervals
-    // Pauses with stale BG data
-    func startProfileTimer(time: TimeInterval =  60 * 10) {
-        profileTimer = Timer.scheduledTimer(timeInterval: time,
-                                               target: self,
-                                               selector: #selector(MainViewController.profileTimerDidEnd(_:)),
-                                               userInfo: nil,
-                                               repeats: false)
-    }
-    
-    @objc func profileTimerDidEnd(_ timer:Timer) {
-        // reset timer to 1 minute if settings aren't entered
-        if !IsNightscoutEnabled() {
-            startProfileTimer(time: 60)
-            return
-        }
-        
-        if IsNightscoutEnabled() {
-            webLoadNSProfile()
-            startProfileTimer()
-        }
-    }
-        
+
     // Cancel and reset the playing alarm if it has not been snoozed after 4 min 50 seconds.
     // This allows the next BG reading to either start the timer going or not fire if the situation has been resolved
     func startAlarmPlayingTimer(time: TimeInterval = 290) {
@@ -245,62 +57,4 @@ extension MainViewController {
             stopAlarmAtNextReading()
         }
     }
-    
-    
-    // Alarm Timer
-    // Run the alarm checker every 15 seconds
-    func startAlarmTimer(time: TimeInterval) {
-        alarmTimer = Timer.scheduledTimer(timeInterval: time,
-                                         target: self,
-                                         selector: #selector(MainViewController.alarmTimerDidEnd(_:)),
-                                         userInfo: nil,
-                                         repeats: true)
-        
-    }
-    
-    @objc func alarmTimerDidEnd(_ timer:Timer) {
-        if bgData.count > 0 {
-            self.checkAlarms(bgs: bgData)
-        }
-        if overrideGraphData.count > 0 {
-            self.checkOverrideAlarms()
-        }
-        if tempTargetGraphData.count > 0 {
-            self.checkTempTargetAlarms()
-        }
-    }
-    
-    // Calendar Timer
-    // Run the calendar writer every 30 seconds
-    func startCalendarTimer(time: TimeInterval) {
-        calendarTimer = Timer.scheduledTimer(timeInterval: time,
-                                         target: self,
-                                         selector: #selector(MainViewController.calendarTimerDidEnd(_:)),
-                                         userInfo: nil,
-                                         repeats: true)
-        
-    }
-    
-    @objc func calendarTimerDidEnd(_ timer:Timer) {
-        if UserDefaultsRepository.writeCalendarEvent.value && UserDefaultsRepository.calendarIdentifier.value != "" {
-            self.writeCalendar()
-        }
-    }
-    
-    
-    
-    // Timer to allow us to write min ago calendar entries but not update them every 30 seconds
-    func startCalTimer(time: TimeInterval) {
-        calTimer = Timer.scheduledTimer(timeInterval: time,
-                                        target: self,
-                                        selector: #selector(MainViewController.calTimerDidEnd(_:)),
-                                        userInfo: nil,
-                                        repeats: false)
-    }
-    
-    // Nothing should be done when this timer ends because it just blocks the calendar from writing when it's active
-    @objc func calTimerDidEnd(_ timer:Timer) {
-        
-    }
-    
 }

+ 44 - 0
LoopFollow/Dexcom/DexcomSettingsView.swift

@@ -0,0 +1,44 @@
+//
+//  DexcomSettingsView.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-18.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import SwiftUI
+
+struct DexcomSettingsView: View {
+    @ObservedObject var viewModel: DexcomSettingsViewModel
+    @Environment(\.presentationMode) var presentationMode
+
+    var body: some View {
+        NavigationView {
+            Form {
+                Section(header: Text("Dexcom Settings")) {
+                    TextField("User Name", text: $viewModel.userName)
+                        .autocapitalization(.none)
+                        .disableAutocorrection(true)
+
+                    TextField("Password", text: $viewModel.password)
+                        .autocapitalization(.none)
+                        .disableAutocorrection(true)
+
+                    Picker("Server", selection: $viewModel.server) {
+                        Text("US").tag("US")
+                        Text("NON-US").tag("NON-US")
+                    }
+                    .pickerStyle(SegmentedPickerStyle())
+                }
+            }
+            .navigationBarTitle("Dexcom Settings", displayMode: .inline)
+            .toolbar {
+                ToolbarItem(placement: .navigationBarTrailing) {
+                    Button("Done") {
+                        presentationMode.wrappedValue.dismiss()
+                    }
+                }
+            }
+        }
+    }
+}

+ 37 - 0
LoopFollow/Dexcom/DexcomSettingsViewModel.swift

@@ -0,0 +1,37 @@
+//
+//  DexcomSettingsViewModel.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-18.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import Combine
+
+class DexcomSettingsViewModel: ObservableObject {
+    @Published var userName: String = UserDefaultsRepository.shareUserName.value {
+        willSet {
+            if newValue != userName {
+                UserDefaultsRepository.shareUserName.value = newValue
+            }
+        }
+    }
+    @Published var password: String = UserDefaultsRepository.sharePassword.value {
+        willSet {
+            if newValue != password {
+                UserDefaultsRepository.sharePassword.value = newValue
+            }
+        }
+    }
+    @Published var server: String = UserDefaultsRepository.shareServer.value {
+        willSet {
+            if newValue != server {
+                UserDefaultsRepository.shareServer.value = newValue
+            }
+        }
+    }
+
+    init() {
+    }
+}

+ 5 - 2
LoopFollow/Helpers/BackgroundTaskAudio.swift

@@ -22,9 +22,11 @@ class BackgroundTask {
     func stopBackgroundTask() {
         NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
         player.stop()
+        LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true)
     }
     
     @objc fileprivate func interruptedAudio(_ notification: Notification) {
+        LogManager.shared.log(category: .general, message: "Silent audio interrupted")
         if notification.name == AVAudioSession.interruptionNotification && notification.userInfo != nil {
             var info = notification.userInfo!
             var intValue = 0
@@ -46,8 +48,9 @@ class BackgroundTask {
             self.player.volume = 0.01
             self.player.prepareToPlay()
             self.player.play()
-            print("silent audio playing")
-        } catch { print(error)
+            LogManager.shared.log(category: .general, message: "Silent audio playing", isDebug: true)
+        } catch {
+            LogManager.shared.log(category: .general, message: "playAudio, error: \(error)")
         }
     }
 }

+ 101 - 29
LoopFollow/Helpers/NightscoutUtils.swift

@@ -47,6 +47,8 @@ class NightscoutUtils {
         case treatments
         case deviceStatus
         case iage = "Insulin Change"
+        case temporaryOverride = "Temporary Override"
+        case temporaryOverrideCancel = "Temporary Override Cancel"
 
         var endpoint: String {
             switch self {
@@ -58,6 +60,8 @@ class NightscoutUtils {
                 return "/api/v1/profile/current.json"
             case .deviceStatus:
                 return "/api/v1/devicestatus.json"
+            case .temporaryOverride, .temporaryOverrideCancel:
+                return "/api/v2/notifications/loop"
             }
         }
     }
@@ -167,27 +171,28 @@ class NightscoutUtils {
         return components?.url
     }
 
-    static func verifyURLAndToken(completion: @escaping (NightscoutError?, String?, Bool) -> Void) {
+    static func verifyURLAndToken(completion: @escaping (NightscoutError?, String?, Bool, Bool) -> Void) {
         let urlUser = ObservableUserDefaults.shared.url.value
         let token = UserDefaultsRepository.token.value
 
         if urlUser.isEmpty {
-            completion(.emptyAddress, nil, false)
+            completion(.emptyAddress, nil, false, false)
             return
         }
 
         guard let _ = URL(string: urlUser), urlUser.hasPrefix("http://") || urlUser.hasPrefix("https://") else {
-            completion(.invalidURL, nil, false)
+            completion(.invalidURL, nil, false, false)
             return
         }
 
         guard let request = createURLRequest(url: urlUser, token: token, path: "/api/v1/status.json") else {
-            completion(.invalidURL, nil, false)
+            completion(.invalidURL, nil, false, false)
             return
         }
 
         let task = URLSession.shared.dataTask(with: request) { data, response, error in
             var nsWriteAuth = false
+            var nsAdminAuth = false
 
             if let httpResponse = response as? HTTPURLResponse {
                 switch httpResponse.statusCode {
@@ -201,57 +206,67 @@ class NightscoutUtils {
 
                                 if permissionGroups.contains(where: { $0.contains("*") }) {
                                     nsWriteAuth = true
+                                    nsAdminAuth = true
                                 } else if permissionGroups.contains(where: { $0.contains("api:treatments:create") }) {
                                     nsWriteAuth = true
                                 }
-                                completion(nil, token, nsWriteAuth)
+                                completion(nil, token, nsWriteAuth, nsAdminAuth)
                             } else {
-                                completion(nil, nil, false)
+                                completion(nil, nil, false, false)
                             }
                         } catch {
-                            completion(nil, nil, false)
+                            completion(nil, nil, false, false)
                         }
                     } else {
-                        completion(nil, nil, false)
+                        completion(nil, nil, false, false)
                     }
                 case 401:
                     if token.isEmpty {
-                        completion(.tokenRequired, nil, false)
+                        completion(.tokenRequired, nil, false, false)
                     } else {
-                        completion(.invalidToken, nil, false)
+                        completion(.invalidToken, nil, false, false)
                     }
                 default:
-                    completion(.unknown, nil, false)
+                    completion(.unknown, nil, false, false)
                 }
             } else {
                 if let _ = error {
-                    completion(.siteNotFound, nil, false)
+                    completion(.siteNotFound, nil, false, false)
                 } else {
-                    completion(.networkError, nil, false)
+                    completion(.networkError, nil, false, false)
                 }
             }
         }
         task.resume()
     }
 
-    static func parseDate(_ dateString: String) -> Date? {
-        let dateFormatterWithMilliseconds = DateFormatter()
-        dateFormatterWithMilliseconds.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
-        dateFormatterWithMilliseconds.timeZone = TimeZone(abbreviation: "UTC")
-        dateFormatterWithMilliseconds.locale = Locale(identifier: "en_US_POSIX")
-
-        let dateFormatterWithoutMilliseconds = DateFormatter()
-        dateFormatterWithoutMilliseconds.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
-        dateFormatterWithoutMilliseconds.timeZone = TimeZone(abbreviation: "UTC")
-        dateFormatterWithoutMilliseconds.locale = Locale(identifier: "en_US_POSIX")
-
-        if let date = dateFormatterWithMilliseconds.date(from: dateString) {
-            return date
-        } else if let date = dateFormatterWithoutMilliseconds.date(from: dateString) {
-            return date
+    static func parseDate(_ rawString: String) -> Date? {
+        var mutableDate = rawString
+
+        if mutableDate.hasSuffix("Z") {
+            mutableDate = String(mutableDate.dropLast())
+        }
+        else if let offsetRange = mutableDate.range(of: "[\\+\\-]\\d{2}:\\d{2}$",
+                                                    options: .regularExpression) {
+            mutableDate.removeSubrange(offsetRange)
         }
 
-        return nil
+        mutableDate = mutableDate.replacingOccurrences(
+            of: "\\.\\d+",
+            with: "",
+            options: .regularExpression
+        )
+
+        let dateFormatter = DateFormatter()
+        dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
+        dateFormatter.locale = Locale(identifier: "en_US")
+        dateFormatter.timeZone = TimeZone(abbreviation: "UTC")
+
+        let result = dateFormatter.date(from: mutableDate)
+        if result == nil {
+            print("Unable to parse string: '\(mutableDate)'")
+        }
+        return result
     }
 
     static func retrieveJWTToken() async throws -> String {
@@ -323,4 +338,61 @@ class NightscoutUtils {
         let decoder = JSONDecoder()
         return try decoder.decode(T.self, from: data)
     }
+
+    static func executePostRequest(eventType: EventType, body: [String: Any]) async throws -> String {
+        let jwtToken = try await retrieveJWTToken()
+        let baseURL = ObservableUserDefaults.shared.url.value
+
+        guard let url = URL(string: "\(baseURL)\(eventType.endpoint)") else {
+            throw NightscoutError.invalidURL
+        }
+
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization")
+        request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
+
+        let sessionConfig = URLSessionConfiguration.default
+        sessionConfig.waitsForConnectivity = true
+        sessionConfig.networkServiceType = .responsiveData
+        let session = URLSession(configuration: sessionConfig)
+
+        let (data, response) = try await session.data(for: request)
+
+        var responseString : String
+        responseString = String(data: data, encoding: .utf8) ?? ""
+        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+            if responseString != "" {
+                return responseString
+            } else {
+                throw NightscoutError.networkError
+            }
+        }
+
+        return responseString
+    }
+
+    static func extractErrorReason(from responseString: String) -> String {
+        // 1) Try to parse the entire string as JSON and return the "message"
+        if let data = responseString.data(using: .utf8) {
+            if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
+               let message = json["message"] as? String {
+                return message
+            }
+        }
+
+        // 2) If not valid JSON (or no "message"), try to parse it as HTML <title>
+        if let startRange = responseString.range(of: "<title>"),
+           let endRange = responseString.range(of: "</title>") {
+            let titleRange = startRange.upperBound..<endRange.lowerBound
+            let titleContent = responseString[titleRange].trimmingCharacters(in: .whitespacesAndNewlines)
+            if !titleContent.isEmpty {
+                return titleContent
+            }
+        }
+
+        // 3) Fallback: just return the entire raw string
+        return responseString
+    }
 }

+ 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">
 <plist version="1.0">
 <dict>
+	<key>NSHumanReadableCopyright</key>
+	<string></string>
 	<key>AppGroupIdentifier</key>
 	<string>group.com.$(unique_id).LoopFollow$(app_suffix)</string>
 	<key>BGTaskSchedulerPermittedIdentifiers</key>
@@ -14,6 +16,8 @@
 	<string>$(display_name)</string>
 	<key>CFBundleExecutable</key>
 	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleGetInfoString</key>
+	<string></string>
 	<key>CFBundleIdentifier</key>
 	<string>com.$(unique_id).LoopFollow$(app_suffix)</string>
 	<key>CFBundleInfoDictionaryVersion</key>
@@ -43,10 +47,14 @@
 	<string>13.0</string>
 	<key>LSRequiresIPhoneOS</key>
 	<true/>
+	<key>NSBluetoothAlwaysUsageDescription</key>
+	<string>This app uses Bluetooth to connect to devices for managing background operations.</string>
 	<key>NSCalendarsFullAccessUsageDescription</key>
 	<string>Loop Follow would like to access your calendar to update BG readings</string>
 	<key>NSCalendarsUsageDescription</key>
 	<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>
 	<string>This app requires Face ID for secure authentication.</string>
 	<key>UIApplicationSceneManifest</key>
@@ -72,6 +80,7 @@
 	<array>
 		<string>audio</string>
 		<string>processing</string>
+		<string>bluetooth-central</string>
 	</array>
 	<key>UILaunchStoryboardName</key>
 	<string>LaunchScreen</string>
@@ -95,8 +104,6 @@
 	<array>
 		<string>UIInterfaceOrientationPortrait</string>
 	</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>
 	<array>
 		<string>UIInterfaceOrientationPortrait</string>
@@ -104,5 +111,9 @@
 		<string>UIInterfaceOrientationLandscapeLeft</string>
 		<string>UIInterfaceOrientationLandscapeRight</string>
 	</array>
+    <key>UIFileSharingEnabled</key>
+    <true/>
+    <key>LSSupportsOpeningDocumentsInPlace</key>
+    <true/>
 </dict>
 </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
+}

+ 107 - 0
LoopFollow/Log/LogManager.swift

@@ -0,0 +1,107 @@
+//
+//  LogManager.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-10.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+class LogManager {
+    static let shared = LogManager()
+
+    private let fileManager = FileManager.default
+    private let logDirectory: URL
+    private let dateFormatter: DateFormatter
+    private let consoleQueue = DispatchQueue(label: "com.loopfollow.log.console", qos: .background)
+
+    enum Category: String, CaseIterable {
+        case bluetooth = "Bluetooth"
+        case nightscout = "Nightscout"
+        case apns = "APNS"
+        case general = "General"
+        case contact = "Contact"
+        case taskScheduler = "Task Scheduler"
+        case dexcom = "Dexcom"
+        case alarm = "Alarm"
+        case calendar = "Calendar"
+        case deviceStatus = "Device Status"
+    }
+
+    init() {
+        // Create log directory in the app's Documents folder
+        let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
+        logDirectory = documentsDirectory.appendingPathComponent("Logs")
+
+        // Ensure the directory exists
+        if !fileManager.fileExists(atPath: logDirectory.path) {
+            try? fileManager.createDirectory(at: logDirectory, withIntermediateDirectories: true, attributes: nil)
+        }
+
+        dateFormatter = DateFormatter()
+        dateFormatter.dateFormat = "yyyy-MM-dd"
+    }
+
+    func log(category: Category, message: String, isDebug: Bool = false) {
+        let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
+        let logMessage = "[\(timestamp)] [\(category.rawValue)] \(message)"
+
+        consoleQueue.async {
+            print(logMessage)
+        }
+
+        if !isDebug || Storage.shared.debugLogLevel.value {
+            let logFileURL = self.currentLogFileURL
+            self.append(logMessage + "\n", to: logFileURL)
+        }
+    }
+
+    func cleanupOldLogs() {
+        let today = dateFormatter.string(from: Date())
+        let yesterday = dateFormatter.string(from: Calendar.current.date(byAdding: .day, value: -1, to: Date())!)
+
+        do {
+            let logFiles = try fileManager.contentsOfDirectory(at: logDirectory, includingPropertiesForKeys: nil)
+            for logFile in logFiles {
+                let filename = logFile.lastPathComponent
+                if !filename.contains(today) && !filename.contains(yesterday) {
+                    try fileManager.removeItem(at: logFile)
+                }
+            }
+        } catch {
+            print("Failed to clean up old logs: \(error)")
+        }
+    }
+
+    func logFileURL(for date: Date) -> URL {
+        let dateString = dateFormatter.string(from: date)
+        return logDirectory.appendingPathComponent("LoopFollow \(dateString).log")
+    }
+
+    func logFilesForTodayAndYesterday() -> [URL] {
+        let today = logFileURL(for: Date())
+        let yesterday = logFileURL(for: Calendar.current.date(byAdding: .day, value: -1, to: Date())!)
+        return [today, yesterday].filter { fileManager.fileExists(atPath: $0.path) }
+    }
+
+    var currentLogFileURL: URL {
+        return logFileURL(for: Date())
+    }
+    
+    private func append(_ message: String, to fileURL: URL) {
+        if !fileManager.fileExists(atPath: fileURL.path) {
+            fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: nil)
+        }
+
+        if let fileHandle = try? FileHandle(forWritingTo: fileURL) {
+            defer { fileHandle.closeFile() }
+            fileHandle.seekToEndOfFile()
+            if let data = message.data(using: .utf8) {
+                fileHandle.write(data)
+            }
+        } else {
+            print("Failed to open log file at \(fileURL.path)")
+        }
+    }
+}

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

+ 103 - 0
LoopFollow/Log/LogViewModel.swift

@@ -0,0 +1,103 @@
+//
+//  LogViewModel.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-13.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import Combine
+
+class LogViewModel: ObservableObject {
+    @Published var allLogEntries: [LogEntry] = []
+    @Published var filteredLogEntries: [LogEntry] = []
+    @Published var selectedCategory: LogManager.Category? = nil
+    @Published var searchText: String = ""
+
+    private var cancellables = Set<AnyCancellable>()
+
+    init() {
+        Publishers.CombineLatest($selectedCategory, $searchText)
+            .sink { [weak self] category, search in
+                self?.filterLogs(category: category, searchText: search)
+            }
+            .store(in: &cancellables)
+
+        loadLogEntries()
+
+        Timer.publish(every: 5.0, on: .main, in: .common)
+            .autoconnect()
+            .sink { [weak self] _ in
+                self?.loadLogEntries()
+            }
+            .store(in: &cancellables)
+    }
+
+    func loadLogEntries() {
+        DispatchQueue.global(qos: .background).async { [weak self] in
+            guard let self = self else { return }
+            let logManager = LogManager.shared
+            let logFileURL = logManager.currentLogFileURL
+
+            guard FileManager.default.fileExists(atPath: logFileURL.path) else {
+                DispatchQueue.main.async {
+                    self.allLogEntries = []
+                    self.filteredLogEntries = []
+                }
+                return
+            }
+
+            do {
+                let logContent = try String(contentsOf: logFileURL, encoding: .utf8)
+                var logLines = logContent.components(separatedBy: .newlines)
+                logLines = logLines.filter { !$0.isEmpty }
+
+                // Reverse the log lines to have newest first
+                logLines.reverse()
+
+                let uniqueLogEntries = logLines.map { LogEntry(id: UUID(), text: $0) }
+
+                DispatchQueue.main.async {
+                    self.allLogEntries = uniqueLogEntries
+                    self.filterLogs(category: self.selectedCategory, searchText: self.searchText)
+                }
+            } catch {
+                print("Error reading log file: \(error)")
+                DispatchQueue.main.async {
+                    self.allLogEntries = []
+                    self.filteredLogEntries = []
+                }
+            }
+        }
+    }
+
+    private func filterLogs(category: LogManager.Category?, searchText: String) {
+        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+            guard let self = self else { return }
+            var filtered = self.allLogEntries
+
+            // Filter by category and remove category tag
+            if let category = category {
+                let categoryTag = "[\(category.rawValue)] "
+                filtered = filtered.filter { $0.text.contains(categoryTag) }
+                    .map { logEntry in
+                        var text = logEntry.text
+                        if let range = text.range(of: categoryTag) {
+                            text.removeSubrange(range)
+                        }
+                        return LogEntry(id: logEntry.id, text: text.trimmingCharacters(in: .whitespaces))
+                    }
+            }
+
+            // Filter by search text
+            if !searchText.isEmpty {
+                filtered = filtered.filter { $0.text.localizedCaseInsensitiveContains(searchText) }
+            }
+
+            DispatchQueue.main.async {
+                self.filteredLogEntries = filtered
+            }
+        }
+    }
+}

+ 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>
 	<key>com.apple.security.app-sandbox</key>
 	<true/>
+	<key>com.apple.security.device.bluetooth</key>
+	<true/>
 	<key>com.apple.security.network.client</key>
 	<true/>
 	<key>com.apple.security.personal-information.calendars</key>

+ 70 - 0
LoopFollow/Nightscout/NightscoutSettingsView.swift

@@ -0,0 +1,70 @@
+//
+//  NightscoutSettingsView.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-18.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import SwiftUI
+
+struct NightscoutSettingsView: View {
+    @ObservedObject var viewModel: NightscoutSettingsViewModel
+    @Environment(\.presentationMode) var presentationMode
+
+    var body: some View {
+        NavigationView {
+            Form {
+                urlSection
+                tokenSection
+                statusSection
+            }
+            .navigationBarTitle("Nightscout Settings", displayMode: .inline)
+            .toolbar {
+                ToolbarItem(placement: .navigationBarTrailing) {
+                    Button("Done") {
+                        presentationMode.wrappedValue.dismiss()
+                    }
+                }
+            }
+            .onDisappear {
+                viewModel.dismiss()
+            }
+        }
+    }
+
+    // MARK: - Subviews / Computed Properties
+
+    private var urlSection: some View {
+        Section {
+            TextField("URL", text: $viewModel.nightscoutURL)
+                .textContentType(.URL)
+                .autocapitalization(.none)
+                .disableAutocorrection(true)
+                .onChange(of: viewModel.nightscoutURL) { newValue in
+                    viewModel.processURL(newValue)
+                }
+        } header: {
+            Text("URL")
+        }
+    }
+
+    private var tokenSection: some View {
+        Section {
+            TextField("Token", text: $viewModel.nightscoutToken)
+                .textContentType(.password)
+                .autocapitalization(.none)
+                .disableAutocorrection(true)
+        } header: {
+            Text("Token")
+        }
+    }
+
+    private var statusSection: some View {
+        Section {
+            Text(viewModel.nightscoutStatus)
+        } header: {
+            Text("Status")
+        }
+    }
+}

+ 148 - 0
LoopFollow/Nightscout/NightscoutSettingsViewModel.swift

@@ -0,0 +1,148 @@
+//
+//  NightscoutSettingsViewModel.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-18.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import Combine
+
+protocol NightscoutSettingsViewModelDelegate: AnyObject {
+    func nightscoutSettingsDidFinish()
+}
+
+class NightscoutSettingsViewModel: ObservableObject {
+    weak var delegate: NightscoutSettingsViewModelDelegate?
+
+    private var initialURL: String
+    private var initialToken: String
+
+    @Published var nightscoutURL: String = ObservableUserDefaults.shared.url.value {
+        willSet {
+            if newValue != nightscoutURL {
+                ObservableUserDefaults.shared.url.value = newValue
+                triggerCheckStatus()
+            }
+        }
+    }
+    @Published var nightscoutToken: String = UserDefaultsRepository.token.value {
+        willSet {
+            if newValue != nightscoutToken {
+                UserDefaultsRepository.token.value = newValue
+                triggerCheckStatus()
+            }
+        }
+    }
+    @Published var nightscoutStatus: String = "Checking..."
+
+    private var cancellables = Set<AnyCancellable>()
+    private var checkStatusSubject = PassthroughSubject<Void, Never>()
+    private var checkStatusWorkItem: DispatchWorkItem?
+
+    init() {
+        self.initialURL = ObservableUserDefaults.shared.url.value
+        self.initialToken = UserDefaultsRepository.token.value
+
+        setupDebounce()
+        checkNightscoutStatus()
+    }
+
+    private func setupDebounce() {
+        checkStatusSubject
+            .debounce(for: .seconds(2), scheduler: DispatchQueue.main)
+            .sink { [weak self] in
+                self?.checkNightscoutStatus()
+            }
+            .store(in: &cancellables)
+    }
+
+    private func triggerCheckStatus() {
+        checkStatusWorkItem?.cancel()
+
+        nightscoutStatus = "Checking..."
+
+        checkStatusWorkItem = DispatchWorkItem {
+            self.checkStatusSubject.send()
+        }
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: checkStatusWorkItem!)
+    }
+
+    func processURL(_ value: String) {
+        var useTokenUrl = false
+
+        if let urlComponents = URLComponents(string: value), let queryItems = urlComponents.queryItems {
+            if let tokenItem = queryItems.first(where: { $0.name.lowercased() == "token" }) {
+                let tokenPattern = "^[^-\\s]+-[0-9a-fA-F]{16}$"
+                if let token = tokenItem.value, let _ = token.range(of: tokenPattern, options: .regularExpression) {
+                    var baseComponents = urlComponents
+                    baseComponents.queryItems = nil
+                    if let baseURL = baseComponents.string {
+                        nightscoutToken = token
+                        nightscoutURL = baseURL
+                        useTokenUrl = true
+                    }
+                }
+            }
+        }
+
+        if !useTokenUrl {
+            let filtered = value.replacingOccurrences(of: "[^A-Za-z0-9:/._-]", with: "", options: .regularExpression).lowercased()
+            var cleanURL = filtered
+            while cleanURL.count > 8 && cleanURL.last == "/" {
+                cleanURL = String(cleanURL.dropLast())
+            }
+            nightscoutURL = cleanURL
+        }
+    }
+
+    func checkNightscoutStatus() {
+        NightscoutUtils.verifyURLAndToken { error, jwtToken, nsWriteAuth, nsAdminAuth in
+            DispatchQueue.main.async {
+                ObservableUserDefaults.shared.nsWriteAuth.value = nsWriteAuth
+                ObservableUserDefaults.shared.nsAdminAuth.value = nsAdminAuth
+
+                self.updateStatusLabel(error: error)
+            }
+        }
+    }
+
+    func updateStatusLabel(error: NightscoutUtils.NightscoutError?) {
+        if let error = error {
+            switch error {
+            case .invalidURL:
+                nightscoutStatus = "Invalid URL"
+            case .networkError:
+                nightscoutStatus = "Network Error"
+            case .invalidToken:
+                nightscoutStatus = "Invalid Token"
+            case .tokenRequired:
+                nightscoutStatus = "Token Required"
+            case .siteNotFound:
+                nightscoutStatus = "Site Not Found"
+            case .unknown:
+                nightscoutStatus = "Unknown Error"
+            case .emptyAddress:
+                nightscoutStatus = "Address Empty"
+            }
+        } else {
+            let authStatus: String
+            if ObservableUserDefaults.shared.nsAdminAuth.value {
+                authStatus = "Admin"
+            } else {
+                authStatus = "Read" + (ObservableUserDefaults.shared.nsWriteAuth.value ? " & Write" : "")
+            }
+
+            nightscoutStatus = "OK (\(authStatus))"
+
+            if (nightscoutURL != initialURL || nightscoutToken != initialToken) {
+                NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
+            }
+        }
+    }
+
+    func dismiss() {
+        delegate?.nightscoutSettingsDidFinish()
+    }
+}

+ 38 - 0
LoopFollow/Remote/Loop/LoopNightscoutRemoteView.swift

@@ -0,0 +1,38 @@
+//
+//  LoopNightscoutRemoteView.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-14.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import SwiftUI
+
+struct LoopNightscoutRemoteView: View {
+    @Environment(\.presentationMode) var presentationMode
+    @ObservedObject var nsAdmin = ObservableUserDefaults.shared.nsWriteAuth
+
+    var body: some View {
+        NavigationView {
+            if !nsAdmin.value {
+                ErrorMessageView(
+                    message: "Please update your token to include the 'admin' role in order to do remote commands with Loop."
+                )} else {
+                    VStack {
+                        let columns = [
+                            GridItem(.flexible(), spacing: 16),
+                            GridItem(.flexible(), spacing: 16)
+                        ]
+
+                        LazyVGrid(columns: columns, spacing: 16) {
+                            CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: LoopOverrideView())
+                        }
+                        .padding(.horizontal)
+
+                        Spacer()
+                    }
+                    .navigationBarTitle("Loop Remote Control", displayMode: .inline)
+                }
+        }
+    }
+}

+ 226 - 0
LoopFollow/Remote/Loop/LoopOverrideView.swift

@@ -0,0 +1,226 @@
+//
+//  LoopOverrideView.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-14.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import SwiftUI
+import HealthKit
+
+struct LoopOverrideView: View {
+    @Environment(\.presentationMode) private var presentationMode
+
+    @ObservedObject var device = ObservableUserDefaults.shared.device
+    @ObservedObject var overrideNote = Observable.shared.override
+    @ObservedObject var nsAdmin = ObservableUserDefaults.shared.nsWriteAuth
+
+    @StateObject private var viewModel = LoopOverrideViewModel()
+
+    @State private var showAlert: Bool = false
+    @State private var alertType: AlertType? = nil
+    @State private var alertMessage: String? = nil
+    @State private var isLoading: Bool = false
+    @State private var statusMessage: String? = nil
+
+    @State private var selectedOverride: ProfileManager.LoopOverride? = nil
+    @State private var showConfirmation: Bool = false
+
+    @FocusState private var noteFieldIsFocused: Bool
+
+    private let pushNotificationManager = PushNotificationManager()
+    private var profileManager = ProfileManager.shared
+
+    enum AlertType {
+        case confirmActivation
+        case confirmCancellation
+        case statusSuccess
+        case statusFailure
+        case validation
+    }
+
+    var body: some View {
+        NavigationView {
+            VStack {
+                if device.value != "Loop" {
+                    ErrorMessageView(
+                        message: "Remote commands are currently only available for Loop."
+                    )
+                } else if !nsAdmin.value {
+                    ErrorMessageView(
+                        message: "Please update your token to include the 'admin' role in order to do remote commands with Loop."
+                    )
+                } else {
+
+                    Form {
+                        if let activeNote = overrideNote.value {
+                            Section(header: Text("Active Override")) {
+                                HStack {
+                                    Text("Override")
+                                    Spacer()
+                                    Text(activeNote)
+                                        .foregroundColor(.secondary)
+                                }
+                                Button {
+                                    alertType = .confirmCancellation
+                                    showAlert = true
+                                } label: {
+                                    HStack {
+                                        Text("Cancel Override")
+                                        Spacer()
+                                        Image(systemName: "xmark.app")
+                                            .font(.title)
+                                    }
+                                }
+                                .tint(.red)
+                            }
+                            Section {
+                                Text("Setting a new override while one is active will cancel the active override and set the new one.")
+                                    .foregroundColor(.secondary)
+                            }
+                        }
+
+                        Section(header: Text("Available Overrides")) {
+                            if profileManager.loopOverrides.isEmpty {
+                                Text("No overrides available.")
+                                    .foregroundColor(.secondary)
+                            } else {
+                                ForEach(profileManager.loopOverrides, id: \.name) { override in
+                                    Button(action: {
+                                        selectedOverride = override
+                                        alertType = .confirmActivation
+                                        showAlert = true
+                                    }) {
+                                        HStack {
+                                            VStack(alignment: .leading) {
+                                                Text("\(override.symbol) \(override.name)")
+                                                    .font(.headline)
+                                                Text("Duration: \(formattedDuration(from: override.duration))")
+                                                    .font(.subheadline)
+                                                    .foregroundColor(.secondary)
+                                                Text("Insulin Scale Factor: \(String(format: "%.2f", override.insulinNeedsScaleFactor))")
+                                                    .font(.subheadline)
+                                                    .foregroundColor(.secondary)
+                                                if !override.targetRange.isEmpty {
+                                                    let range = override.targetRange.map { Localizer.formatQuantity($0) }.joined(separator: " - ")
+                                                    Text("Target Range: \(range) \(UserDefaultsRepository.getPreferredUnit().localizedShortUnitString)")
+                                                        .font(.subheadline)
+                                                        .foregroundColor(.secondary)
+                                                }
+                                            }
+                                            Spacer()
+                                            Image(systemName: "arrow.right.circle")
+                                                .foregroundColor(.blue)
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+
+                    if isLoading {
+                        ProgressView("Please wait...")
+                            .padding()
+                    }
+                }
+            }
+            .navigationTitle("Loop Overrides")
+            .navigationBarTitleDisplayMode(.inline)
+            .alert(isPresented: $showAlert) {
+                switch alertType {
+                case .confirmActivation:
+                    return Alert(
+                        title: Text("Activate Override"),
+                        message: Text("Do you want to activate the override '\(selectedOverride?.name ?? "")'?"),
+                        primaryButton: .default(Text("Confirm"), action: {
+                            if let override = selectedOverride {
+                                activateOverride(override: override)
+                            }
+                        }),
+                        secondaryButton: .cancel()
+                    )
+                case .confirmCancellation:
+                    return Alert(
+                        title: Text("Cancel Override"),
+                        message: Text("Are you sure you want to cancel the active override?"),
+                        primaryButton: .default(Text("Confirm"), action: {
+                            cancelOverride()
+                        }),
+                        secondaryButton: .cancel()
+                    )
+                case .statusSuccess:
+                    return Alert(
+                        title: Text("Success"),
+                        message: Text(statusMessage ?? ""),
+                        dismissButton: .default(Text("OK"), action: {
+                            presentationMode.wrappedValue.dismiss()
+                        })
+                    )
+                case .statusFailure:
+                    return Alert(
+                        title: Text("Error"),
+                        message: Text(statusMessage ?? "An error occurred."),
+                        dismissButton: .default(Text("OK"))
+                    )
+                case .validation:
+                    return Alert(
+                        title: Text("Validation Error"),
+                        message: Text(alertMessage ?? "Invalid input."),
+                        dismissButton: .default(Text("OK"))
+                    )
+                case .none:
+                    return Alert(title: Text("Unknown Alert"))
+                }
+            }
+        }
+    }
+
+    // MARK: - Functions
+    private func formattedDuration(from duration: Int?) -> String {
+        guard let duration = duration, duration != 0 else {
+            return "Indefinitely"
+        }
+
+        let hours = duration / 3600
+        let minutes = (duration % 3600) / 60
+
+        if hours > 0 && minutes > 0 {
+            return "\(hours) hr, \(minutes) min"
+        } else if hours > 0 {
+            return "\(hours) hr"
+        } else {
+            return "\(minutes) min"
+        }
+    }
+
+    private func activateOverride(override: ProfileManager.LoopOverride) {
+        isLoading = true
+        viewModel.sendActivateOverrideRequest(override: override) { success, message in
+            self.isLoading = false
+            if success {
+                self.statusMessage = "Override command sent successfully."
+                self.alertType = .statusSuccess
+            } else {
+                self.statusMessage = message ?? "Failed to send override command."
+                self.alertType = .statusFailure
+            }
+            self.showAlert = true
+        }
+    }
+
+    private func cancelOverride() {
+        isLoading = true
+        viewModel.sendCancelOverrideRequest { success, message in
+            self.isLoading = false
+            if success {
+                self.statusMessage = "Cancellation request successfully sent to Nightscout."
+                self.alertType = .statusSuccess
+            } else {
+                self.statusMessage = message ?? "Failed to cancel temp target."
+                self.alertType = .statusFailure
+            }
+            self.showAlert = true
+        }
+    }
+}

+ 82 - 0
LoopFollow/Remote/Loop/LoopOverrideViewModel.swift

@@ -0,0 +1,82 @@
+//
+//  LoopOverrideViewModel.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-15.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+final class LoopOverrideViewModel: ObservableObject, Sendable {
+    func sendActivateOverrideRequest(
+        override: ProfileManager.LoopOverride,
+        completion: @escaping (Bool, String?) -> Void
+    ) {
+        Task {
+            let body: [String: Any] = [
+                "eventType": "Temporary Override",
+                "enteredBy": Storage.shared.user.value,
+                "reason": override.name,
+                "reasonDisplay": "\(override.symbol) \(override.name)"
+            ]
+
+            do {
+                let response: String = try await NightscoutUtils.executePostRequest(eventType: .temporaryOverride, body: body)
+                DispatchQueue.main.async {
+                    if response == "OK" {
+                        Observable.shared.override.value = nil
+                        NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
+                        completion(true, response)
+                    } else {
+                        let errorTitle = NightscoutUtils.extractErrorReason(from: response)
+                        let formattedError = self.formatErrorMessage(errorTitle)
+                        completion(false, formattedError)
+                    }
+                }
+            } catch {
+                DispatchQueue.main.async {
+                    completion(false, nil)
+                }
+            }
+        }
+    }
+
+    func sendCancelOverrideRequest(completion: @escaping (Bool, String?) -> Void) {
+        Task {
+            let body: [String: Any] = [
+                "eventType": "Temporary Override Cancel"
+            ]
+
+            do {
+                let response: String = try await NightscoutUtils.executePostRequest(eventType: .temporaryOverrideCancel, body: body)
+                DispatchQueue.main.async {
+                    if response == "OK" {
+                        Observable.shared.override.value = nil
+                        NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
+                        completion(true, response)
+                    } else {
+                        let errorTitle = NightscoutUtils.extractErrorReason(from: response)
+                        let formattedError = self.formatErrorMessage(errorTitle)
+                        completion(false, formattedError)
+                    }
+                }
+            } catch {
+                DispatchQueue.main.async {
+                    completion(false, nil)
+                }
+            }
+        }
+    }
+
+    func formatErrorMessage(_ errorTitle: String) -> String {
+        switch errorTitle {
+        case "Unauthorized":
+            return "Unauthorized, verify that your token is correct and has admin auth"
+        case "APNs delivery failed: BadDeviceToken":
+            return "APNs delivery failed: BadDeviceToken, verify that the LOOP_PUSH_SERVER_ENVIRONMENT parameter in your Nightscout setup matches the build method used for Loop"
+        default:
+            return errorTitle
+        }
+    }
+}

+ 1 - 1
LoopFollow/Remote/TrioNightscoutRemoteView.swift

@@ -52,7 +52,7 @@ struct TrioNightscoutRemoteView: View {
                     )
                 } else if !nsWriteAuth.value {
                     ErrorMessageView(
-                        message: "Please update your token to include the 'careportal' and 'readable' roles in order to do remote commands."
+                        message: "Please update your token to include the 'careportal' and 'readable' roles in order to do remote commands with Trio."
                     )
                 } else {
                     Form {

+ 23 - 0
LoopFollow/Remote/NoRemoteView.swift

@@ -0,0 +1,23 @@
+//
+//  NoRemoteView.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-14.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import SwiftUI
+
+struct NoRemoteView: View {
+    private let remoteController = TrioNightscoutRemoteController()
+
+    var body: some View {
+        NavigationView {
+            VStack {
+                ErrorMessageView(
+                    message: "Remote commands are currently only available for Trio and Loop."
+                )
+            }
+        }
+    }
+}

+ 33 - 11
LoopFollow/Remote/RemoteViewController.swift

@@ -20,12 +20,15 @@ class RemoteViewController: UIViewController {
     override func viewDidLoad() {
         super.viewDidLoad()
 
-        cancellable = Storage.shared.remoteType.objectWillChange
-            .sink { [weak self] _ in
-                DispatchQueue.main.async {
-                    self?.updateView()
-                }
+        cancellable = Publishers.CombineLatest(
+            Storage.shared.remoteType.$value,
+            ObservableUserDefaults.shared.device.$value
+        )
+        .sink { [weak self] newRemoteType, newDevice in
+            DispatchQueue.main.async {
+                self?.updateView()
             }
+        }
 
         updateView()
     }
@@ -40,12 +43,30 @@ class RemoteViewController: UIViewController {
         }
 
         if remoteType == .nightscout {
-            let remoteView = TrioNightscoutRemoteView()
-            hostingController = UIHostingController(rootView: AnyView(remoteView))
+            var remoteView: AnyView
+
+            switch ObservableUserDefaults.shared.device.value {
+            case "Trio":
+                remoteView = AnyView(TrioNightscoutRemoteView())
+            case "Loop":
+                remoteView = AnyView(LoopNightscoutRemoteView())
+            default:
+                remoteView = AnyView(NoRemoteView())
+            }
+
+            hostingController = UIHostingController(rootView: remoteView)
         } else if remoteType == .trc {
-            let trioRemoteControlViewModel = TrioRemoteControlViewModel()
-            let trioRemoteControlView = TrioRemoteControlView(viewModel: trioRemoteControlViewModel)
-            hostingController = UIHostingController(rootView: AnyView(trioRemoteControlView))
+            if ObservableUserDefaults.shared.device.value != "Trio" {
+                hostingController = UIHostingController(
+                    rootView: AnyView(
+                        Text("Trio Remote Control is only supported for 'Trio'")
+                    )
+                )
+            } else {
+                let trioRemoteControlViewModel = TrioRemoteControlViewModel()
+                let trioRemoteControlView = TrioRemoteControlView(viewModel: trioRemoteControlViewModel)
+                hostingController = UIHostingController(rootView: AnyView(trioRemoteControlView))
+            }
         } else {
             hostingController = UIHostingController(rootView: AnyView(Text("Please select a Remote Type in Settings.")))
         }
@@ -66,9 +87,10 @@ class RemoteViewController: UIViewController {
         }
 
         if remoteType == .nightscout, !ObservableUserDefaults.shared.nsWriteAuth.value {
-            NightscoutUtils.verifyURLAndToken { error, jwtToken, nsWriteAuth in
+            NightscoutUtils.verifyURLAndToken { error, jwtToken, nsWriteAuth, nsAdminAuth in
                 DispatchQueue.main.async {
                     ObservableUserDefaults.shared.nsWriteAuth.value = nsWriteAuth
+                    ObservableUserDefaults.shared.nsAdminAuth.value = nsAdminAuth
                 }
             }
         }

+ 46 - 38
LoopFollow/Remote/Settings/RemoteSettingsView.swift

@@ -13,23 +13,11 @@ import HealthKit
 struct RemoteSettingsView: View {
     @ObservedObject var viewModel: RemoteSettingsViewModel
     @Environment(\.presentationMode) var presentationMode
-    @FocusState private var focusedField: Field?
 
     @State private var showAlert: Bool = false
     @State private var alertType: AlertType? = nil
     @State private var alertMessage: String? = nil
 
-    enum Field: Hashable {
-        case user
-        case deviceToken
-        case sharedSecret
-        case apnsKey
-        case teamId
-        case keyId
-        case bundleId
-        case maxBolus
-    }
-
     enum AlertType {
         case validation
     }
@@ -37,27 +25,32 @@ struct RemoteSettingsView: View {
     var body: some View {
         NavigationView {
             Form {
-                // Remote Type Section
-                // Instructions for Remote Type options
-                Section {
-                    Picker("Remote Type", selection: $viewModel.remoteType) {
-                        Text("None").tag(RemoteType.none)
-                        Text("Nightscout").tag(RemoteType.nightscout)
-                        if BuildDetails.default.branch?.lowercased() != "main" {
-                            Text("Trio Remote Control").tag(RemoteType.trc)
-                        }
+                // MARK: - Remote Type Section (Custom Rows)
+                Section(header: Text("Remote Type")) {
+                    remoteTypeRow(type: .none, label: "None", isEnabled: true)
+
+                    remoteTypeRow(type: .nightscout, label: "Nightscout", isEnabled: true)
+
+                    if BuildDetails.default.branch?.lowercased() != "main" {
+                        remoteTypeRow(
+                            type: .trc,
+                            label: "Trio Remote Control",
+                            isEnabled: viewModel.isTrioDevice
+                        )
                     }
-                    .pickerStyle(MenuPickerStyle())
 
-                    Text("Nightscout is the only option available for the released version of Trio.")
-                        .font(.footnote)
-                        .foregroundColor(.secondary)
-                    Text("Trio Remote Control requires a special version of Trio, which is under development in a private repository until sufficient testing is completed.")
+                    Text("Nightscout is the only option for the released version of Trio and Loop.")
                         .font(.footnote)
                         .foregroundColor(.secondary)
+
+                    if BuildDetails.default.branch?.lowercased() != "main" {
+                        Text("Trio Remote Control requires a special version of Trio, which is under development in a private repository until sufficient testing is completed.")
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                    }
                 }
 
-                // User Information Section
+                // MARK: - User Information Section
                 if viewModel.remoteType != .none {
                     Section(header: Text("User Information")) {
                         HStack {
@@ -65,13 +58,12 @@ struct RemoteSettingsView: View {
                             TextField("Enter User", text: $viewModel.user)
                                 .autocapitalization(.none)
                                 .disableAutocorrection(true)
-                                .focused($focusedField, equals: .user)
                                 .multilineTextAlignment(.trailing)
                         }
                     }
                 }
 
-                // Trio Remote Control Settings Section
+                // MARK: - Trio Remote Control Settings
                 if viewModel.remoteType == .trc {
                     Section(header: Text("Trio Remote Control Settings")) {
                         HStack {
@@ -79,7 +71,6 @@ struct RemoteSettingsView: View {
                             TextField("Enter Shared Secret", text: $viewModel.sharedSecret)
                                 .autocapitalization(.none)
                                 .disableAutocorrection(true)
-                                .focused($focusedField, equals: .sharedSecret)
                                 .multilineTextAlignment(.trailing)
                         }
 
@@ -88,7 +79,6 @@ struct RemoteSettingsView: View {
                             TextField("Enter APNS Key ID", text: $viewModel.keyId)
                                 .autocapitalization(.none)
                                 .disableAutocorrection(true)
-                                .focused($focusedField, equals: .keyId)
                                 .multilineTextAlignment(.trailing)
                         }
 
@@ -102,12 +92,10 @@ struct RemoteSettingsView: View {
                                     RoundedRectangle(cornerRadius: 8)
                                         .stroke(Color.gray.opacity(0.5), lineWidth: 1)
                                 )
-                                .focused($focusedField, equals: .apnsKey)
                         }
-
                     }
 
-                    // Guardrails Section
+                    // MARK: - Guardrails
                     Section(header: Text("Guardrails")) {
                         HStack {
                             Text("Max Bolus")
@@ -186,7 +174,7 @@ struct RemoteSettingsView: View {
                         }
                     }
 
-                    // Meal Section
+                    // MARK: - Meal Section
                     Section(header: Text("Meal Settings")) {
                         Toggle("Meal with Bolus", isOn: $viewModel.mealWithBolus)
                             .toggleStyle(SwitchToggleStyle())
@@ -195,6 +183,7 @@ struct RemoteSettingsView: View {
                             .toggleStyle(SwitchToggleStyle())
                     }
 
+                    // MARK: - Debug / Info
                     Section(header: Text("Debug / Info")) {
                         Text("Device Token: \(Storage.shared.deviceToken.value)")
                         Text("Production Env.: \(Storage.shared.productionEnvironment.value ? "True" : "False")")
@@ -211,9 +200,6 @@ struct RemoteSettingsView: View {
                     }
                 }
             }
-            .onTapGesture {
-                focusedField = nil
-            }
             .alert(isPresented: $showAlert) {
                 switch alertType {
                 case .validation:
@@ -229,6 +215,28 @@ struct RemoteSettingsView: View {
         }
     }
 
+    // MARK: - Custom Row for Remote Type Selection
+    private func remoteTypeRow(type: RemoteType, label: String, isEnabled: Bool) -> some View {
+        Button(action: {
+            if isEnabled {
+                viewModel.remoteType = type
+            }
+        }) {
+            HStack {
+                Text(label)
+                Spacer()
+                if viewModel.remoteType == type {
+                    Image(systemName: "checkmark")
+                        .foregroundColor(.accentColor)
+                }
+            }
+        }
+        // If isEnabled is false, user can see the row but not tap it.
+        .disabled(!isEnabled)
+        .foregroundColor(isEnabled ? .primary : .gray)
+    }
+
+    // MARK: - Validation Error Handler
     private func handleValidationError(_ message: String) {
         alertMessage = message
         alertType = .validation

+ 8 - 0
LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift

@@ -23,6 +23,7 @@ class RemoteSettingsViewModel: ObservableObject {
     @Published var maxFat: HKQuantity
     @Published var mealWithBolus: Bool
     @Published var mealWithFatProtein: Bool
+    @Published var isTrioDevice: Bool = (ObservableUserDefaults.shared.device.value == "Trio")
 
     private var storage = Storage.shared
     private var cancellables = Set<AnyCancellable>()
@@ -87,5 +88,12 @@ class RemoteSettingsViewModel: ObservableObject {
         $mealWithFatProtein
             .sink { [weak self] in self?.storage.mealWithFatProtein.value = $0 }
             .store(in: &cancellables)
+
+        ObservableUserDefaults.shared.device.$value
+            .receive(on: DispatchQueue.main            )
+            .sink { [weak self] newValue in
+                self?.isTrioDevice = (newValue == "Trio")
+            }
+            .store(in: &cancellables)
     }
 }

LoopFollow/Remote/PushMessage.swift → LoopFollow/Remote/TRC/PushMessage.swift


+ 6 - 6
LoopFollow/Remote/PushNotificationManager.swift

@@ -207,7 +207,7 @@ class PushNotificationManager {
 
         if !missingFields.isEmpty {
             let errorMessage = "Missing required fields, check your remote settings: \(missingFields.joined(separator: ", "))"
-            print(errorMessage)
+            LogManager.shared.log(category: .apns, message: errorMessage)
             completion(false, errorMessage)
             return
         }
@@ -218,28 +218,28 @@ class PushNotificationManager {
 
         if !missingFields.isEmpty {
             let errorMessage = "Missing required data, verify that you are using the latest version of Trio: \(missingFields.joined(separator: ", "))"
-            print(errorMessage)
+            LogManager.shared.log(category: .apns, message: errorMessage)
             completion(false, errorMessage)
             return
         }
 
         if let validationErrors = validateCredentials() {
             let errorMessage = "Credential validation failed: \(validationErrors.joined(separator: ", "))"
-            print(errorMessage)
+            LogManager.shared.log(category: .apns, message: errorMessage)
             completion(false, errorMessage)
             return
         }
 
         guard let url = constructAPNsURL() else {
             let errorMessage = "Failed to construct APNs URL"
-            print(errorMessage)
+            LogManager.shared.log(category: .apns, message: errorMessage)
             completion(false, errorMessage)
             return
         }
 
         guard let jwt = getOrGenerateJWT() else {
             let errorMessage = "Failed to generate JWT, please check that the token is correct."
-            print(errorMessage)
+            LogManager.shared.log(category: .apns, message: errorMessage)
             completion(false, errorMessage)
             return
         }
@@ -260,7 +260,7 @@ class PushNotificationManager {
             let task = URLSession.shared.dataTask(with: request) { data, response, error in
                 if let error = error {
                     let errorMessage = "Failed to send push notification: \(error.localizedDescription)"
-                    print(errorMessage)
+                    LogManager.shared.log(category: .apns, message: errorMessage)
                     completion(false, errorMessage)
                     return
                 }

LoopFollow/Remote/TreatmentResponse.swift → LoopFollow/Remote/TRC/TreatmentResponse.swift


+ 1 - 1
LoopFollow/Remote/TrioNightscoutRemoteController.swift.swift

@@ -1,5 +1,5 @@
 //
-//  TrioNightscoutRemoteController.swift.swift
+//  TrioNightscoutRemoteController.swift
 //  LoopFollow
 //
 //  Created by Jonas Björkert on 2024-08-26.

LoopFollow/Remote/TrioRemoteControlView.swift → LoopFollow/Remote/TRC/TrioRemoteControlView.swift


LoopFollow/Remote/TrioRemoteControlViewModel.swift → LoopFollow/Remote/TRC/TrioRemoteControlViewModel.swift


+ 45 - 0
LoopFollow/Settings/AdvancedSettingsView.swift

@@ -0,0 +1,45 @@
+//
+//  AdvancedSettingsView.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-23.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import SwiftUI
+
+struct AdvancedSettingsView: View {
+    @ObservedObject var viewModel: AdvancedSettingsViewModel
+    @Environment(\.presentationMode) var presentationMode
+
+    var body: some View {
+        NavigationView {
+            Form {
+                Section(header: Text("Advanced Settings")) {
+                    Toggle("Download Treatments", isOn: $viewModel.downloadTreatments)
+                    Toggle("Download Prediction", isOn: $viewModel.downloadPrediction)
+                    Toggle("Graph Basal", isOn: $viewModel.graphBasal)
+                    Toggle("Graph Bolus", isOn: $viewModel.graphBolus)
+                    Toggle("Graph Carbs", isOn: $viewModel.graphCarbs)
+                    Toggle("Graph Other Treatments", isOn: $viewModel.graphOtherTreatments)
+
+                    Stepper(value: $viewModel.bgUpdateDelay, in: 1...30, step: 1) {
+                        Text("BG Update Delay (Sec): \(viewModel.bgUpdateDelay)")
+                    }
+                }
+
+                Section(header: Text("Logging Options")) {
+                    Toggle("Debug Log Level", isOn: $viewModel.debugLogLevel)
+                }
+            }
+            .navigationBarTitle("Advanced Settings", displayMode: .inline)
+            .toolbar {
+                ToolbarItem(placement: .navigationBarTrailing) {
+                    Button("Done") {
+                        presentationMode.wrappedValue.dismiss()
+                    }
+                }
+            }
+        }
+    }
+}

+ 62 - 0
LoopFollow/Settings/AdvancedSettingsViewModel.swift

@@ -0,0 +1,62 @@
+//
+//  AdvancedSettingsViewModel.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-23.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+class AdvancedSettingsViewModel: ObservableObject {
+    @Published var downloadTreatments: Bool {
+        didSet {
+            UserDefaultsRepository.downloadTreatments.value = downloadTreatments
+        }
+    }
+    @Published var downloadPrediction: Bool {
+        didSet {
+            UserDefaultsRepository.downloadPrediction.value = downloadPrediction
+        }
+    }
+    @Published var graphBasal: Bool {
+        didSet {
+            UserDefaultsRepository.graphBasal.value = graphBasal
+        }
+    }
+    @Published var graphBolus: Bool {
+        didSet {
+            UserDefaultsRepository.graphBolus.value = graphBolus
+        }
+    }
+    @Published var graphCarbs: Bool {
+        didSet {
+            UserDefaultsRepository.graphCarbs.value = graphCarbs
+        }
+    }
+    @Published var graphOtherTreatments: Bool {
+        didSet {
+            UserDefaultsRepository.graphOtherTreatments.value = graphOtherTreatments
+        }
+    }
+    @Published var bgUpdateDelay: Int {
+        didSet {
+            UserDefaultsRepository.bgUpdateDelay.value = bgUpdateDelay
+        }
+    }
+    @Published var debugLogLevel: Bool {
+        didSet {
+            Storage.shared.debugLogLevel.value = debugLogLevel
+        }
+    }
+    init() {
+        self.downloadTreatments = UserDefaultsRepository.downloadTreatments.value
+        self.downloadPrediction = UserDefaultsRepository.downloadPrediction.value
+        self.graphBasal = UserDefaultsRepository.graphBasal.value
+        self.graphBolus = UserDefaultsRepository.graphBolus.value
+        self.graphCarbs = UserDefaultsRepository.graphCarbs.value
+        self.graphOtherTreatments = UserDefaultsRepository.graphOtherTreatments.value
+        self.bgUpdateDelay = UserDefaultsRepository.bgUpdateDelay.value
+        self.debugLogLevel = Storage.shared.debugLogLevel.value
+    }
+}

+ 1 - 0
LoopFollow/Storage/Observable.swift

@@ -14,6 +14,7 @@ class Observable {
 
     var tempTarget = ObservableValue<HKQuantity?>(default: nil)
     var override = ObservableValue<String?>(default: nil)
+    var lastRecBolusTriggered = ObservableValue<Double?>(default: nil)
 
     private init() {}
 }

+ 1 - 0
LoopFollow/Storage/ObservableUserDefaults.swift

@@ -15,6 +15,7 @@ class ObservableUserDefaults {
     var url = ObservableUserDefaultsValue<String>(key: "url", default: "")
     var device = ObservableUserDefaultsValue<String>(key: "device", default: "")
     var nsWriteAuth = ObservableUserDefaultsValue<Bool>(key: "nsWriteAuth", default: false)
+    var nsAdminAuth = ObservableUserDefaultsValue<Bool>(key: "nsAdminAuth", default: false)
 
     var contactEnabled = ObservableUserDefaultsValue<Bool>(key: "contactEnabled", default: false)
     var contactTrend = ObservableUserDefaultsValue<String>(key: "contactTrend", default: "Off")

+ 7 - 0
LoopFollow/Storage/Storage.swift

@@ -31,6 +31,13 @@ class Storage {
     var cachedJWT = StorageValue<String?>(key: "cachedJWT", 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)
+
+    var debugLogLevel = StorageValue<Bool>(key: "debugLogLevel", defaultValue: false)
+
+
     static let shared = Storage()
 
     private init() { }

+ 5 - 7
LoopFollow/Storage/UserDefaults.swift

@@ -57,8 +57,6 @@ class UserDefaultsRepository {
     static let hideInfoTable = UserDefaultsValue<Bool>(key: "hideInfoTable", default: false)
 
     // Nightscout Settings
-    static let showNS = UserDefaultsValue<Bool>(key: "showNS", default: false)
-    //static let url = UserDefaultsValue<String>(key: "url", default: "")
     static let token = UserDefaultsValue<String>(key: "token", default: "")
     static let units = UserDefaultsValue<String>(key: "units", default: "mg/dL")
 
@@ -81,7 +79,6 @@ class UserDefaultsRepository {
     }
 
     // Dexcom Share Settings
-    static let showDex = UserDefaultsValue<Bool>(key: "showDex", default: false)
     static let shareUserName = UserDefaultsValue<String>(key: "shareUserName", default: "")
     static let sharePassword = UserDefaultsValue<String>(key: "sharePassword", default: "")
     static let shareServer = UserDefaultsValue<String>(key: "shareServer", default: "US")
@@ -121,8 +118,10 @@ class UserDefaultsRepository {
     static let speakHighBG = UserDefaultsValue<Bool>(key: "speakHighBG", default: false)
     static let speakLanguage = UserDefaultsValue<String>(key: "speakLanguage", default: "en")
     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 appBadge = UserDefaultsValue<Bool>(key: "appBadge", default: true)
     static let dimScreenWhenIdle = UserDefaultsValue<Int>(key: "dimScreenWhenIdle", default: 0)
     static let forceDarkMode = UserDefaultsValue<Bool>(key: "forceDarkMode", default: true)
@@ -143,7 +142,6 @@ class UserDefaultsRepository {
     static let graphBasal = UserDefaultsValue<Bool>(key: "graphBasal", default: true)
     static let graphBolus = UserDefaultsValue<Bool>(key: "graphBolus", default: true)
     static let graphCarbs = UserDefaultsValue<Bool>(key: "graphCarbs", default: true)
-    static let debugLog = UserDefaultsValue<Bool>(key: "debugLog", default: false)
     static let bgUpdateDelay = UserDefaultsValue<Int>(key: "bgUpdateDelay", default: 10)
     static let downloadDays = UserDefaultsValue<Int>(key: "downloadDays", default: 1)
     
@@ -293,7 +291,7 @@ class UserDefaultsRepository {
     
     
     static let alertMissedReadingActive = UserDefaultsValue<Bool>(key: "alertMissedReadingActive", default: false)
-    static let alertMissedReading = UserDefaultsValue<Int>(key: "alertMissedReading", default: 30)
+    static let alertMissedReading = UserDefaultsValue<Int>(key: "alertMissedReading", default: 31)
     static let alertMissedReadingSnooze = UserDefaultsValue<Int>(key: "alertMissedReadingSnooze", default: 30)
     static let alertMissedReadingSnoozedTime = UserDefaultsValue<Date?>(key: "alertMissedReadingSnoozedTime", default: nil)
     static let alertMissedReadingIsSnoozed = UserDefaultsValue<Bool>(key: "alertMissedReadingIsSnoozed", default: false)
@@ -310,7 +308,7 @@ class UserDefaultsRepository {
     
     
     static let alertNotLoopingActive = UserDefaultsValue<Bool>(key: "alertNotLoopingActive", default: false)
-    static let alertNotLooping = UserDefaultsValue<Int>(key: "alertNotLooping", default: 30)
+    static let alertNotLooping = UserDefaultsValue<Int>(key: "alertNotLooping", default: 31)
     static let alertNotLoopingSnooze = UserDefaultsValue<Int>(key: "alertNotLoopingSnooze", default: 30)
     static let alertNotLoopingUseLimits = UserDefaultsValue<Bool>(key: "alertNotLoopingUseLimits", default: false)
     static let alertNotLoopingLowerLimit = UserDefaultsValue<Float>(key: "alertNotLoopingBelowBG", default: 100.0)

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

+ 44 - 0
LoopFollow/Task/BGTask.swift

@@ -0,0 +1,44 @@
+//
+//  BGTask.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-11.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+extension MainViewController {
+    func scheduleBGTask(initialDelay: TimeInterval = 2) {
+        let firstRun = Date().addingTimeInterval(initialDelay)
+        TaskScheduler.shared.scheduleTask(id: .fetchBG, nextRun: firstRun) { [weak self] in
+            guard let self = self else { return }
+            self.bgTaskAction()
+        }
+    }
+
+    func bgTaskAction() {
+        // If anything goes wrong, try again in 60 seconds.
+        TaskScheduler.shared.rescheduleTask(
+            id: .fetchBG,
+            to: Date().addingTimeInterval(60)
+        )
+
+        // If no Dexcom credentials and no Nightscout, schedule a retry in 60 seconds.
+        if UserDefaultsRepository.shareUserName.value == "",
+           UserDefaultsRepository.sharePassword.value == "",
+           !IsNightscoutEnabled()
+        {
+            return
+        }
+
+        // If Dexcom credentials exist, fetch from DexShare
+        if UserDefaultsRepository.shareUserName.value != "" &&
+            UserDefaultsRepository.sharePassword.value != ""
+        {
+            self.webLoadDexShare()
+        } else {
+            self.webLoadNSBGData()
+        }
+    }
+}

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

+ 98 - 0
LoopFollow/Task/MinAgoTask.swift

@@ -0,0 +1,98 @@
+//
+//  MinAgoTask.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-11.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import UIKit
+
+extension MainViewController {
+    func scheduleMinAgoTask(initialDelay: TimeInterval = 1.0) {
+        let firstRun = Date().addingTimeInterval(initialDelay)
+        TaskScheduler.shared.scheduleTask(id: .minAgoUpdate, nextRun: firstRun) { [weak self] in
+            guard let self = self else { return }
+            self.minAgoTaskAction()
+        }
+    }
+
+    func minAgoTaskAction() {
+        guard bgData.count > 0, let lastBG = bgData.last else {
+            DispatchQueue.main.async { [weak self] in
+                guard let self = self else { return }
+                self.MinAgoText.text = ""
+                self.latestMinAgoString = ""
+                if let snoozer = self.tabBarController?.viewControllers?[2] as? SnoozeViewController {
+                    snoozer.MinAgoLabel.text = ""
+                    snoozer.BGLabel.text = ""
+                    snoozer.BGLabel.attributedText = NSAttributedString(string: "")
+                }
+            }
+            TaskScheduler.shared.rescheduleTask(id: .minAgoUpdate, to: Date().addingTimeInterval(1))
+            return
+        }
+
+        let bgSeconds = lastBG.date
+        let now = Date()
+        let secondsAgo = now.timeIntervalSince1970 - bgSeconds
+
+        let formatter = DateComponentsFormatter()
+        formatter.unitsStyle = .positional
+        formatter.zeroFormattingBehavior = .dropLeading
+
+        let shouldDisplaySeconds = secondsAgo >= 270 && secondsAgo < 720 // 4.5 to 12 minutes
+
+        if shouldDisplaySeconds {
+            formatter.allowedUnits = [.minute, .second]
+        } else {
+            formatter.allowedUnits = [.minute]
+        }
+
+        let formattedDuration = formatter.string(from: secondsAgo) ?? ""
+        let minAgoDisplayText = formattedDuration + " min ago"
+
+        // Update UI only if the display text has changed
+        if minAgoDisplayText != latestMinAgoString {
+            DispatchQueue.main.async { [weak self] in
+                guard let self = self else { return }
+                self.MinAgoText.text = minAgoDisplayText
+                self.latestMinAgoString = minAgoDisplayText
+
+                if let snoozer = self.tabBarController?.viewControllers?[2] as? SnoozeViewController {
+                    snoozer.MinAgoLabel.text = minAgoDisplayText
+
+                    let bgLabelText = snoozer.BGLabel.text ?? ""
+                    let attributeString = NSMutableAttributedString(string: bgLabelText)
+                    attributeString.addAttribute(.strikethroughStyle,
+                                                 value: NSUnderlineStyle.single.rawValue,
+                                                 range: NSRange(location: 0, length: attributeString.length))
+                    attributeString.addAttribute(.strikethroughColor,
+                                                 value: secondsAgo >= 720 ? UIColor.systemRed : UIColor.clear,
+                                                 range: NSRange(location: 0, length: attributeString.length))
+                    snoozer.BGLabel.attributedText = attributeString
+                }
+            }
+        }
+
+        // Determine the next run interval based on the current state
+        let nextUpdateInterval: TimeInterval
+        if shouldDisplaySeconds {
+            // Update every second when showing seconds
+            nextUpdateInterval = 1.0
+        } else if secondsAgo >= 240 && secondsAgo < 720 {
+            // Schedule exactly at the transition point to start showing seconds
+            nextUpdateInterval = 270.0 - secondsAgo
+        } else {
+            // Schedule exactly at the transition point to next minute
+            let secondsToNextMinute = 60.0 - (secondsAgo.truncatingRemainder(dividingBy: 60.0))
+            nextUpdateInterval = secondsToNextMinute
+        }
+
+        // Ensure the nextUpdateInterval is not negative or too small
+        let safeNextInterval = max(nextUpdateInterval, 1.0)
+
+        TaskScheduler.shared.rescheduleTask(id: .minAgoUpdate, to: Date().addingTimeInterval(safeNextInterval))
+    }
+}

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

+ 141 - 0
LoopFollow/Task/TaskScheduler.swift

@@ -0,0 +1,141 @@
+//
+//  TaskScheduler.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-01-10.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import UIKit
+
+enum TaskID: CaseIterable {
+    case profile
+    case deviceStatus
+    case treatments
+    case fetchBG
+    case minAgoUpdate
+    case calendarWrite
+    case alarmCheck
+}
+
+struct ScheduledTask {
+    var nextRun: Date
+    var action: () -> Void
+}
+
+class TaskScheduler {
+    static let shared = TaskScheduler()
+
+    // Thread-safety: a serial queue so we don’t manipulate tasks from multiple threads at once
+    private let queue = DispatchQueue(label: "com.LoopFollow.TaskSchedulerQueue")
+
+    private var tasks: [TaskID: ScheduledTask] = [:]
+    private var currentTimer: Timer?
+
+    private init() {}
+
+    // MARK: - Public API
+
+    func scheduleTask(id: TaskID, nextRun: Date, action: @escaping () -> Void) {
+        queue.async {
+            let timeString = self.formatTime(nextRun)
+            LogManager.shared.log(category: .taskScheduler, message: "scheduleTask(\(id)): next run = \(timeString)", isDebug: true)
+
+            self.tasks[id] = ScheduledTask(nextRun: nextRun, action: action)
+            self.rescheduleTimer()
+        }
+    }
+
+    func rescheduleTask(id: TaskID, to newRunDate: Date) {
+        let timeString = self.formatTime(newRunDate)
+        LogManager.shared.log(category: .taskScheduler, message: "Reschedule Task \(id): next run = \(timeString)", isDebug: true)
+
+        queue.async {
+            guard var existingTask = self.tasks[id] else {
+                return
+            }
+            existingTask.nextRun = newRunDate
+            self.tasks[id] = existingTask
+            self.checkTasksNow()
+        }
+    }
+
+    func checkTasksNow() {
+        queue.async {
+            self.fireOverdueTasks()
+            self.rescheduleTimer()
+        }
+    }
+
+    // MARK: - Private
+
+    /// Updated signature to include info about who called us, and which task triggered it (if any).
+    private func rescheduleTimer() {
+        // Invalidate any existing timer
+        currentTimer?.invalidate()
+        currentTimer = nil
+
+        guard let (_, earliestTask) = tasks.min(by: { $0.value.nextRun < $1.value.nextRun }) else {
+            LogManager.shared.log(category: .taskScheduler, message: "No tasks, no timer scheduled.")
+            return
+        }
+
+        let interval = earliestTask.nextRun.timeIntervalSinceNow
+        let safeInterval = max(interval, 0)
+
+        // Comment out this block to simulate heartbeat execution only
+        DispatchQueue.main.async {
+            self.currentTimer = Timer.scheduledTimer(withTimeInterval: safeInterval, repeats: false) { [weak self] _ in
+                guard let self = self else { return }
+                self.queue.async {
+                    self.fireOverdueTasks()
+                    self.rescheduleTimer()
+                }
+            }
+        }
+    }
+
+    private func fireOverdueTasks() {
+        BackgroundAlertManager.shared.scheduleBackgroundAlert()
+
+        let now = Date()
+        let tasksToSkipAlarmCheck: Set<TaskID> = [.deviceStatus, .treatments, .fetchBG]
+
+        for taskID in TaskID.allCases {
+            guard let task = tasks[taskID], task.nextRun <= now else {
+                continue
+            }
+
+            // Check if we should skip alarmCheck
+            if taskID == .alarmCheck {
+                let shouldSkip = tasksToSkipAlarmCheck.contains {
+                    guard let checkTask = tasks[$0] else { return false }
+                    return checkTask.nextRun <= now || checkTask.nextRun == .distantFuture
+                }
+
+                if shouldSkip {
+                    //LogManager.shared.log(category: .taskScheduler, message: "Skipping alarmCheck because one of the specified tasks is due or set to distant future.")
+                    continue
+                }
+            }
+
+            var updatedTask = task
+            updatedTask.nextRun = .distantFuture
+            tasks[taskID] = updatedTask
+
+            LogManager.shared.log(category: .taskScheduler, message: "Executing task \(taskID)", isDebug: true)
+
+            DispatchQueue.main.async {
+                task.action()
+            }
+        }
+    }
+
+    private func formatTime(_ date: Date) -> String {
+        let formatter = DateFormatter()
+        formatter.dateStyle = .none
+        formatter.timeStyle = .medium
+        return formatter.string(from: date)
+    }
+}

+ 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 - 99
LoopFollow/ViewControllers/AdvancedSettingsViewController.swift

@@ -1,99 +0,0 @@
-//
-//  DebugSettingsViewController.swift
-//  LoopFollow
-//
-//  Created by Jose Paredes on 7/16/20.
-//  Copyright © 2020 Jon Fawcett. All rights reserved.
-//
-
-import Foundation
-import Eureka
-import EventKit
-import EventKitUI
-
-class AdvancedSettingsViewController: FormViewController {
-    
-    var appStateController: AppStateController?
-    
-    override func viewDidLoad()  {
-        super.viewDidLoad()
-        if UserDefaultsRepository.forceDarkMode.value {
-            overrideUserInterfaceStyle = .dark
-        }
-        buildAdvancedSettings()
-    }
-    private func buildAdvancedSettings() {
-        form
-            +++ Section("Advanced Settings")
-            
-            <<< SwitchRow("downloadTreatments"){ row in
-                row.title = "Download Treatments"
-                row.value = UserDefaultsRepository.downloadTreatments.value
-            }.onChange { [weak self] row in
-                guard let value = row.value else { return }
-                UserDefaultsRepository.downloadTreatments.value = value
-            }
-            <<< SwitchRow("downloadPrediction"){ row in
-                row.title = "Download Prediction"
-                row.value = UserDefaultsRepository.downloadPrediction.value
-            }.onChange { [weak self] row in
-                guard let value = row.value else { return }
-                UserDefaultsRepository.downloadPrediction.value = value
-            }
-            <<< SwitchRow("graphBasal"){ row in
-                row.title = "Graph Basal"
-                row.value = UserDefaultsRepository.graphBasal.value
-            }.onChange { [weak self] row in
-                guard let value = row.value else { return }
-                UserDefaultsRepository.graphBasal.value = value
-            }
-            <<< SwitchRow("graphBolus"){ row in
-                row.title = "Graph Bolus"
-                row.value = UserDefaultsRepository.graphBolus.value
-            }.onChange { [weak self] row in
-                guard let value = row.value else { return }
-                UserDefaultsRepository.graphBolus.value = value
-            }
-            <<< SwitchRow("graphCarbs"){ row in
-                row.title = "Graph Carbs"
-                row.value = UserDefaultsRepository.graphCarbs.value
-            }.onChange { [weak self] row in
-                guard let value = row.value else { return }
-                UserDefaultsRepository.graphCarbs.value = value
-            }
-            <<< SwitchRow("graphOtherTreatments"){ row in
-                row.title = "Graph Other Treatments"
-                row.value = UserDefaultsRepository.graphOtherTreatments.value
-            }.onChange { [weak self] row in
-                guard let value = row.value else { return }
-                UserDefaultsRepository.graphOtherTreatments.value = value
-            }
-            <<< StepperRow("bgUpdateDelay") { row in
-                row.title = "BG Update Delay (Sec)"
-                row.cell.stepper.stepValue = 1
-                row.cell.stepper.minimumValue = 1
-                row.cell.stepper.maximumValue = 30
-                row.value = Double(UserDefaultsRepository.bgUpdateDelay.value)
-                row.displayValueFor = { value in
-                        guard let value = value else { return nil }
-                        return "\(Int(value))"
-                    }
-            }.onChange { [weak self] row in
-                    guard let value = row.value else { return }
-                    UserDefaultsRepository.bgUpdateDelay.value = Int(value)
-            }
-
-
-            
-            
-            
-            
-            +++ ButtonRow() {
-                $0.title = "DONE"
-            }.onCellSelection { (row, arg)  in
-                self.dismiss(animated:true, completion: nil)
-            }
-    }
-    
-
-}

+ 4 - 4
LoopFollow/ViewControllers/AlarmViewController.swift

@@ -1661,8 +1661,8 @@ class AlarmViewController: FormViewController {
         <<< StepperRow("alertMissedReading") { row in
             row.title = "Time"
             row.cell.stepper.stepValue = 5
-            row.cell.stepper.minimumValue = 10
-            row.cell.stepper.maximumValue = 120
+            row.cell.stepper.minimumValue = 11
+            row.cell.stepper.maximumValue = 121
             row.value = Double(UserDefaultsRepository.alertMissedReading.value)
             row.displayValueFor = { value in
                 guard let value = value else { return nil }
@@ -1805,8 +1805,8 @@ class AlarmViewController: FormViewController {
         <<< StepperRow("alertNotLooping") { row in
             row.title = "Time"
             row.cell.stepper.stepValue = 5
-            row.cell.stepper.minimumValue = 10
-            row.cell.stepper.maximumValue = 60
+            row.cell.stepper.minimumValue = 16
+            row.cell.stepper.maximumValue = 61
             row.value = Double(UserDefaultsRepository.alertNotLooping.value)
             row.displayValueFor = { value in
                 guard let value = value else { return nil }

+ 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
         row.title = "Persistent Notification"
         row.value = UserDefaultsRepository.persistentNotification.value

+ 50 - 66
LoopFollow/ViewControllers/MainViewController.swift

@@ -12,6 +12,7 @@ import EventKit
 import ShareClient
 import UserNotifications
 import AVFAudio
+import CoreBluetooth
 
 func IsNightscoutEnabled() -> Bool {
     return !ObservableUserDefaults.shared.url.value.isEmpty
@@ -73,25 +74,14 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     // check every 30 Seconds whether new bgvalues should be retrieved
     let timeInterval: TimeInterval = 30.0
     
-    // Min Ago Timer
-    var minAgoTimer = Timer()
-    var minAgoTimeInterval: TimeInterval = 1.0
-    
     // Check Alarms Timer
     // Don't check within 1 minute of alarm triggering to give the snoozer time to save data
     var checkAlarmTimer = Timer()
     var checkAlarmInterval: TimeInterval = 60.0
-    
-    var calTimer = Timer()
-    
-    var bgTimer = Timer()
-    var profileTimer = Timer()
-    var deviceStatusTimer = Timer()
-    var treatmentsTimer = Timer()
-    var alarmTimer = Timer()
-    var calendarTimer = Timer()
     var graphNowTimer = Timer()
 
+    var lastCalendarWriteAttemptTime: TimeInterval = 0
+
     // Info Table Setup
     var infoManager: InfoManager!
     var profileManager = ProfileManager.shared
@@ -118,7 +108,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     var latestMinAgoString = ""
     var latestDeltaString = ""
     var latestLoopStatusString = ""
-    var latestLoopTime: Double = 0
     var latestCOB: CarbMetric?
     var latestBasal = ""
     var latestPumpVolume: Double = 50.0
@@ -157,12 +146,24 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     override func viewDidLoad() {
         super.viewDidLoad()
 
+        if ObservableUserDefaults.shared.device.value != "Trio" && Storage.shared.remoteType.value == .trc {
+            Storage.shared.remoteType.value = .none
+        }
+
+        //Migration of UserDefaultsRepository -> Storage handling
+        if !UserDefaultsRepository.backgroundRefresh.value {
+            Storage.shared.backgroundRefreshType.value = .none
+            UserDefaultsRepository.backgroundRefresh.value = true
+        }
+
+        // Ensure alertNotLooping has a minimum value of 16.
+        if UserDefaultsRepository.alertNotLooping.value < 16 {
+            UserDefaultsRepository.alertNotLooping.value = 16
+        }
+
         // Synchronize info types to ensure arrays are the correct size
         UserDefaultsRepository.synchronizeInfoTypes()
 
-        // Reset deprecated settings
-        UserDefaultsRepository.debugLog.value = false;
-        
         infoTable.rowHeight = 21
         infoTable.dataSource = self
         infoTable.tableFooterView = UIView(frame: .zero)
@@ -209,9 +210,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         // setup display for NS vs Dex
         showHideNSDetails()
         
-        // Load Startup Data
-        restartAllTimers()
-        
+        scheduleAllTasks()
+
         // Set up refreshScrollView for BGText
         refreshScrollView = UIScrollView()
         refreshScrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -233,7 +233,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         refreshScrollView.alwaysBounceVertical = true
         
         refreshScrollView.delegate = self
-        
         NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil)
     }
     
@@ -243,7 +242,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     
     // Clean all timers and start new ones when refreshing
     @objc func refresh() {
-        print("Refreshing")
+        LogManager.shared.log(category: .general, message: "Refreshing")
 
         // Clear prediction for both Loop or OpenAPS
 
@@ -269,8 +268,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         }
 
         MinAgoText.text = "Refreshing"
-        invalidateTimers()
-        restartAllTimers()
+        latestMinAgoString = "Refreshing"
+        scheduleAllTasks()
+
         currentCage = nil
         currentSage = nil
         currentIage = nil
@@ -387,26 +387,29 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         // We want to always come back to the home screen
         tabBarController?.selectedIndex = 0
         
-        // Cancel the current timer and start a fresh background timer using the settings value only if background task is enabled
-        
-        if UserDefaultsRepository.backgroundRefresh.value {
-            BackgroundAlertManager.shared.startBackgroundAlert()
+        if Storage.shared.backgroundRefreshType.value == .silentTune {
             backgroundTask.startBackgroundTask()
         }
-        
+
+        if Storage.shared.backgroundRefreshType.value != .none {
+            BackgroundAlertManager.shared.startBackgroundAlert()
+        }
     }
     
     @objc func appCameToForeground() {
         // reset screenlock state if needed
         UIApplication.shared.isIdleTimerDisabled = UserDefaultsRepository.screenlockSwitchState.value;
         
-        // Cancel the background tasks, start a fresh timer
-        if UserDefaultsRepository.backgroundRefresh.value {
+        if Storage.shared.backgroundRefreshType.value == .silentTune {
             backgroundTask.stopBackgroundTask()
+        }
+        
+        if Storage.shared.backgroundRefreshType.value != .none {
             BackgroundAlertManager.shared.stopBackgroundAlert()
         }
+
+        TaskScheduler.shared.checkTasksNow()
         
-        restartAllTimers()
         checkAndNotifyVersionStatus()
         checkAppExpirationStatus()
     }
@@ -543,13 +546,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     }
     
     func writeCalendar() {
-        if UserDefaultsRepository.debugLog.value {
-            self.writeDebugLog(value: "Write calendar start")
-        }
-        
         self.store.requestCalendarAccess { (granted, error) in
             if !granted {
-                print("Failed to get calendar access: \(String(describing: error))")
+                LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))")
                 return
             }
             self.processCalendarUpdates()
@@ -558,15 +557,19 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
 
     func processCalendarUpdates() {
         if UserDefaultsRepository.calendarIdentifier.value == "" { return }
-        
+
         if self.bgData.count < 1 { return }
-        
+
         // This lets us fire the method to write Min Ago entries only once a minute starting after 6 minutes but allows new readings through
-        if self.lastCalDate == self.bgData[self.bgData.count - 1].date
-            && (self.calTimer.isValid || (dateTimeUtils.getNowTimeIntervalUTC() - self.lastCalDate) < 360) {
-            return
+        let now = dateTimeUtils.getNowTimeIntervalUTC()
+        let newestBGDate = bgData[bgData.count - 1].date
+
+        if lastCalDate == newestBGDate {
+            if (now - lastCalendarWriteAttemptTime) < 60 || (now - newestBGDate) < 360 {
+                return
+            }
         }
-        
+
         // Create Event info
         var deltaBG = 0 // protect index out of bounds
         if self.bgData.count > 1 {
@@ -584,7 +587,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         let direction = self.bgDirectionGraphic(self.bgData[self.bgData.count - 1].direction ?? "")
         
         var eventStartDate = Date(timeIntervalSince1970: self.bgData[self.bgData.count - 1].date)
-        //                if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Calendar start date") }
         var eventEndDate = eventStartDate.addingTimeInterval(60 * 10)
         var  eventTitle = UserDefaultsRepository.watchLine1.value
         if (UserDefaultsRepository.watchLine2.value.count > 1) {
@@ -628,9 +630,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
             for i in eVDelete! {
                 do {
                     try self.store.remove(i, span: EKSpan.thisEvent, commit: true)
-                    //if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Calendar Delete") }
                 } catch let error {
-                    //if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Error - Calendar Delete") }
                     print(error)
                 }
             }
@@ -644,16 +644,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         event.calendar = self.store.calendar(withIdentifier: UserDefaultsRepository.calendarIdentifier.value)
         do {
             try self.store.save(event, span: .thisEvent, commit: true)
-            self.calTimer.invalidate()
-            self.startCalTimer(time: (60 * 1))
-            
+            self.lastCalendarWriteAttemptTime = now
+
             self.lastCalDate = self.bgData[self.bgData.count - 1].date
-            //if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Calendar Write: " + eventTitle) }
             //UserDefaultsRepository.savedEventID.value = event.eventIdentifier //save event id to access this particular event later
         } catch {
-            print("*** Error storing to the calendar")
-            // Display error to user
-            //if UserDefaultsRepository.debugLog.value { self.writeDebugLog(value: "Error: Calendar Write") }
+            LogManager.shared.log(category: .calendar, message: "Error storing to the calendar")
         }
     }
     
@@ -662,19 +658,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     {
         if UserDefaultsRepository.persistentNotification.value && bgTime > UserDefaultsRepository.persistentNotificationLastBGTime.value && bgData.count > 0 {
             guard let snoozer = self.tabBarController!.viewControllers?[2] as? SnoozeViewController else { return }
-            snoozer.sendNotification(self, bgVal: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)), directionVal: latestDirectionString, deltaVal: Localizer.toDisplayUnits(String(latestDeltaString)), minAgoVal: latestMinAgoString, alertLabelVal: "Latest BG")
-        }
-    }
-    
-    func writeDebugLog(value: String) {
-        DispatchQueue.main.async {
-            var logText = "\n" + dateTimeUtils.printNow() + " - " + value
-            print(logText)
-            guard let debug = self.tabBarController!.viewControllers?[2] as? SnoozeViewController else { return }
-            if debug.debugTextView.text.lengthOfBytes(using: .utf8) > 20000 {
-                debug.debugTextView.text = ""
-            }
-            debug.debugTextView.text += logText
+            snoozer.sendNotification(self, bgVal: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)), directionVal: latestDirectionString, deltaVal: latestDeltaString, minAgoVal: latestMinAgoString, alertLabelVal: "Latest BG")
         }
     }
 

+ 134 - 181
LoopFollow/ViewControllers/SettingsViewController.swift

@@ -12,7 +12,7 @@ import EventKit
 import EventKitUI
 import SwiftUI
 
-class SettingsViewController: FormViewController {
+class SettingsViewController: FormViewController, NightscoutSettingsViewModelDelegate {
     var tokenRow: TextRow?
     var appStateController: AppStateController?
     var statusLabelRow: LabelRow!
@@ -45,8 +45,6 @@ class SettingsViewController: FormViewController {
         if UserDefaultsRepository.forceDarkMode.value {
             overrideUserInterfaceStyle = .dark
         }
-        UserDefaultsRepository.showNS.value = false
-        UserDefaultsRepository.showDex.value = false
 
         let buildDetails = BuildDetails.default
         let formattedBuildDate = dateTimeUtils.formattedDate(from: buildDetails.buildDate())
@@ -66,146 +64,38 @@ class SettingsViewController: FormViewController {
             guard let value = row.value else { return }
             UserDefaultsRepository.units.value = value
         }
-        <<< SwitchRow("showNS"){ row in
-            row.title = "Show Nightscout Settings"
-            row.value = UserDefaultsRepository.showNS.value
-        }.onChange { [weak self] row in
-            guard let value = row.value else { return }
-            UserDefaultsRepository.showNS.value = value
-        }
-        <<< TextRow() { row in
-            row.title = "URL"
-            row.placeholder = "https://mycgm.herokuapp.com"
-            row.value = ObservableUserDefaults.shared.url.value
-            row.hidden = "$showNS == false"
-        }.cellSetup { (cell, row) in
-            cell.textField.autocorrectionType = .no
-            cell.textField.autocapitalizationType = .none
-        }.onChange { row in
-            guard let value = row.value else {
-                ObservableUserDefaults.shared.url.value = ""
-                self.showHideNSDetails()
-                return
-            }
-
-            var useTokenUrl = false
-
-            // Attempt to handle special case: pasted URL including token
-            if let urlComponents = URLComponents(string: value), let queryItems = urlComponents.queryItems {
-                if let tokenItem = queryItems.first(where: { $0.name.lowercased() == "token" }) {
-                    let tokenPattern = "^[^-\\s]+-[0-9a-fA-F]{16}$"
-                    if let token = tokenItem.value, let _ = token.range(of: tokenPattern, options: .regularExpression) {
-                        var baseComponents = urlComponents
-                        baseComponents.queryItems = nil
-                        if let baseURL = baseComponents.string {
-                            UserDefaultsRepository.token.value = token
-                            self.tokenRow?.value = token
-                            self.tokenRow?.updateCell()
-
-                            ObservableUserDefaults.shared.url.value = baseURL
-                            row.value = baseURL
-                            row.updateCell()
-                            useTokenUrl = true
-                        }
-                    }
-                }
-            }
-
-            if !useTokenUrl {
-                // Normalize input: remove unwanted characters and lowercase
-                let filtered = value.replacingOccurrences(of: "[^A-Za-z0-9:/._-]", with: "", options: .regularExpression).lowercased()
-
-                // Further clean-up: Remove trailing slashes
-                var cleanURL = filtered
-                while cleanURL.count > 8 && cleanURL.last == "/" {
-                    cleanURL = String(cleanURL.dropLast())
-                }
-
-                ObservableUserDefaults.shared.url.value = cleanURL
-                row.value = cleanURL
-                row.updateCell()
-            }
-
-            self.showHideNSDetails()
-
-            // Verify Nightscout URL and token
-            self.checkNightscoutStatus()
-        }
-
-        <<< TextRow() { row in
-            row.title = "NS Token"
-            row.placeholder = "Leave blank if not using tokens"
-            row.value = UserDefaultsRepository.token.value
-            row.hidden = "$showNS == false"
-            self.tokenRow = row
-        }.cellSetup { (cell, row) in
-            cell.textField.autocorrectionType = .no
-            cell.textField.autocapitalizationType = .none
-            cell.textField.textContentType = .password
-        }.onChange { row in
-            if row.value == nil {
-                UserDefaultsRepository.token.value = ""
-            }
-            guard let value = row.value else { return }
-            UserDefaultsRepository.token.value = value
-
-            // Verify Nightscout URL and token
-            self.checkNightscoutStatus()
-        }
-        <<< LabelRow() { row in
-            row.title = "NS Status"
-            row.value = "Checking..."
-            statusLabelRow = row
-            row.hidden = "$showNS == false"
-        }
-        <<< SwitchRow("showDex"){ row in
-            row.title = "Show Dexcom Settings"
-            row.value = UserDefaultsRepository.showDex.value
-        }.onChange { row in
-            guard let value = row.value else { return }
-            UserDefaultsRepository.showDex.value = value
-        }
-        <<< TextRow(){ row in
-            row.title = "User Name"
-            row.value = UserDefaultsRepository.shareUserName.value
-            row.hidden = "$showDex == false"
-        }.cellSetup { (cell, row) in
-            cell.textField.autocorrectionType = .no
-            cell.textField.autocapitalizationType = .none
-        }.onChange { row in
-            if row.value == nil {
-                UserDefaultsRepository.shareUserName.value = ""
-            }
-            guard let value = row.value else { return }
-            UserDefaultsRepository.shareUserName.value = value
-        }
-        <<< TextRow(){ row in
-            row.title = "Password"
-            row.value = UserDefaultsRepository.sharePassword.value
-            row.hidden = "$showDex == false"
-        }.cellSetup { (cell, row) in
-            cell.textField.autocorrectionType = .no
-            cell.textField.isSecureTextEntry = true
-            cell.textField.autocapitalizationType = .none
-        }.onChange { row in
-            if row.value == nil {
-                UserDefaultsRepository.sharePassword.value = ""
-            }
-            guard let value = row.value else { return }
-            UserDefaultsRepository.sharePassword.value = value
+        <<< ButtonRow("nightscout") {
+            $0.title = "Nightscout Settings"
+            $0.presentationMode = .show(
+                controllerProvider: .callback(builder: {
+                    self.presentNightscoutSettingsView()
+                    return UIViewController()
+                }), onDismiss: nil
+            )
         }
-        <<< SegmentedRow<String>("shareServer") { row in
-            row.title = "Server"
-            row.options = ["US", "NON-US"]
-            row.value = UserDefaultsRepository.shareServer.value
-            row.hidden = "$showDex == false"
-        }.onChange { row in
-            guard let value = row.value else { return }
-            UserDefaultsRepository.shareServer.value = value
+        <<< ButtonRow("dexcom") {
+            $0.title = "Dexcom Settings"
+            $0.presentationMode = .show(
+                controllerProvider: .callback(builder: {
+                    self.presentDexcomSettingsView()
+                    return UIViewController()
+                }), onDismiss: nil
+            )
         }
 
         +++ Section("App Settings")
 
+        <<< ButtonRow("backgroundRefreshSettings") {
+            $0.title = "Background Refresh Settings"
+            $0.presentationMode = .show(
+                controllerProvider: .callback(builder: {
+                    self.presentBackgroundRefreshSettings()
+                    return UIViewController()
+                }),
+                onDismiss: nil
+            )
+        }
+
         <<< ButtonRow() {
             $0.title = "General Settings"
             $0.presentationMode = .show(
@@ -282,12 +172,28 @@ class SettingsViewController: FormViewController {
             $0.title = "Advanced Settings"
             $0.presentationMode = .show(
                 controllerProvider: .callback(builder: {
-                    let controller = AdvancedSettingsViewController()
-                    controller.appStateController = self.appStateController
-                    return controller
-                }
-                                             ), onDismiss: nil)
+                    self.presentAdvancedSettingsView()
+                    return UIViewController()
+                }), onDismiss: nil)
+        }
 
+        +++ Section("Logging")
+        <<< ButtonRow("viewlog") {
+            $0.title = "View Log"
+            $0.presentationMode = .show(
+                controllerProvider: .callback(builder: {
+                    self.presentLogView()
+                    return UIViewController()
+                }), onDismiss: nil)
+        }
+        <<< ButtonRow("shareLogs") {
+            $0.title = "Share Logs"
+            $0.cellSetup { cell, row in
+                cell.accessibilityIdentifier = "ShareLogsButton"
+            }
+            $0.onCellSelection { [weak self] _, _ in
+                self?.shareLogs()
+            }
         }
 
         +++ Section("Build Information")
@@ -316,14 +222,12 @@ class SettingsViewController: FormViewController {
         }
 
         showHideNSDetails()
-        checkNightscoutStatus()
     }
 
     override func viewWillAppear(_ animated: Bool) {
         super.viewWillAppear(animated)
 
         refreshVersionInfo()
-        checkNightscoutStatus()
     }
 
     func refreshVersionInfo() {
@@ -363,41 +267,6 @@ class SettingsViewController: FormViewController {
 #endif
     }
 
-    func updateStatusLabel(error: NightscoutUtils.NightscoutError?) {
-        if let error = error {
-            switch error {
-            case .invalidURL:
-                statusLabelRow.value = "Invalid URL"
-            case .networkError:
-                statusLabelRow.value = "Network Error"
-            case .invalidToken:
-                statusLabelRow.value = "Invalid Token"
-            case .tokenRequired:
-                statusLabelRow.value = "Token Required"
-            case .siteNotFound:
-                statusLabelRow.value = "Site Not Found"
-            case .unknown:
-                statusLabelRow.value = "Unknown Error"
-            case .emptyAddress:
-                statusLabelRow.value = "Address Empty"
-            }
-        } else {
-            statusLabelRow.value = "OK (Read\(ObservableUserDefaults.shared.nsWriteAuth.value ? " & Write" : ""))"
-            NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
-        }
-        statusLabelRow.updateCell()
-    }
-
-    func checkNightscoutStatus() {
-        NightscoutUtils.verifyURLAndToken { error, jwtToken, nsWriteAuth in
-            DispatchQueue.main.async {
-                ObservableUserDefaults.shared.nsWriteAuth.value = nsWriteAuth
-
-                self.updateStatusLabel(error: error)
-            }
-        }
-    }
-
     func presentInfoDisplaySettings() {
         let viewModel = InfoDisplaySettingsViewModel()
         let settingsView = InfoDisplaySettingsView(viewModel: viewModel)
@@ -425,7 +294,6 @@ class SettingsViewController: FormViewController {
         present(hostingController, animated: true, completion: nil)
     }
 
-
     func presentContactSettings() {
         let viewModel = ContactSettingsViewModel()
         let contactSettingsView = ContactSettingsView(viewModel: viewModel)
@@ -438,4 +306,89 @@ class SettingsViewController: FormViewController {
 
         present(hostingController, animated: true, completion: nil)
     }
+
+    func presentBackgroundRefreshSettings() {
+        let viewModel = BackgroundRefreshSettingsViewModel()
+        let settingsView = BackgroundRefreshSettingsView(viewModel: viewModel)
+        let hostingController = UIHostingController(rootView: settingsView)
+        hostingController.modalPresentationStyle = .formSheet
+
+        if UserDefaultsRepository.forceDarkMode.value {
+            hostingController.overrideUserInterfaceStyle = .dark
+        }
+
+        present(hostingController, animated: true, completion: nil)
+    }
+
+    func presentLogView() {
+        let viewModel = LogViewModel()
+        let logView = LogView(viewModel: viewModel)
+        let hostingController = UIHostingController(rootView: logView)
+        hostingController.modalPresentationStyle = .formSheet
+
+        if UserDefaultsRepository.forceDarkMode.value {
+            hostingController.overrideUserInterfaceStyle = .dark
+        }
+
+        present(hostingController, animated: true, completion: nil)
+    }
+
+    func presentNightscoutSettingsView() {
+        let viewModel = NightscoutSettingsViewModel()
+        viewModel.delegate = self
+
+        let view = NightscoutSettingsView(viewModel: viewModel)
+        let hostingController = UIHostingController(rootView: view)
+        hostingController.modalPresentationStyle = .formSheet
+
+        if UserDefaultsRepository.forceDarkMode.value {
+            hostingController.overrideUserInterfaceStyle = .dark
+        }
+
+        present(hostingController, animated: true, completion: nil)
+    }
+
+    func nightscoutSettingsDidFinish() {
+        showHideNSDetails()
+    }
+
+    func presentDexcomSettingsView() {
+        let viewModel = DexcomSettingsViewModel()
+        let settingsView = DexcomSettingsView(viewModel: viewModel)
+        let hostingController = UIHostingController(rootView: settingsView)
+        hostingController.modalPresentationStyle = .formSheet
+
+        if UserDefaultsRepository.forceDarkMode.value {
+            hostingController.overrideUserInterfaceStyle = .dark
+        }
+
+        present(hostingController, animated: true, completion: nil)
+    }
+
+    func presentAdvancedSettingsView() {
+        let viewModel = AdvancedSettingsViewModel()
+        let view = AdvancedSettingsView(viewModel: viewModel)
+        let hostingController = UIHostingController(rootView: view)
+        hostingController.modalPresentationStyle = .formSheet
+
+        if UserDefaultsRepository.forceDarkMode.value {
+            hostingController.overrideUserInterfaceStyle = .dark
+        }
+
+        present(hostingController, animated: true, completion: nil)
+    }
+
+    private func shareLogs() {
+        let logFilesToShare = LogManager.shared.logFilesForTodayAndYesterday()
+
+        if !logFilesToShare.isEmpty {
+            let activityViewController = UIActivityViewController(activityItems: logFilesToShare, applicationActivities: nil)
+            activityViewController.popoverPresentationController?.sourceView = self.view
+            present(activityViewController, animated: true, completion: nil)
+        } else {
+            let alert = UIAlertController(title: "No Logs Available", message: "There are no logs to share.", preferredStyle: .alert)
+            alert.addAction(UIAlertAction(title: "OK", style: .default))
+            present(alert, animated: true, completion: nil)
+        }
+    }
 }

+ 1 - 1
LoopFollow/ViewControllers/SnoozeViewController.swift

@@ -246,7 +246,7 @@ class SnoozeViewController: UIViewController, UNUserNotificationCenterDelegate {
             alarms.reloadSnoozeTime(key: "alertTempTargetEndSnoozedTime", setNil: false, value: currentDate.addingTimeInterval(snoozeDuration))
 
         default:
-            print("Unhandled alarm: \(AlarmSound.whichAlarm)")
+            LogManager.shared.log(category: .alarm, message: "Unhandled alarm: \(AlarmSound.whichAlarm)")
         }
     }