Преглед на файлове

合并 LoopFollow v7.0.0

q5078620 преди 3 дни
родител
ревизия
6df6c7132b
променени са 100 файла, в които са добавени 5627 реда и са изтрити 5505 реда
  1. 30 6
      .github/workflows/build_LoopFollow.yml
  2. 4 0
      .github/workflows/tag_on_main.yml
  3. 1 1
      Config.xcconfig
  4. 1 1
      Gemfile
  5. 29 25
      Gemfile.lock
  6. 63 2089
      LoopFollow.xcodeproj/project.pbxproj
  7. 30 2
      LoopFollow/Alarm/AddAlarm/AddAlarmSheet.swift
  8. 60 37
      LoopFollow/Alarm/Alarm.swift
  9. 14 1
      LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift
  10. 5 0
      LoopFollow/Alarm/AlarmCondition/BatteryCondition.swift
  11. 18 0
      LoopFollow/Alarm/AlarmCondition/DBSizeCondition.swift
  12. 10 0
      LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift
  13. 23 4
      LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift
  14. 2 0
      LoopFollow/Alarm/AlarmData.swift
  15. 1 0
      LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift
  16. 97 14
      LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift
  17. 231 114
      LoopFollow/Alarm/AlarmEditing/Components/SoundFile.swift
  18. 33 0
      LoopFollow/Alarm/AlarmEditing/Editors/DBSizeAlarmEditor.swift
  19. 8 0
      LoopFollow/Alarm/AlarmEditing/Editors/HighBgAlarmEditor.swift
  20. 21 10
      LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift
  21. 8 0
      LoopFollow/Alarm/AlarmEditing/Editors/PhoneBatteryAlarmEditor.swift
  22. 48 4
      LoopFollow/Alarm/AlarmListView.swift
  23. 10 0
      LoopFollow/Alarm/AlarmManager.swift
  24. 1 1
      LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift
  25. 1 1
      LoopFollow/Alarm/AlarmType/AlarmType+SortDirection.swift
  26. 1 1
      LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift
  27. 1 0
      LoopFollow/Alarm/AlarmType/AlarmType.swift
  28. 237 0
      LoopFollow/Alarm/CustomSoundStore.swift
  29. 0 9
      LoopFollow/Alarm/SnoozeState.swift
  30. 67 62
      LoopFollow/Application/AppDelegate.swift
  31. 18 9
      LoopFollow/Application/LoopFollowApp.swift
  32. 64 7
      LoopFollow/Application/MainTabView.swift
  33. 19 0
      LoopFollow/Application/StorageLoadingView.swift
  34. 95 5
      LoopFollow/BackgroundRefresh/BT/BLEManager.swift
  35. 0 1
      LoopFollow/BackgroundRefresh/BT/BluetoothDevice.swift
  36. 0 31
      LoopFollow/BackgroundRefresh/BT/DexcomG7HeartBeat.swift
  37. 7 1
      LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift
  38. 75 0
      LoopFollow/Banner/AppBannerView.swift
  39. 172 0
      LoopFollow/Banner/BannerManager.swift
  40. 599 0
      LoopFollow/Charts/BGChartModel.swift
  41. 194 0
      LoopFollow/Charts/BGChartStubs.swift
  42. 1623 0
      LoopFollow/Charts/BGChartView.swift
  43. 24 1
      LoopFollow/Controllers/AlarmSound.swift
  44. 4 1
      LoopFollow/Controllers/BackgroundAlertManager.swift
  45. 0 2182
      LoopFollow/Controllers/Graphs.swift
  46. 4 3
      LoopFollow/Controllers/NightScout.swift
  47. 6 1
      LoopFollow/Controllers/Nightscout/BGData.swift
  48. 1 1
      LoopFollow/Controllers/Nightscout/CAge.swift
  49. 62 0
      LoopFollow/Controllers/Nightscout/DBSize.swift
  50. 32 5
      LoopFollow/Controllers/Nightscout/DeviceStatus.swift
  51. 5 3
      LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
  52. 17 8
      LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift
  53. 1 1
      LoopFollow/Controllers/Nightscout/IAge.swift
  54. 4 2
      LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift
  55. 1 1
      LoopFollow/Controllers/Nightscout/SAge.swift
  56. 0 1
      LoopFollow/Controllers/Nightscout/Treatments/BGCheck.swift
  57. 6 2
      LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift
  58. 1 1
      LoopFollow/Controllers/Nightscout/Treatments/InsulinCartridgeChange.swift
  59. 0 1
      LoopFollow/Controllers/Nightscout/Treatments/Notes.swift
  60. 33 13
      LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift
  61. 0 1
      LoopFollow/Controllers/Nightscout/Treatments/SMB.swift
  62. 10 2
      LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift
  63. 20 25
      LoopFollow/Controllers/StatsDisplayView.swift
  64. 2 2
      LoopFollow/Controllers/Timers.swift
  65. 2 8
      LoopFollow/Extensions/EKEventStore+Extensions.swift
  66. 0 31
      LoopFollow/Extensions/HKQuantity+AnyConvertible.swift
  67. 0 130
      LoopFollow/Helpers/AnyConvertible.swift
  68. 0 2
      LoopFollow/Helpers/AppConstants.swift
  69. 1 1
      LoopFollow/Helpers/BackgroundRefreshManager.swift
  70. 0 216
      LoopFollow/Helpers/Chart.swift
  71. 184 3
      LoopFollow/Helpers/NightscoutUtils.swift
  72. 30 0
      LoopFollow/Helpers/NotificationAuthorization.swift
  73. 0 17
      LoopFollow/Helpers/ObservationToken.swift
  74. 1 1
      LoopFollow/Helpers/Telemetry.swift
  75. 10 2
      LoopFollow/Helpers/Views/NavigationRow.swift
  76. 17 20
      LoopFollow/Helpers/Views/TogglableSecureInput.swift
  77. 0 28
      LoopFollow/Helpers/carbBolusArrays.swift
  78. 41 12
      LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift
  79. 18 15
      LoopFollow/InfoDisplaySettings/InfoDisplaySettingsViewModel.swift
  80. 99 0
      LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift
  81. 5 1
      LoopFollow/InfoTable/InfoData.swift
  82. 73 0
      LoopFollow/InfoTable/InfoDisplayItem.swift
  83. 12 7
      LoopFollow/InfoTable/InfoManager.swift
  84. 14 4
      LoopFollow/InfoTable/InfoTableView.swift
  85. 55 22
      LoopFollow/InfoTable/InfoType.swift
  86. 3 0
      LoopFollow/LiveActivity/APNSClient.swift
  87. 12 0
      LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift
  88. 51 235
      LoopFollow/LiveActivity/LiveActivityManager.swift
  89. 33 27
      LoopFollow/LiveActivity/RestartLiveActivityIntent.swift
  90. 17 13
      LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift
  91. 1 1
      LoopFollow/LiveActivitySettingsView.swift
  92. 29 2
      LoopFollow/Nightscout/NightscoutSettingsView.swift
  93. 256 14
      LoopFollow/Nightscout/NightscoutSettingsViewModel.swift
  94. 49 0
      LoopFollow/Onboarding/ConnectionStatusPill.swift
  95. 176 0
      LoopFollow/Onboarding/LoopFollowLogo.swift
  96. 138 0
      LoopFollow/Onboarding/OnboardingContainerView.swift
  97. 39 0
      LoopFollow/Onboarding/OnboardingNavFooter.swift
  98. 69 0
      LoopFollow/Onboarding/OnboardingStep.swift
  99. 38 0
      LoopFollow/Onboarding/OnboardingStepHeader.swift
  100. 0 0
      LoopFollow/Onboarding/OnboardingViewModel.swift

+ 30 - 6
.github/workflows/build_LoopFollow.yml

@@ -131,10 +131,10 @@ jobs:
         if: steps.workflow-permission.outputs.has_permission != 'true'
         if: steps.workflow-permission.outputs.has_permission != 'true'
         run: |
         run: |
           echo "### :calendar: Scheduled Sync and Build Disabled :mobile_phone_off:" >> $GITHUB_STEP_SUMMARY
           echo "### :calendar: Scheduled Sync and Build Disabled :mobile_phone_off:" >> $GITHUB_STEP_SUMMARY
-          echo "You have not yet configured the scheduled sync and build for LoopFollow's browser build." >> $GITHUB_STEP_SUMMARY
-          echo "Synchronizing your fork of <code>LoopFollow</code> with the upstream repository <code>loopandlearn/LoopFollow</code> will be skipped." >> $GITHUB_STEP_SUMMARY
-          echo "If you want to enable automatic builds and updates for your LoopFollow, please follow the instructions \
-              under the following path <code>LoopFollow/fastlane/testflight.md</code>." >> $GITHUB_STEP_SUMMARY
+          echo "You have not yet configured the scheduled sync and build for ${{ github.event.repository.name }}'s browser build." >> $GITHUB_STEP_SUMMARY
+          echo "Synchronizing your fork of <code>${{ github.event.repository.name }}</code> with the upstream repository <code>${{ env.UPSTREAM_REPO }}</code> will be skipped." >> $GITHUB_STEP_SUMMARY
+          echo "If you want to enable automatic builds and updates for your ${{ github.event.repository.name }}, please follow the instructions \
+              under the following path <code>${{ github.event.repository.name }}/fastlane/testflight.md</code>." >> $GITHUB_STEP_SUMMARY
 
 
       # Set a logic flag if this is the second instance of this day-of-week in this month
       # Set a logic flag if this is the second instance of this day-of-week in this month
       - name: Check if this is the second time this day-of-week happens this month
       - name: Check if this is the second time this day-of-week happens this month
@@ -148,6 +148,30 @@ jobs:
             echo "is_second_instance=false" >> "$GITHUB_OUTPUT"
             echo "is_second_instance=false" >> "$GITHUB_OUTPUT"
           fi
           fi
 
 
+  # When a scheduled run finds nothing new to build, cancel the run so it shows
+  # as "cancelled" instead of "success" — a green check should always mean a
+  # new build was made.
+  cancel_when_no_build:
+    needs: check_status
+    name: Cancel run when there is nothing to build
+    runs-on: ubuntu-latest
+    permissions:
+      actions: write
+    if: |
+      github.event_name == 'schedule' &&
+        !(vars.SCHEDULED_BUILD != 'false' && needs.check_status.outputs.IS_SECOND_IN_MONTH == 'true') &&
+        !(vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true')
+    steps:
+      - name: Cancel the run
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          echo "Repository is up to date and no monthly build is due — cancelling this run so it is not reported as a successful build." >> "$GITHUB_STEP_SUMMARY"
+          gh run cancel ${{ github.run_id }} --repo ${{ github.repository }}
+          # Keep the job alive until the cancellation lands, so the run ends as
+          # "cancelled" rather than completing successfully first.
+          sleep 300
+
   # Checks if Distribution certificate is present and valid, optionally nukes and
   # Checks if Distribution certificate is present and valid, optionally nukes and
   # creates new certs if the repository variable ENABLE_NUKE_CERTS == 'true'
   # creates new certs if the repository variable ENABLE_NUKE_CERTS == 'true'
   # only run if a build is planned
   # only run if a build is planned
@@ -161,7 +185,7 @@ jobs:
           (vars.SCHEDULED_BUILD != 'false' && needs.check_status.outputs.IS_SECOND_IN_MONTH == 'true') ||
           (vars.SCHEDULED_BUILD != 'false' && needs.check_status.outputs.IS_SECOND_IN_MONTH == 'true') ||
           (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' )
           (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' )
 
 
-  # Builds LoopFollow
+  # Builds the app
   build:
   build:
     name: Build
     name: Build
     needs: [check_certs, check_status]
     needs: [check_certs, check_status]
@@ -203,7 +227,7 @@ jobs:
       - name: Sync clock
       - name: Sync clock
         run: sudo sntp -sS time.windows.com
         run: sudo sntp -sS time.windows.com
 
 
-      # Build signed LoopFollow IPA file
+      # Build signed IPA file
       - name: Fastlane Build & Archive
       - name: Fastlane Build & Archive
         run: bundle exec fastlane build_LoopFollow
         run: bundle exec fastlane build_LoopFollow
         env:
         env:

+ 4 - 0
.github/workflows/tag_on_main.yml

@@ -17,6 +17,10 @@ on:
   push:
   push:
     branches:
     branches:
       - main
       - main
+  # Manual fallback: if the push-triggered run was ever skipped (e.g. a commit
+  # with [skip ci] landing on main), the tag can be created by dispatching this
+  # workflow against main. It is idempotent — it skips if the tag already exists.
+  workflow_dispatch:
 
 
 jobs:
 jobs:
   tag:
   tag:

+ 1 - 1
Config.xcconfig

@@ -6,5 +6,5 @@
 unique_id = ${DEVELOPMENT_TEAM}
 unique_id = ${DEVELOPMENT_TEAM}
 
 
 //Version (DEFAULT)
 //Version (DEFAULT)
-LOOP_FOLLOW_MARKETING_VERSION = 6.2.1
+LOOP_FOLLOW_MARKETING_VERSION = 7.0.0
 LOOP_FOLLOW_BUILD_NUMBER = 3
 LOOP_FOLLOW_BUILD_NUMBER = 3

+ 1 - 1
Gemfile

@@ -1,2 +1,2 @@
 source "https://rubygems.org"
 source "https://rubygems.org"
-gem "fastlane", "2.235.0"
+gem "fastlane", "2.237.0"

+ 29 - 25
Gemfile.lock

@@ -8,8 +8,8 @@ GEM
     artifactory (3.0.17)
     artifactory (3.0.17)
     atomos (0.1.3)
     atomos (0.1.3)
     aws-eventstream (1.4.0)
     aws-eventstream (1.4.0)
-    aws-partitions (1.1254.0)
-    aws-sdk-core (3.250.0)
+    aws-partitions (1.1267.0)
+    aws-sdk-core (3.253.0)
       aws-eventstream (~> 1, >= 1.3.0)
       aws-eventstream (~> 1, >= 1.3.0)
       aws-partitions (~> 1, >= 1.992.0)
       aws-partitions (~> 1, >= 1.992.0)
       aws-sigv4 (~> 1.9)
       aws-sigv4 (~> 1.9)
@@ -17,10 +17,10 @@ GEM
       bigdecimal
       bigdecimal
       jmespath (~> 1, >= 1.6.1)
       jmespath (~> 1, >= 1.6.1)
       logger
       logger
-    aws-sdk-kms (1.128.0)
+    aws-sdk-kms (1.129.0)
       aws-sdk-core (~> 3, >= 3.248.0)
       aws-sdk-core (~> 3, >= 3.248.0)
       aws-sigv4 (~> 1.5)
       aws-sigv4 (~> 1.5)
-    aws-sdk-s3 (1.224.0)
+    aws-sdk-s3 (1.226.0)
       aws-sdk-core (~> 3, >= 3.248.0)
       aws-sdk-core (~> 3, >= 3.248.0)
       aws-sdk-kms (~> 1)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.5)
       aws-sigv4 (~> 1.5)
@@ -42,8 +42,9 @@ GEM
     domain_name (0.6.20240107)
     domain_name (0.6.20240107)
     dotenv (2.8.1)
     dotenv (2.8.1)
     emoji_regex (3.2.3)
     emoji_regex (3.2.3)
-    excon (0.112.0)
-    faraday (1.10.5)
+    excon (1.5.0)
+      logger
+    faraday (1.10.6)
       faraday-em_http (~> 1.0)
       faraday-em_http (~> 1.0)
       faraday-em_synchrony (~> 1.0)
       faraday-em_synchrony (~> 1.0)
       faraday-excon (~> 1.1)
       faraday-excon (~> 1.1)
@@ -72,10 +73,10 @@ GEM
     faraday_middleware (1.2.1)
     faraday_middleware (1.2.1)
       faraday (~> 1.0)
       faraday (~> 1.0)
     fastimage (2.4.1)
     fastimage (2.4.1)
-    fastlane (2.235.0)
+    fastlane (2.237.0)
       CFPropertyList (>= 2.3, < 5.0.0)
       CFPropertyList (>= 2.3, < 5.0.0)
       abbrev (~> 0.1)
       abbrev (~> 0.1)
-      addressable (>= 2.8, < 3.0.0)
+      addressable (>= 2.9.0, < 3.0.0)
       artifactory (~> 3.0)
       artifactory (~> 3.0)
       aws-sdk-s3 (~> 1.197)
       aws-sdk-s3 (~> 1.197)
       babosa (>= 1.0.3, < 2.0.0)
       babosa (>= 1.0.3, < 2.0.0)
@@ -87,7 +88,7 @@ GEM
       csv (~> 3.3)
       csv (~> 3.3)
       dotenv (>= 2.1.1, < 3.0.0)
       dotenv (>= 2.1.1, < 3.0.0)
       emoji_regex (>= 0.1, < 4.0)
       emoji_regex (>= 0.1, < 4.0)
-      excon (>= 0.71.0, < 1.0.0)
+      excon (>= 0.71.0, < 2.0.0)
       faraday (~> 1.0)
       faraday (~> 1.0)
       faraday-cookie_jar (~> 0.0.6)
       faraday-cookie_jar (~> 0.0.6)
       faraday_middleware (~> 1.0)
       faraday_middleware (~> 1.0)
@@ -101,9 +102,10 @@ GEM
       highline (~> 2.0)
       highline (~> 2.0)
       http-cookie (~> 1.0.5)
       http-cookie (~> 1.0.5)
       json (< 3.0.0)
       json (< 3.0.0)
-      jwt (>= 2.1.0, < 4)
+      jwt (>= 2.10.3, < 4)
       logger (>= 1.6, < 2.0)
       logger (>= 1.6, < 2.0)
       mini_magick (>= 4.9.4, < 5.0.0)
       mini_magick (>= 4.9.4, < 5.0.0)
+      multi_json (~> 1.12)
       multipart-post (>= 2.0.0, < 3.0.0)
       multipart-post (>= 2.0.0, < 3.0.0)
       mutex_m (~> 0.3)
       mutex_m (~> 0.3)
       naturally (~> 2.2)
       naturally (~> 2.2)
@@ -124,7 +126,7 @@ GEM
       xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
       xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
     fastlane-sirp (1.1.0)
     fastlane-sirp (1.1.0)
     gh_inspector (1.1.3)
     gh_inspector (1.1.3)
-    google-apis-androidpublisher_v3 (0.101.0)
+    google-apis-androidpublisher_v3 (0.104.0)
       google-apis-core (>= 0.15.0, < 2.a)
       google-apis-core (>= 0.15.0, < 2.a)
     google-apis-core (0.18.0)
     google-apis-core (0.18.0)
       addressable (~> 2.5, >= 2.5.1)
       addressable (~> 2.5, >= 2.5.1)
@@ -134,20 +136,20 @@ GEM
       mutex_m
       mutex_m
       representable (~> 3.0)
       representable (~> 3.0)
       retriable (>= 2.0, < 4.a)
       retriable (>= 2.0, < 4.a)
-    google-apis-iamcredentials_v1 (0.27.0)
+    google-apis-iamcredentials_v1 (0.28.0)
       google-apis-core (>= 0.15.0, < 2.a)
       google-apis-core (>= 0.15.0, < 2.a)
-    google-apis-playcustomapp_v1 (0.17.0)
+    google-apis-playcustomapp_v1 (0.18.0)
       google-apis-core (>= 0.15.0, < 2.a)
       google-apis-core (>= 0.15.0, < 2.a)
-    google-apis-storage_v1 (0.62.0)
+    google-apis-storage_v1 (0.64.0)
       google-apis-core (>= 0.15.0, < 2.a)
       google-apis-core (>= 0.15.0, < 2.a)
-    google-cloud-core (1.8.0)
+    google-cloud-core (1.9.0)
       google-cloud-env (>= 1.0, < 3.a)
       google-cloud-env (>= 1.0, < 3.a)
       google-cloud-errors (~> 1.0)
       google-cloud-errors (~> 1.0)
     google-cloud-env (2.2.2)
     google-cloud-env (2.2.2)
       base64 (~> 0.2)
       base64 (~> 0.2)
       faraday (>= 1.0, < 3.a)
       faraday (>= 1.0, < 3.a)
-    google-cloud-errors (1.6.0)
-    google-cloud-storage (1.60.0)
+    google-cloud-errors (1.7.0)
+    google-cloud-storage (1.62.0)
       addressable (~> 2.8)
       addressable (~> 2.8)
       digest-crc (~> 0.4)
       digest-crc (~> 0.4)
       google-apis-core (>= 0.18, < 2)
       google-apis-core (>= 0.18, < 2)
@@ -157,13 +159,13 @@ GEM
       googleauth (~> 1.9)
       googleauth (~> 1.9)
       mini_mime (~> 1.0)
       mini_mime (~> 1.0)
     google-logging-utils (0.2.0)
     google-logging-utils (0.2.0)
-    googleauth (1.16.2)
+    googleauth (1.17.1)
       faraday (>= 1.0, < 3.a)
       faraday (>= 1.0, < 3.a)
       google-cloud-env (~> 2.2)
       google-cloud-env (~> 2.2)
       google-logging-utils (~> 0.1)
       google-logging-utils (~> 0.1)
       jwt (>= 1.4, < 4.0)
       jwt (>= 1.4, < 4.0)
-      multi_json (~> 1.11)
       os (>= 0.9, < 2.0)
       os (>= 0.9, < 2.0)
+      pstore (~> 0.1)
       signet (>= 0.16, < 2.a)
       signet (>= 0.16, < 2.a)
     highline (2.0.3)
     highline (2.0.3)
     http-cookie (1.0.8)
     http-cookie (1.0.8)
@@ -171,7 +173,7 @@ GEM
     httpclient (2.9.0)
     httpclient (2.9.0)
       mutex_m
       mutex_m
     jmespath (1.6.2)
     jmespath (1.6.2)
-    json (2.19.7)
+    json (2.20.0)
     jwt (3.2.0)
     jwt (3.2.0)
       base64
       base64
     logger (1.7.0)
     logger (1.7.0)
@@ -182,11 +184,12 @@ GEM
     mutex_m (0.3.0)
     mutex_m (0.3.0)
     nanaimo (0.4.0)
     nanaimo (0.4.0)
     naturally (2.3.0)
     naturally (2.3.0)
-    nkf (0.2.0)
+    nkf (0.3.0)
     optparse (0.8.1)
     optparse (0.8.1)
     os (1.1.4)
     os (1.1.4)
     ostruct (0.6.3)
     ostruct (0.6.3)
     plist (3.7.2)
     plist (3.7.2)
+    pstore (0.2.1)
     public_suffix (7.0.5)
     public_suffix (7.0.5)
     rake (13.4.2)
     rake (13.4.2)
     representable (3.2.0)
     representable (3.2.0)
@@ -199,11 +202,10 @@ GEM
     ruby2_keywords (0.0.5)
     ruby2_keywords (0.0.5)
     rubyzip (2.4.1)
     rubyzip (2.4.1)
     security (0.1.5)
     security (0.1.5)
-    signet (0.21.0)
+    signet (0.22.0)
       addressable (~> 2.8)
       addressable (~> 2.8)
       faraday (>= 0.17.5, < 3.a)
       faraday (>= 0.17.5, < 3.a)
       jwt (>= 1.5, < 4.0)
       jwt (>= 1.5, < 4.0)
-      multi_json (~> 1.10)
     simctl (1.6.10)
     simctl (1.6.10)
       CFPropertyList
       CFPropertyList
       naturally
       naturally
@@ -218,12 +220,14 @@ GEM
     uber (0.1.0)
     uber (0.1.0)
     unicode-display_width (2.6.0)
     unicode-display_width (2.6.0)
     word_wrap (1.0.0)
     word_wrap (1.0.0)
-    xcodeproj (1.27.0)
+    xcodeproj (1.28.1)
       CFPropertyList (>= 2.3.3, < 4.0)
       CFPropertyList (>= 2.3.3, < 4.0)
       atomos (~> 0.1.3)
       atomos (~> 0.1.3)
+      base64
       claide (>= 1.0.2, < 2.0)
       claide (>= 1.0.2, < 2.0)
       colored2 (~> 3.1)
       colored2 (~> 3.1)
       nanaimo (~> 0.4.0)
       nanaimo (~> 0.4.0)
+      nkf
       rexml (>= 3.3.6, < 4.0)
       rexml (>= 3.3.6, < 4.0)
     xcpretty (0.4.1)
     xcpretty (0.4.1)
       rouge (~> 3.28.0)
       rouge (~> 3.28.0)
@@ -235,7 +239,7 @@ PLATFORMS
   ruby
   ruby
 
 
 DEPENDENCIES
 DEPENDENCIES
-  fastlane (= 2.235.0)
+  fastlane (= 2.237.0)
 
 
 BUNDLED WITH
 BUNDLED WITH
   4.0.12
   4.0.12

Файловите разлики са ограничени, защото са твърде много
+ 63 - 2089
LoopFollow.xcodeproj/project.pbxproj


+ 30 - 2
LoopFollow/Alarm/AddAlarm/AddAlarmSheet.swift

@@ -7,22 +7,33 @@ struct AddAlarmSheet: View {
     let onSelect: (AlarmType) -> Void
     let onSelect: (AlarmType) -> Void
     @Environment(\.dismiss) private var dismiss
     @Environment(\.dismiss) private var dismiss
 
 
+    @State private var searchText = ""
+
     private let columns = [
     private let columns = [
         GridItem(.adaptive(minimum: 110), spacing: 16),
         GridItem(.adaptive(minimum: 110), spacing: 16),
     ]
     ]
 
 
+    private func matches(_ type: AlarmType) -> Bool {
+        let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !query.isEmpty else { return true }
+        return type.rawValue.localizedCaseInsensitiveContains(query)
+            || type.blurb.localizedCaseInsensitiveContains(query)
+            || type.group.rawValue.localizedCaseInsensitiveContains(query)
+    }
+
     var body: some View {
     var body: some View {
         NavigationStack {
         NavigationStack {
             ScrollView {
             ScrollView {
                 LazyVGrid(columns: columns, spacing: 16) {
                 LazyVGrid(columns: columns, spacing: 16) {
                     ForEach(AlarmType.Group.allCases, id: \.self) { group in
                     ForEach(AlarmType.Group.allCases, id: \.self) { group in
-                        if AlarmType.allCases.contains(where: { $0.group == group }) {
+                        let types = AlarmType.allCases.filter { $0.group == group && matches($0) }
+                        if !types.isEmpty {
                             Section(header: Text(group.rawValue)
                             Section(header: Text(group.rawValue)
                                 .font(.headline)
                                 .font(.headline)
                                 .frame(maxWidth: .infinity, alignment: .leading)
                                 .frame(maxWidth: .infinity, alignment: .leading)
                                 .padding(.horizontal, 4)
                                 .padding(.horizontal, 4)
                             ) {
                             ) {
-                                ForEach(AlarmType.allCases.filter { $0.group == group }, id: \.self) { type in
+                                ForEach(types, id: \.self) { type in
                                     AlarmTile(type: type) {
                                     AlarmTile(type: type) {
                                         onSelect(type)
                                         onSelect(type)
                                     }
                                     }
@@ -32,8 +43,25 @@ struct AddAlarmSheet: View {
                     }
                     }
                 }
                 }
                 .padding()
                 .padding()
+
+                if !AlarmType.allCases.contains(where: matches) {
+                    VStack(spacing: 8) {
+                        Image(systemName: "magnifyingglass")
+                            .font(.largeTitle)
+                            .foregroundColor(.secondary)
+                        Text("No Results")
+                            .font(.headline)
+                        Text("No alarms match “\(searchText)”.")
+                            .font(.subheadline)
+                            .foregroundColor(.secondary)
+                            .multilineTextAlignment(.center)
+                    }
+                    .padding(.top, 40)
+                    .padding(.horizontal)
+                }
             }
             }
             .navigationTitle("Add Alarm")
             .navigationTitle("Add Alarm")
+            .searchable(text: $searchText, prompt: "Search alarms")
             .toolbar {
             .toolbar {
                 ToolbarItem(placement: .cancellationAction) {
                 ToolbarItem(placement: .cancellationAction) {
                     Button("Cancel") { dismiss() }
                     Button("Cancel") { dismiss() }

+ 60 - 37
LoopFollow/Alarm/Alarm.swift

@@ -11,13 +11,7 @@ protocol DayNightDisplayable {
 
 
 extension DayNightDisplayable where Self: RawRepresentable, Self.RawValue == String {
 extension DayNightDisplayable where Self: RawRepresentable, Self.RawValue == String {
     var displayName: String {
     var displayName: String {
-        switch rawValue {
-        case "always": return String(localized: "Day & Night")
-        case "day": return String(localized: "Day")
-        case "night": return String(localized: "Night")
-        case "never": return String(localized: "Never")
-        default: return String(localized: String.LocalizationValue(rawValue.capitalized))
-        }
+        rawValue == "always" ? "Day & Night" : rawValue.capitalized
     }
     }
 }
 }
 
 
@@ -82,6 +76,17 @@ struct Alarm: Identifiable, Codable, Equatable {
     /// Number of minutes that must satisfy the alarm criteria
     /// Number of minutes that must satisfy the alarm criteria
     var persistentMinutes: Int?
     var persistentMinutes: Int?
 
 
+    /// When set, the low alarm stays silent while BG is rising (positive delta),
+    /// only firing when the latest reading is flat or still falling.
+    var suppressIfRising: Bool = false
+
+    /// When set, the high alarm stays silent while BG is falling (negative delta),
+    /// only firing when the latest reading is flat or still rising.
+    var suppressIfFalling: Bool = false
+
+    /// When set, the phone-battery alarm stays silent while the phone is charging.
+    var suppressIfCharging: Bool = false
+
     /// Size of window to observe values, for example battery drop of x within this number of minutes,
     /// Size of window to observe values, for example battery drop of x within this number of minutes,
     var monitoringWindow: Int?
     var monitoringWindow: Int?
 
 
@@ -107,7 +112,8 @@ struct Alarm: Identifiable, Codable, Equatable {
     enum CodingKeys: String, CodingKey {
     enum CodingKeys: String, CodingKey {
         case id, type, name, isEnabled, snoozedUntil
         case id, type, name, isEnabled, snoozedUntil
         case aboveBG, belowBG, threshold, predictiveMinutes, delta
         case aboveBG, belowBG, threshold, predictiveMinutes, delta
-        case persistentMinutes, monitoringWindow, soundFile
+        case persistentMinutes, suppressIfRising, suppressIfFalling, suppressIfCharging
+        case monitoringWindow, soundFile
         case snoozeDuration, playSoundOption, repeatSoundOption
         case snoozeDuration, playSoundOption, repeatSoundOption
         case soundDelay, activeOption
         case soundDelay, activeOption
         case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits
         case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits
@@ -130,6 +136,9 @@ struct Alarm: Identifiable, Codable, Equatable {
         predictiveMinutes = try container.decodeIfPresent(Int.self, forKey: .predictiveMinutes)
         predictiveMinutes = try container.decodeIfPresent(Int.self, forKey: .predictiveMinutes)
         delta = try container.decodeIfPresent(Double.self, forKey: .delta)
         delta = try container.decodeIfPresent(Double.self, forKey: .delta)
         persistentMinutes = try container.decodeIfPresent(Int.self, forKey: .persistentMinutes)
         persistentMinutes = try container.decodeIfPresent(Int.self, forKey: .persistentMinutes)
+        suppressIfRising = try container.decodeIfPresent(Bool.self, forKey: .suppressIfRising) ?? false
+        suppressIfFalling = try container.decodeIfPresent(Bool.self, forKey: .suppressIfFalling) ?? false
+        suppressIfCharging = try container.decodeIfPresent(Bool.self, forKey: .suppressIfCharging) ?? false
         monitoringWindow = try container.decodeIfPresent(Int.self, forKey: .monitoringWindow)
         monitoringWindow = try container.decodeIfPresent(Int.self, forKey: .monitoringWindow)
         soundFile = try container.decode(SoundFile.self, forKey: .soundFile)
         soundFile = try container.decode(SoundFile.self, forKey: .soundFile)
         snoozeDuration = try container.decodeIfPresent(Int.self, forKey: .snoozeDuration) ?? 5
         snoozeDuration = try container.decodeIfPresent(Int.self, forKey: .snoozeDuration) ?? 5
@@ -160,6 +169,9 @@ struct Alarm: Identifiable, Codable, Equatable {
         try container.encodeIfPresent(predictiveMinutes, forKey: .predictiveMinutes)
         try container.encodeIfPresent(predictiveMinutes, forKey: .predictiveMinutes)
         try container.encodeIfPresent(delta, forKey: .delta)
         try container.encodeIfPresent(delta, forKey: .delta)
         try container.encodeIfPresent(persistentMinutes, forKey: .persistentMinutes)
         try container.encodeIfPresent(persistentMinutes, forKey: .persistentMinutes)
+        try container.encode(suppressIfRising, forKey: .suppressIfRising)
+        try container.encode(suppressIfFalling, forKey: .suppressIfFalling)
+        try container.encode(suppressIfCharging, forKey: .suppressIfCharging)
         try container.encodeIfPresent(monitoringWindow, forKey: .monitoringWindow)
         try container.encodeIfPresent(monitoringWindow, forKey: .monitoringWindow)
         try container.encode(soundFile, forKey: .soundFile)
         try container.encode(soundFile, forKey: .soundFile)
         try container.encode(snoozeDuration, forKey: .snoozeDuration)
         try container.encode(snoozeDuration, forKey: .snoozeDuration)
@@ -265,10 +277,10 @@ struct Alarm: Identifiable, Codable, Equatable {
             }
             }
         }()
         }()
 
 
-        AlarmManager.shared.sendNotification(title: type.localizedTitle, actionTitle: snoozeDuration == 0 ? String(localized: "Acknowledge") : String(localized: "Snooze"))
+        AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze")
 
 
         if playSound {
         if playSound {
-            AlarmSound.setSoundFile(str: soundFile.rawValue)
+            AlarmSound.setSoundFile(soundFile)
             // Only use delay if repeating is enabled, otherwise delay doesn't make sense
             // Only use delay if repeating is enabled, otherwise delay doesn't make sense
             let delay = shouldRepeat ? soundDelay : 0
             let delay = shouldRepeat ? soundDelay : 0
             AlarmSound.play(repeating: shouldRepeat, delay: delay)
             AlarmSound.play(repeating: shouldRepeat, delay: delay)
@@ -306,8 +318,8 @@ struct Alarm: Identifiable, Codable, Equatable {
             persistentMinutes = 0
             persistentMinutes = 0
         case .fastDrop:
         case .fastDrop:
             soundFile = .bigClockTicking
             soundFile = .bigClockTicking
-            delta = 18
-            monitoringWindow = 2
+            delta = 15
+            monitoringWindow = 3
         case .fastRise:
         case .fastRise:
             soundFile = .cartoonFailStringsTrumpet
             soundFile = .cartoonFailStringsTrumpet
             delta = 10
             delta = 10
@@ -317,7 +329,7 @@ struct Alarm: Identifiable, Codable, Equatable {
             threshold = 16
             threshold = 16
         case .notLooping:
         case .notLooping:
             soundFile = .sciFiEngineShutDown
             soundFile = .sciFiEngineShutDown
-            threshold = 31
+            threshold = 16
         case .missedBolus:
         case .missedBolus:
             soundFile = .dholShuffleloop
             soundFile = .dholShuffleloop
             monitoringWindow = 15
             monitoringWindow = 15
@@ -370,6 +382,15 @@ struct Alarm: Identifiable, Codable, Equatable {
             snoozeDuration = 0
             snoozeDuration = 0
             aboveBG = 180
             aboveBG = 180
             belowBG = 70
             belowBG = 70
+        case .dbSize:
+            /// Nightscout's own dbsize plugin warns at 60% and calls 75% urgent
+            soundFile = .wrongAnswer
+            threshold = 75
+            snoozeDuration = 1
+            /// The database fills over weeks, so there is never a reason to wake someone for it
+            activeOption = .day
+            playSoundOption = .day
+            repeatSoundOption = .day
         }
         }
     }
     }
 }
 }
@@ -389,7 +410,7 @@ extension AlarmType {
         case .iob, .cob, .missedBolus, .futureCarbs, .recBolus:
         case .iob, .cob, .missedBolus, .futureCarbs, .recBolus:
             return .insulin
             return .insulin
         case .battery, .batteryDrop, .pump, .pumpBattery, .pumpChange,
         case .battery, .batteryDrop, .pump, .pumpBattery, .pumpChange,
-             .sensorChange, .notLooping, .buildExpire:
+             .sensorChange, .notLooping, .buildExpire, .dbSize:
             return .device
             return .device
         case .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd:
         case .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd:
             return .other
             return .other
@@ -416,6 +437,7 @@ extension AlarmType {
         case .sensorChange: return "sensor.tag.radiowaves.forward"
         case .sensorChange: return "sensor.tag.radiowaves.forward"
         case .notLooping: return "circle.slash"
         case .notLooping: return "circle.slash"
         case .buildExpire: return "calendar.badge.exclamationmark"
         case .buildExpire: return "calendar.badge.exclamationmark"
+        case .dbSize: return "externaldrive.badge.exclamationmark"
         case .overrideStart: return "play.circle"
         case .overrideStart: return "play.circle"
         case .overrideEnd: return "stop.circle"
         case .overrideEnd: return "stop.circle"
         case .tempTargetStart: return "flag"
         case .tempTargetStart: return "flag"
@@ -426,29 +448,30 @@ extension AlarmType {
 
 
     var blurb: String {
     var blurb: String {
         switch self {
         switch self {
-        case .low: return String(localized: "Alerts when BG goes below a limit.")
-        case .high: return String(localized: "Alerts when BG rises above a limit.")
-        case .fastDrop: return String(localized: "Rapid downward BG trend.")
-        case .fastRise: return String(localized: "Rapid upward BG trend.")
-        case .missedReading: return String(localized: "No CGM data for X minutes.")
-        case .iob: return String(localized: "High insulin-on-board.")
-        case .cob: return String(localized: "High carbs-on-board.")
-        case .missedBolus: return String(localized: "Carbs without bolus.")
-        case .futureCarbs: return String(localized: "Reminder when future carbs are due.")
-        case .recBolus: return String(localized: "Recommended bolus issued.")
-        case .battery: return String(localized: "Phone battery low.")
-        case .batteryDrop: return String(localized: "Battery drops quickly.")
-        case .pump: return String(localized: "Reservoir level low.")
-        case .pumpBattery: return String(localized: "Pump battery low.")
-        case .pumpChange: return String(localized: "Pump change due.")
-        case .sensorChange: return String(localized: "Sensor change due.")
-        case .notLooping: return String(localized: "Loop hasn’t completed.")
-        case .buildExpire: return String(localized: "Looping-app build expiring.")
-        case .overrideStart: return String(localized: "Override just started.")
-        case .overrideEnd: return String(localized: "Override ended.")
-        case .tempTargetStart: return String(localized: "Temp target started.")
-        case .tempTargetEnd: return String(localized: "Temp target ended.")
-        case .temporary: return String(localized: "One-time BG limit alert.")
+        case .low: return "Alerts when BG goes below a limit."
+        case .high: return "Alerts when BG rises above a limit."
+        case .fastDrop: return "Rapid downward BG trend."
+        case .fastRise: return "Rapid upward BG trend."
+        case .missedReading: return "No CGM data for X minutes."
+        case .iob: return "High insulin-on-board."
+        case .cob: return "High carbs-on-board."
+        case .missedBolus: return "Carbs without bolus."
+        case .futureCarbs: return "Reminder when future carbs are due."
+        case .recBolus: return "Recommended bolus issued."
+        case .battery: return "Phone battery low."
+        case .batteryDrop: return "Battery drops quickly."
+        case .pump: return "Reservoir level low."
+        case .pumpBattery: return "Pump battery low."
+        case .pumpChange: return "Pump change due."
+        case .sensorChange: return "Sensor change due."
+        case .notLooping: return "Loop hasn’t completed."
+        case .buildExpire: return "Looping-app build expiring."
+        case .dbSize: return "Nightscout database filling up."
+        case .overrideStart: return "Override just started."
+        case .overrideEnd: return "Override ended."
+        case .tempTargetStart: return "Temp target started."
+        case .tempTargetEnd: return "Temp target ended."
+        case .temporary: return "One-time BG limit alert."
         }
         }
     }
     }
 }
 }

+ 14 - 1
LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift

@@ -8,9 +8,15 @@ protocol AlarmCondition {
     init()
     init()
     /// pure, per-alarm logic against `AlarmData`
     /// pure, per-alarm logic against `AlarmData`
     func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool
     func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool
+    /// `true` when `belowBG`/`aboveBG` are this condition's own trigger
+    /// threshold, checked in `evaluate`; `false` (the default) treats them
+    /// as activation limits enforced by `passesBGLimits`.
+    var checksOwnBGLimits: Bool { get }
 }
 }
 
 
 extension AlarmCondition {
 extension AlarmCondition {
+    var checksOwnBGLimits: Bool { false }
+
     /// Returns `true` when the alarm is allowed to continue evaluating
     /// Returns `true` when the alarm is allowed to continue evaluating
     /// after BG-limit checks; `false` blocks it immediately.
     /// after BG-limit checks; `false` blocks it immediately.
     func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool {
     func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool {
@@ -24,7 +30,14 @@ extension AlarmCondition {
         if alarm.type.isBGBased && !haveBG { return false }
         if alarm.type.isBGBased && !haveBG { return false }
 
 
         // ────────────────────────────────────
         // ────────────────────────────────────
-        // 2. No limits?  we’re done.
+        // 2. Conditions that check their own threshold in `evaluate`
+        //    skip the limit gate (the data requirement above applies
+        //    regardless).
+        // ────────────────────────────────────
+        if checksOwnBGLimits { return true }
+
+        // ────────────────────────────────────
+        // 3. No limits?  we’re done.
         // ────────────────────────────────────
         // ────────────────────────────────────
         if alarm.belowBG == nil && alarm.aboveBG == nil { return true }
         if alarm.belowBG == nil && alarm.aboveBG == nil { return true }
 
 

+ 5 - 0
LoopFollow/Alarm/AlarmCondition/BatteryCondition.swift

@@ -11,6 +11,11 @@ struct BatteryCondition: AlarmCondition {
         guard let limit = alarm.threshold, limit > 0 else { return false }
         guard let limit = alarm.threshold, limit > 0 else { return false }
         guard let level = data.latestBattery else { return false }
         guard let level = data.latestBattery else { return false }
 
 
+        // Optional: stay silent while the phone is charging back up.
+        if alarm.suppressIfCharging, data.latestBatteryIsCharging == true {
+            return false
+        }
+
         return level <= limit
         return level <= limit
     }
     }
 }
 }

+ 18 - 0
LoopFollow/Alarm/AlarmCondition/DBSizeCondition.swift

@@ -0,0 +1,18 @@
+// LoopFollow
+// DBSizeCondition.swift
+
+import Foundation
+
+/// Fires when the Nightscout database has filled **≥ threshold percent**
+/// of the size limit configured on the site (`DBSIZE_MAX`).
+struct DBSizeCondition: AlarmCondition {
+    static let type: AlarmType = .dbSize
+    init() {}
+
+    func evaluate(alarm: Alarm, data: AlarmData, now _: Date) -> Bool {
+        guard let limitPercentage = alarm.threshold, limitPercentage > 0 else { return false }
+        guard let usedPercentage = data.dbSizePercentage else { return false }
+
+        return usedPercentage >= limitPercentage
+    }
+}

+ 10 - 0
LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift

@@ -35,6 +35,16 @@ struct HighBGCondition: AlarmCondition {
             }
             }
         }
         }
 
 
+        // Optional: stay silent while the latest reading is below the previous one;
+        // only alarm when BG is flat or still rising.
+        if alarm.suppressIfFalling, data.bgReadings.count >= 2 {
+            let last = data.bgReadings[data.bgReadings.count - 1]
+            let previous = data.bgReadings[data.bgReadings.count - 2]
+            if last.sgv > 0, previous.sgv > 0, last.sgv < previous.sgv {
+                return false
+            }
+        }
+
         return persistentOK
         return persistentOK
     }
     }
 }
 }

+ 23 - 4
LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift

@@ -4,17 +4,23 @@
 import Foundation
 import Foundation
 
 
 /// Fires when:
 /// Fires when:
-/// • every BG in `persistentMinutes` (if set) **and** the latest BG are ≤ `threshold`; **or**
-/// • any predicted BG within `predictiveMinutes` is ≤ `threshold`.
+/// • every BG in `persistentMinutes` (if set) **and** the latest BG are ≤ `belowBG`; **or**
+/// • any predicted BG within `predictiveMinutes` is ≤ `belowBG`.
 struct LowBGCondition: AlarmCondition {
 struct LowBGCondition: AlarmCondition {
     static let type: AlarmType = .low
     static let type: AlarmType = .low
     init() {}
     init() {}
 
 
+    /// `belowBG` is this alarm's trigger threshold, not an activation limit:
+    /// the predictive look-ahead must be able to run while the latest BG is
+    /// still above it, so `evaluate` checks the threshold itself.
+    var checksOwnBGLimits: Bool { true }
+
     func evaluate(alarm: Alarm, data: AlarmData, now _: Date) -> Bool {
     func evaluate(alarm: Alarm, data: AlarmData, now _: Date) -> Bool {
         // ────────────────────────────────
         // ────────────────────────────────
         // 0. sanity checks
         // 0. sanity checks
         // ────────────────────────────────
         // ────────────────────────────────
         guard let belowBG = alarm.belowBG else { return false }
         guard let belowBG = alarm.belowBG else { return false }
+        guard let latest = data.bgReadings.last, latest.sgv > 0 else { return false }
 
 
         func isLow(_ g: GlucoseValue) -> Bool {
         func isLow(_ g: GlucoseValue) -> Bool {
             g.sgv > 0 && Double(g.sgv) <= belowBG
             g.sgv > 0 && Double(g.sgv) <= belowBG
@@ -58,8 +64,21 @@ struct LowBGCondition: AlarmCondition {
         }
         }
 
 
         // ────────────────────────────────
         // ────────────────────────────────
-        // 3. final decision
+        // 3. rising BG suppression (optional)
+        //    Stay silent while the latest reading is above the previous one;
+        //    only alarm when BG is flat or still falling.
+        // ────────────────────────────────
+        if alarm.suppressIfRising, data.bgReadings.count >= 2 {
+            let last = data.bgReadings[data.bgReadings.count - 1]
+            let previous = data.bgReadings[data.bgReadings.count - 2]
+            if last.sgv > 0, previous.sgv > 0, last.sgv > previous.sgv {
+                return false
+            }
+        }
+
+        // ────────────────────────────────
+        // 4. final decision
         // ────────────────────────────────
         // ────────────────────────────────
-        return persistentOK || predictiveTrigger
+        return (isLow(latest) && persistentOK) || predictiveTrigger
     }
     }
 }
 }

+ 2 - 0
LoopFollow/Alarm/AlarmData.swift

@@ -20,7 +20,9 @@ struct AlarmData: Codable {
     let IOB: Double?
     let IOB: Double?
     let recentBoluses: [BolusEntry]
     let recentBoluses: [BolusEntry]
     let latestBattery: Double?
     let latestBattery: Double?
+    let latestBatteryIsCharging: Bool?
     let latestPumpBattery: Double?
     let latestPumpBattery: Double?
     let batteryHistory: [DataStructs.batteryStruct]
     let batteryHistory: [DataStructs.batteryStruct]
     let recentCarbs: [CarbSample]
     let recentCarbs: [CarbSample]
+    let dbSizePercentage: Double?
 }
 }

+ 1 - 0
LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift

@@ -83,6 +83,7 @@ struct AlarmEditor: View {
         case .batteryDrop: BatteryDropAlarmEditor(alarm: $alarm)
         case .batteryDrop: BatteryDropAlarmEditor(alarm: $alarm)
         case .missedBolus: MissedBolusAlarmEditor(alarm: $alarm)
         case .missedBolus: MissedBolusAlarmEditor(alarm: $alarm)
         case .futureCarbs: FutureCarbsAlarmEditor(alarm: $alarm)
         case .futureCarbs: FutureCarbsAlarmEditor(alarm: $alarm)
+        case .dbSize: DBSizeAlarmEditor(alarm: $alarm)
         }
         }
     }
     }
 }
 }

+ 97 - 14
LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift

@@ -2,6 +2,7 @@
 // AlarmAudioSection.swift
 // AlarmAudioSection.swift
 
 
 import SwiftUI
 import SwiftUI
+import UniformTypeIdentifiers
 
 
 struct AlarmAudioSection: View {
 struct AlarmAudioSection: View {
     @Binding var alarm: Alarm
     @Binding var alarm: Alarm
@@ -111,27 +112,37 @@ private struct TonePickerSheet: View {
     @Binding var selected: SoundFile
     @Binding var selected: SoundFile
     @Environment(\.dismiss) private var dismiss
     @Environment(\.dismiss) private var dismiss
 
 
+    @State private var customSounds: [CustomSound] = []
+    @State private var showingImporter = false
+    @State private var importError: String?
+
     var body: some View {
     var body: some View {
         NavigationView {
         NavigationView {
             ScrollViewReader { proxy in
             ScrollViewReader { proxy in
                 List {
                 List {
-                    ForEach(SoundFile.allCases) { tone in
+                    Section {
+                        ForEach(customSounds) { sound in
+                            toneRow(tone: .custom(sound.id), label: sound.displayName)
+                                .id(SoundFile.custom(sound.id))
+                        }
+                        .onDelete(perform: deleteCustomSounds)
+
                         Button {
                         Button {
-                            selected = tone
-                            AlarmSound.setSoundFile(str: tone.rawValue)
-                            AlarmSound.stop()
-                            AlarmSound.playTest()
+                            showingImporter = true
                         } label: {
                         } label: {
-                            HStack {
-                                Text(tone.displayName)
-                                if tone == selected {
-                                    Spacer()
-                                    Image(systemName: "checkmark")
-                                        .foregroundColor(.accentColor)
-                                }
-                            }
+                            Label("Import Sound…", systemImage: "plus.circle")
+                        }
+                    } header: {
+                        Text("Custom")
+                    } footer: {
+                        Text("Custom sounds stay on this device and aren't included in settings export.")
+                    }
+
+                    Section(header: Text("Built-in")) {
+                        ForEach(SoundFile.allBuiltins) { tone in
+                            toneRow(tone: tone, label: tone.displayName)
+                                .id(tone)
                         }
                         }
-                        .id(tone)
                     }
                     }
                 }
                 }
                 .navigationTitle("Choose Tone")
                 .navigationTitle("Choose Tone")
@@ -144,9 +155,81 @@ private struct TonePickerSheet: View {
                     }
                     }
                 }
                 }
                 .onAppear {
                 .onAppear {
+                    reloadCustomSounds()
                     proxy.scrollTo(selected, anchor: .center)
                     proxy.scrollTo(selected, anchor: .center)
                 }
                 }
+                .fileImporter(
+                    isPresented: $showingImporter,
+                    allowedContentTypes: [.audio],
+                    allowsMultipleSelection: false
+                ) { result in
+                    handleImport(result)
+                }
+                .alert(
+                    "Import Failed",
+                    isPresented: Binding(
+                        get: { importError != nil },
+                        set: { if !$0 { importError = nil } }
+                    ),
+                    actions: { Button("OK", role: .cancel) { importError = nil } },
+                    message: { Text(importError ?? "") }
+                )
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func toneRow(tone: SoundFile, label: String) -> some View {
+        Button {
+            selected = tone
+            AlarmSound.setSoundFile(tone)
+            AlarmSound.stop()
+            AlarmSound.playTest()
+        } label: {
+            HStack {
+                Text(label)
+                if tone == selected {
+                    Spacer()
+                    Image(systemName: "checkmark")
+                        .foregroundColor(.accentColor)
+                }
+            }
+        }
+    }
+
+    private func reloadCustomSounds() {
+        customSounds = CustomSoundStore.shared.list()
+    }
+
+    private func deleteCustomSounds(at offsets: IndexSet) {
+        let deleted = offsets.map { customSounds[$0] }
+        for sound in deleted {
+            CustomSoundStore.shared.delete(sound.id)
+        }
+        // If the alarm was pointing at a sound we just deleted, fall back to a valid
+        // built-in so the picker and the alarm don't keep a dangling reference.
+        if deleted.contains(where: { SoundFile.custom($0.id) == selected }) {
+            selected = .fallback
+        }
+        reloadCustomSounds()
+    }
+
+    private func handleImport(_ result: Result<[URL], Error>) {
+        switch result {
+        case let .success(urls):
+            guard let url = urls.first else { return }
+            do {
+                let imported = try CustomSoundStore.shared.importFile(at: url)
+                reloadCustomSounds()
+                selected = .custom(imported.id)
+                AlarmSound.setSoundFile(.custom(imported.id))
+                AlarmSound.stop()
+                AlarmSound.playTest()
+            } catch {
+                importError = error.localizedDescription
             }
             }
+        case let .failure(error):
+            importError = error.localizedDescription
         }
         }
     }
     }
 }
 }

+ 231 - 114
LoopFollow/Alarm/AlarmEditing/Components/SoundFile.swift

@@ -3,122 +3,239 @@
 
 
 import Foundation
 import Foundation
 
 
-enum SoundFile: String, CaseIterable, Identifiable, Codable {
-    case alarmBuzzer = "Alarm_Buzzer"
-    case alarmClock = "Alarm_Clock"
-    case alertToneBusy = "Alert_Tone_Busy"
-    case alertToneRingtone1 = "Alert_Tone_Ringtone_1"
-    case alertToneRingtone2 = "Alert_Tone_Ringtone_2"
-    case alienSiren = "Alien_Siren"
-    case ambulance = "Ambulance"
-    case analogWatchAlarm = "Analog_Watch_Alarm"
-    case bigClockTicking = "Big_Clock_Ticking"
-    case burglarAlarmSiren1 = "Burglar_Alarm_Siren_1"
-    case burglarAlarmSiren2 = "Burglar_Alarm_Siren_2"
-    case cartoonAscendClimbSneaky = "Cartoon_Ascend_Climb_Sneaky"
-    case cartoonAscendThenDescend = "Cartoon_Ascend_Then_Descend"
-    case cartoonBounceToCeiling = "Cartoon_Bounce_To_Ceiling"
-    case cartoonDreamyGlissandoHarp = "Cartoon_Dreamy_Glissando_Harp"
-    case cartoonFailStringsTrumpet = "Cartoon_Fail_Strings_Trumpet"
-    case cartoonMachineClumsyLoop = "Cartoon_Machine_Clumsy_Loop"
-    case cartoonSiren = "Cartoon_Siren"
-    case cartoonTipToeSneakyWalk = "Cartoon_Tip_Toe_Sneaky_Walk"
-    case cartoonUhOh = "Cartoon_Uh_Oh"
-    case cartoonVillainHorns = "Cartoon_Villain_Horns"
-    case cellPhoneRingTone = "Cell_Phone_Ring_Tone"
-    case chimesGlassy = "Chimes_Glassy"
-    case computerMagic = "Computer_Magic"
-    case csfx2Alarm = "CSFX-2_Alarm"
-    case cuckooClock = "Cuckoo_Clock"
-    case dholShuffleloop = "Dhol_Shuffleloop"
-    case discreet = "Discreet"
-    case earlySunrise = "Early_Sunrise"
-    case emergencyAlarmCarbonMonoxide = "Emergency_Alarm_Carbon_Monoxide"
-    case emergencyAlarmSiren = "Emergency_Alarm_Siren"
-    case emergencyAlarm = "Emergency_Alarm"
-    case endingReached = "Ending_Reached"
-    case fly = "Fly"
-    case ghostHover = "Ghost_Hover"
-    case goodMorning = "Good_Morning"
-    case hellYeahSomewhatCalmer = "Hell_Yeah_Somewhat_Calmer"
-    case inAHurry = "In_A_Hurry"
-    case indeed = "Indeed"
-    case insistently = "Insistently"
-    case jingleAllTheWay = "Jingle_All_The_Way"
-    case laserShoot = "Laser_Shoot"
-    case machineCharge = "Machine_Charge"
-    case magicalTwinkle = "Magical_Twinkle"
-    case marchingHeavyFootedFatElephants = "Marching_Heavy_Footed_Fat_Elephants"
-    case marimbaDescend = "Marimba_Descend"
-    case marimbaFlutterOrShake = "Marimba_Flutter_or_Shake"
-    case martianGun = "Martian_Gun"
-    case martianScanner = "Martian_Scanner"
-    case metallic = "Metallic"
-    case nightguard = "Nightguard"
-    case notKiddin = "Not_Kiddin"
-    case openYourEyesAndSee = "Open_Your_Eyes_And_See"
-    case orchestralHorns = "Orchestral_Horns"
-    case oringz = "Oringz"
-    case pagerBeeps = "Pager_Beeps"
-    case remembersMeOfAsia = "Remembers_Me_Of_Asia"
-    case riseAndShine = "Rise_And_Shine"
-    case rush = "Rush"
-    case sciFiAirRaidAlarm = "Sci-Fi_Air_Raid_Alarm"
-    case sciFiAlarmLoop1 = "Sci-Fi_Alarm_Loop_1"
-    case sciFiAlarmLoop2 = "Sci-Fi_Alarm_Loop_2"
-    case sciFiAlarmLoop3 = "Sci-Fi_Alarm_Loop_3"
-    case sciFiAlarmLoop4 = "Sci-Fi_Alarm_Loop_4"
-    case sciFiAlarm = "Sci-Fi_Alarm"
-    case sciFiComputerConsoleAlarm = "Sci-Fi_Computer_Console_Alarm"
-    case sciFiConsoleAlarm = "Sci-Fi_Console_Alarm"
-    case sciFiEerieAlarm = "Sci-Fi_Eerie_Alarm"
-    case sciFiEngineShutDown = "Sci-Fi_Engine_Shut_Down"
-    case sciFiIncomingMessageAlert = "Sci-Fi_Incoming_Message_Alert"
-    case sciFiSpaceshipMessage = "Sci-Fi_Spaceship_Message"
-    case sciFiSpaceshipWarmUp = "Sci-Fi_Spaceship_Warm_Up"
-    case sciFiWarning = "Sci-Fi_Warning"
-    case signatureCorporate = "Signature_Corporate"
-    case siriAlertCalibrationNeeded = "Siri_Alert_Calibration_Needed"
-    case siriAlertDeviceMuted = "Siri_Alert_Device_Muted"
-    case siriAlertGlucoseDroppingFast = "Siri_Alert_Glucose_Dropping_Fast"
-    case siriAlertGlucoseRisingFast = "Siri_Alert_Glucose_Rising_Fast"
-    case siriAlertHighGlucose = "Siri_Alert_High_Glucose"
-    case siriAlertLowGlucose = "Siri_Alert_Low_Glucose"
-    case siriAlertMissedReadings = "Siri_Alert_Missed_Readings"
-    case siriAlertTransmitterBatteryLow = "Siri_Alert_Transmitter_Battery_Low"
-    case siriAlertUrgentHighGlucose = "Siri_Alert_Urgent_High_Glucose"
-    case siriAlertUrgentLowGlucose = "Siri_Alert_Urgent_Low_Glucose"
-    case siriCalibrationNeeded = "Siri_Calibration_Needed"
-    case siriDeviceMuted = "Siri_Device_Muted"
-    case siriGlucoseDroppingFast = "Siri_Glucose_Dropping_Fast"
-    case siriGlucoseRisingFast = "Siri_Glucose_Rising_Fast"
-    case siriHighGlucose = "Siri_High_Glucose"
-    case siriLowGlucose = "Siri_Low_Glucose"
-    case siriMissedReadings = "Siri_Missed_Readings"
-    case siriTransmitterBatteryLow = "Siri_Transmitter_Battery_Low"
-    case siriUrgentHighGlucose = "Siri_Urgent_High_Glucose"
-    case siriUrgentLowGlucose = "Siri_Urgent_Low_Glucose"
-    case softMarimbaPadPositive = "Soft_Marimba_Pad_Positive"
-    case softWarmAiryOptimistic = "Soft_Warm_Airy_Optimistic"
-    case softWarmAiryReassuring = "Soft_Warm_Airy_Reassuring"
-    case storeDoorChime = "Store_Door_Chime"
-    case sunny = "Sunny"
-    case thunderSoundFX = "Thunder_Sound_FX"
-    case timeHasCome = "Time_Has_Come"
-    case tornadoSiren = "Tornado_Siren"
-    case twoTurtleDoves = "Two_Turtle_Doves"
-    case unpaved = "Unpaved"
-    case wakeUpWillYou = "Wake_Up_Will_You"
-    case winGain = "Win_Gain"
-    case wrongAnswer = "Wrong_Answer"
+/// A sound chosen for an alarm.
+///
+/// `.builtin` wraps the filename (no extension) of a `.caf` resource bundled with the app.
+/// `.custom` references an audio file imported by the user into `Documents/CustomSounds/`,
+/// keyed by UUID (see `CustomSoundStore`).
+///
+/// Codable note: built-in values encode/decode as a bare string (e.g. `"Indeed"`) so stored
+/// alarm data written by older app versions continues to load. Custom values encode as a
+/// keyed object and are new with this version.
+enum SoundFile: Hashable, Identifiable {
+    case builtin(String)
+    case custom(UUID)
 
 
-    // Identifiable conformance
-    var id: SoundFile { self }
+    var id: String {
+        switch self {
+        case let .builtin(name): return "builtin:\(name)"
+        case let .custom(uuid): return "custom:\(uuid.uuidString)"
+        }
+    }
 
 
-    /// Human-friendly name (spaces instead of underscores)
+    /// Human-friendly name for display in pickers.
     var displayName: String {
     var displayName: String {
-        rawValue
-            .replacingOccurrences(of: "_", with: " ")
-            .replacingOccurrences(of: "  ", with: " ")
+        switch self {
+        case let .builtin(name):
+            return name
+                .replacingOccurrences(of: "_", with: " ")
+                .replacingOccurrences(of: "  ", with: " ")
+        case let .custom(uuid):
+            return CustomSoundStore.shared.displayName(for: uuid) ?? "Custom Sound"
+        }
+    }
+
+    /// Convenience for call sites that still think in terms of a bundle filename.
+    var builtinName: String? {
+        if case let .builtin(name) = self { return name }
+        return nil
+    }
+
+    /// Back-compat shim for migration code that reads legacy string values out of UserDefaults.
+    /// The string is treated as a built-in filename; playback will fall back gracefully if the
+    /// file isn't actually in the bundle.
+    init?(rawValue: String) {
+        guard !rawValue.isEmpty else { return nil }
+        self = .builtin(rawValue)
     }
     }
 }
 }
+
+extension SoundFile: Codable {
+    private enum CodingKeys: String, CodingKey {
+        case kind, name, id
+    }
+
+    init(from decoder: Decoder) throws {
+        // Legacy form: bare string == built-in filename.
+        if let container = try? decoder.singleValueContainer(),
+           let name = try? container.decode(String.self)
+        {
+            self = .builtin(name)
+            return
+        }
+        let c = try decoder.container(keyedBy: CodingKeys.self)
+        let kind = try c.decode(String.self, forKey: .kind)
+        switch kind {
+        case "builtin":
+            let name = try c.decode(String.self, forKey: .name)
+            self = .builtin(name)
+        case "custom":
+            let uuid = try c.decode(UUID.self, forKey: .id)
+            self = .custom(uuid)
+        default:
+            throw DecodingError.dataCorruptedError(
+                forKey: .kind,
+                in: c,
+                debugDescription: "Unknown SoundFile kind: \(kind)"
+            )
+        }
+    }
+
+    func encode(to encoder: Encoder) throws {
+        switch self {
+        case let .builtin(name):
+            // Preserve legacy wire format so older app versions can still read it.
+            var c = encoder.singleValueContainer()
+            try c.encode(name)
+        case let .custom(uuid):
+            var c = encoder.container(keyedBy: CodingKeys.self)
+            try c.encode("custom", forKey: .kind)
+            try c.encode(uuid, forKey: .id)
+        }
+    }
+}
+
+// MARK: - Built-in catalog
+
+extension SoundFile {
+    /// Fallback used when a referenced sound file can't be located (e.g. a deleted custom sound).
+    static let fallback: SoundFile = .indeed
+
+    /// Every built-in sound shipped with the app, in the order shown in the picker.
+    static let allBuiltins: [SoundFile] = [
+        .alarmBuzzer, .alarmClock, .alertToneBusy, .alertToneRingtone1, .alertToneRingtone2,
+        .alienSiren, .ambulance, .analogWatchAlarm, .bigClockTicking, .burglarAlarmSiren1,
+        .burglarAlarmSiren2, .cartoonAscendClimbSneaky, .cartoonAscendThenDescend,
+        .cartoonBounceToCeiling, .cartoonDreamyGlissandoHarp, .cartoonFailStringsTrumpet,
+        .cartoonMachineClumsyLoop, .cartoonSiren, .cartoonTipToeSneakyWalk, .cartoonUhOh,
+        .cartoonVillainHorns, .cellPhoneRingTone, .chimesGlassy, .computerMagic, .csfx2Alarm,
+        .cuckooClock, .dholShuffleloop, .discreet, .earlySunrise, .emergencyAlarmCarbonMonoxide,
+        .emergencyAlarmSiren, .emergencyAlarm, .endingReached, .fly, .ghostHover, .goodMorning,
+        .hellYeahSomewhatCalmer, .inAHurry, .indeed, .insistently, .jingleAllTheWay, .laserShoot,
+        .machineCharge, .magicalTwinkle, .marchingHeavyFootedFatElephants, .marimbaDescend,
+        .marimbaFlutterOrShake, .martianGun, .martianScanner, .metallic, .nightguard, .notKiddin,
+        .openYourEyesAndSee, .orchestralHorns, .oringz, .pagerBeeps, .remembersMeOfAsia,
+        .riseAndShine, .rush, .sciFiAirRaidAlarm, .sciFiAlarmLoop1, .sciFiAlarmLoop2,
+        .sciFiAlarmLoop3, .sciFiAlarmLoop4, .sciFiAlarm, .sciFiComputerConsoleAlarm,
+        .sciFiConsoleAlarm, .sciFiEerieAlarm, .sciFiEngineShutDown, .sciFiIncomingMessageAlert,
+        .sciFiSpaceshipMessage, .sciFiSpaceshipWarmUp, .sciFiWarning, .signatureCorporate,
+        .siriAlertCalibrationNeeded, .siriAlertDeviceMuted, .siriAlertGlucoseDroppingFast,
+        .siriAlertGlucoseRisingFast, .siriAlertHighGlucose, .siriAlertLowGlucose,
+        .siriAlertMissedReadings, .siriAlertTransmitterBatteryLow, .siriAlertUrgentHighGlucose,
+        .siriAlertUrgentLowGlucose, .siriCalibrationNeeded, .siriDeviceMuted,
+        .siriGlucoseDroppingFast, .siriGlucoseRisingFast, .siriHighGlucose, .siriLowGlucose,
+        .siriMissedReadings, .siriTransmitterBatteryLow, .siriUrgentHighGlucose,
+        .siriUrgentLowGlucose, .softMarimbaPadPositive, .softWarmAiryOptimistic,
+        .softWarmAiryReassuring, .storeDoorChime, .sunny, .thunderSoundFX, .timeHasCome,
+        .tornadoSiren, .twoTurtleDoves, .unpaved, .wakeUpWillYou, .winGain, .wrongAnswer,
+    ]
+
+    // Static aliases so existing call sites like `soundFile = .indeed` keep compiling.
+    static let alarmBuzzer: SoundFile = .builtin("Alarm_Buzzer")
+    static let alarmClock: SoundFile = .builtin("Alarm_Clock")
+    static let alertToneBusy: SoundFile = .builtin("Alert_Tone_Busy")
+    static let alertToneRingtone1: SoundFile = .builtin("Alert_Tone_Ringtone_1")
+    static let alertToneRingtone2: SoundFile = .builtin("Alert_Tone_Ringtone_2")
+    static let alienSiren: SoundFile = .builtin("Alien_Siren")
+    static let ambulance: SoundFile = .builtin("Ambulance")
+    static let analogWatchAlarm: SoundFile = .builtin("Analog_Watch_Alarm")
+    static let bigClockTicking: SoundFile = .builtin("Big_Clock_Ticking")
+    static let burglarAlarmSiren1: SoundFile = .builtin("Burglar_Alarm_Siren_1")
+    static let burglarAlarmSiren2: SoundFile = .builtin("Burglar_Alarm_Siren_2")
+    static let cartoonAscendClimbSneaky: SoundFile = .builtin("Cartoon_Ascend_Climb_Sneaky")
+    static let cartoonAscendThenDescend: SoundFile = .builtin("Cartoon_Ascend_Then_Descend")
+    static let cartoonBounceToCeiling: SoundFile = .builtin("Cartoon_Bounce_To_Ceiling")
+    static let cartoonDreamyGlissandoHarp: SoundFile = .builtin("Cartoon_Dreamy_Glissando_Harp")
+    static let cartoonFailStringsTrumpet: SoundFile = .builtin("Cartoon_Fail_Strings_Trumpet")
+    static let cartoonMachineClumsyLoop: SoundFile = .builtin("Cartoon_Machine_Clumsy_Loop")
+    static let cartoonSiren: SoundFile = .builtin("Cartoon_Siren")
+    static let cartoonTipToeSneakyWalk: SoundFile = .builtin("Cartoon_Tip_Toe_Sneaky_Walk")
+    static let cartoonUhOh: SoundFile = .builtin("Cartoon_Uh_Oh")
+    static let cartoonVillainHorns: SoundFile = .builtin("Cartoon_Villain_Horns")
+    static let cellPhoneRingTone: SoundFile = .builtin("Cell_Phone_Ring_Tone")
+    static let chimesGlassy: SoundFile = .builtin("Chimes_Glassy")
+    static let computerMagic: SoundFile = .builtin("Computer_Magic")
+    static let csfx2Alarm: SoundFile = .builtin("CSFX-2_Alarm")
+    static let cuckooClock: SoundFile = .builtin("Cuckoo_Clock")
+    static let dholShuffleloop: SoundFile = .builtin("Dhol_Shuffleloop")
+    static let discreet: SoundFile = .builtin("Discreet")
+    static let earlySunrise: SoundFile = .builtin("Early_Sunrise")
+    static let emergencyAlarmCarbonMonoxide: SoundFile = .builtin("Emergency_Alarm_Carbon_Monoxide")
+    static let emergencyAlarmSiren: SoundFile = .builtin("Emergency_Alarm_Siren")
+    static let emergencyAlarm: SoundFile = .builtin("Emergency_Alarm")
+    static let endingReached: SoundFile = .builtin("Ending_Reached")
+    static let fly: SoundFile = .builtin("Fly")
+    static let ghostHover: SoundFile = .builtin("Ghost_Hover")
+    static let goodMorning: SoundFile = .builtin("Good_Morning")
+    static let hellYeahSomewhatCalmer: SoundFile = .builtin("Hell_Yeah_Somewhat_Calmer")
+    static let inAHurry: SoundFile = .builtin("In_A_Hurry")
+    static let indeed: SoundFile = .builtin("Indeed")
+    static let insistently: SoundFile = .builtin("Insistently")
+    static let jingleAllTheWay: SoundFile = .builtin("Jingle_All_The_Way")
+    static let laserShoot: SoundFile = .builtin("Laser_Shoot")
+    static let machineCharge: SoundFile = .builtin("Machine_Charge")
+    static let magicalTwinkle: SoundFile = .builtin("Magical_Twinkle")
+    static let marchingHeavyFootedFatElephants: SoundFile = .builtin("Marching_Heavy_Footed_Fat_Elephants")
+    static let marimbaDescend: SoundFile = .builtin("Marimba_Descend")
+    static let marimbaFlutterOrShake: SoundFile = .builtin("Marimba_Flutter_or_Shake")
+    static let martianGun: SoundFile = .builtin("Martian_Gun")
+    static let martianScanner: SoundFile = .builtin("Martian_Scanner")
+    static let metallic: SoundFile = .builtin("Metallic")
+    static let nightguard: SoundFile = .builtin("Nightguard")
+    static let notKiddin: SoundFile = .builtin("Not_Kiddin")
+    static let openYourEyesAndSee: SoundFile = .builtin("Open_Your_Eyes_And_See")
+    static let orchestralHorns: SoundFile = .builtin("Orchestral_Horns")
+    static let oringz: SoundFile = .builtin("Oringz")
+    static let pagerBeeps: SoundFile = .builtin("Pager_Beeps")
+    static let remembersMeOfAsia: SoundFile = .builtin("Remembers_Me_Of_Asia")
+    static let riseAndShine: SoundFile = .builtin("Rise_And_Shine")
+    static let rush: SoundFile = .builtin("Rush")
+    static let sciFiAirRaidAlarm: SoundFile = .builtin("Sci-Fi_Air_Raid_Alarm")
+    static let sciFiAlarmLoop1: SoundFile = .builtin("Sci-Fi_Alarm_Loop_1")
+    static let sciFiAlarmLoop2: SoundFile = .builtin("Sci-Fi_Alarm_Loop_2")
+    static let sciFiAlarmLoop3: SoundFile = .builtin("Sci-Fi_Alarm_Loop_3")
+    static let sciFiAlarmLoop4: SoundFile = .builtin("Sci-Fi_Alarm_Loop_4")
+    static let sciFiAlarm: SoundFile = .builtin("Sci-Fi_Alarm")
+    static let sciFiComputerConsoleAlarm: SoundFile = .builtin("Sci-Fi_Computer_Console_Alarm")
+    static let sciFiConsoleAlarm: SoundFile = .builtin("Sci-Fi_Console_Alarm")
+    static let sciFiEerieAlarm: SoundFile = .builtin("Sci-Fi_Eerie_Alarm")
+    static let sciFiEngineShutDown: SoundFile = .builtin("Sci-Fi_Engine_Shut_Down")
+    static let sciFiIncomingMessageAlert: SoundFile = .builtin("Sci-Fi_Incoming_Message_Alert")
+    static let sciFiSpaceshipMessage: SoundFile = .builtin("Sci-Fi_Spaceship_Message")
+    static let sciFiSpaceshipWarmUp: SoundFile = .builtin("Sci-Fi_Spaceship_Warm_Up")
+    static let sciFiWarning: SoundFile = .builtin("Sci-Fi_Warning")
+    static let signatureCorporate: SoundFile = .builtin("Signature_Corporate")
+    static let siriAlertCalibrationNeeded: SoundFile = .builtin("Siri_Alert_Calibration_Needed")
+    static let siriAlertDeviceMuted: SoundFile = .builtin("Siri_Alert_Device_Muted")
+    static let siriAlertGlucoseDroppingFast: SoundFile = .builtin("Siri_Alert_Glucose_Dropping_Fast")
+    static let siriAlertGlucoseRisingFast: SoundFile = .builtin("Siri_Alert_Glucose_Rising_Fast")
+    static let siriAlertHighGlucose: SoundFile = .builtin("Siri_Alert_High_Glucose")
+    static let siriAlertLowGlucose: SoundFile = .builtin("Siri_Alert_Low_Glucose")
+    static let siriAlertMissedReadings: SoundFile = .builtin("Siri_Alert_Missed_Readings")
+    static let siriAlertTransmitterBatteryLow: SoundFile = .builtin("Siri_Alert_Transmitter_Battery_Low")
+    static let siriAlertUrgentHighGlucose: SoundFile = .builtin("Siri_Alert_Urgent_High_Glucose")
+    static let siriAlertUrgentLowGlucose: SoundFile = .builtin("Siri_Alert_Urgent_Low_Glucose")
+    static let siriCalibrationNeeded: SoundFile = .builtin("Siri_Calibration_Needed")
+    static let siriDeviceMuted: SoundFile = .builtin("Siri_Device_Muted")
+    static let siriGlucoseDroppingFast: SoundFile = .builtin("Siri_Glucose_Dropping_Fast")
+    static let siriGlucoseRisingFast: SoundFile = .builtin("Siri_Glucose_Rising_Fast")
+    static let siriHighGlucose: SoundFile = .builtin("Siri_High_Glucose")
+    static let siriLowGlucose: SoundFile = .builtin("Siri_Low_Glucose")
+    static let siriMissedReadings: SoundFile = .builtin("Siri_Missed_Readings")
+    static let siriTransmitterBatteryLow: SoundFile = .builtin("Siri_Transmitter_Battery_Low")
+    static let siriUrgentHighGlucose: SoundFile = .builtin("Siri_Urgent_High_Glucose")
+    static let siriUrgentLowGlucose: SoundFile = .builtin("Siri_Urgent_Low_Glucose")
+    static let softMarimbaPadPositive: SoundFile = .builtin("Soft_Marimba_Pad_Positive")
+    static let softWarmAiryOptimistic: SoundFile = .builtin("Soft_Warm_Airy_Optimistic")
+    static let softWarmAiryReassuring: SoundFile = .builtin("Soft_Warm_Airy_Reassuring")
+    static let storeDoorChime: SoundFile = .builtin("Store_Door_Chime")
+    static let sunny: SoundFile = .builtin("Sunny")
+    static let thunderSoundFX: SoundFile = .builtin("Thunder_Sound_FX")
+    static let timeHasCome: SoundFile = .builtin("Time_Has_Come")
+    static let tornadoSiren: SoundFile = .builtin("Tornado_Siren")
+    static let twoTurtleDoves: SoundFile = .builtin("Two_Turtle_Doves")
+    static let unpaved: SoundFile = .builtin("Unpaved")
+    static let wakeUpWillYou: SoundFile = .builtin("Wake_Up_Will_You")
+    static let winGain: SoundFile = .builtin("Win_Gain")
+    static let wrongAnswer: SoundFile = .builtin("Wrong_Answer")
+}

+ 33 - 0
LoopFollow/Alarm/AlarmEditing/Editors/DBSizeAlarmEditor.swift

@@ -0,0 +1,33 @@
+// LoopFollow
+// DBSizeAlarmEditor.swift
+
+import SwiftUI
+
+struct DBSizeAlarmEditor: View {
+    @Binding var alarm: Alarm
+
+    var body: some View {
+        Group {
+            InfoBanner(
+                text: "This warns you when the Nightscout database has filled the percentage you choose. Nightscout measures the used space against its own DBSIZE_MAX setting, which is 496 MiB unless the site owner changed it, so the percentage is only meaningful when that setting matches the real hosting limit.",
+                alarmType: alarm.type
+            )
+
+            AlarmGeneralSection(alarm: $alarm)
+
+            AlarmStepperSection(
+                header: "Database Size",
+                footer: "This alerts you when the database reaches or exceeds this percentage of the limit configured on the Nightscout site.",
+                title: "At or Above",
+                range: 0 ... 100,
+                step: 5,
+                unitLabel: "%",
+                value: $alarm.threshold
+            )
+
+            AlarmActiveSection(alarm: $alarm)
+            AlarmAudioSection(alarm: $alarm)
+            AlarmSnoozeSection(alarm: $alarm)
+        }
+    }
+}

+ 8 - 0
LoopFollow/Alarm/AlarmEditing/Editors/HighBgAlarmEditor.swift

@@ -33,6 +33,14 @@ struct HighBgAlarmEditor: View {
                 value: $alarm.persistentMinutes
                 value: $alarm.persistentMinutes
             )
             )
 
 
+            Section(
+                header: Text("FALLING BG"),
+                footer: Text("Stay silent while BG is falling. The alert only sounds "
+                    + "when the latest reading is flat or still rising.")
+            ) {
+                Toggle("Skip if BG is falling", isOn: $alarm.suppressIfFalling)
+            }
+
             AlarmActiveSection(alarm: $alarm)
             AlarmActiveSection(alarm: $alarm)
             AlarmAudioSection(alarm: $alarm)
             AlarmAudioSection(alarm: $alarm)
             AlarmSnoozeSection(alarm: $alarm)
             AlarmSnoozeSection(alarm: $alarm)

+ 21 - 10
LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift

@@ -8,22 +8,23 @@ struct LowBgAlarmEditor: View {
 
 
     var body: some View {
     var body: some View {
         Group {
         Group {
-            InfoBanner(text: "This warns you if the glucose is too low now or might be soon, based on predictions. Note: predictions is currently not available for Trio.")
+            InfoBanner(text: "This warns you if the glucose is too low now or might be soon, based on the forecast.")
 
 
             AlarmGeneralSection(alarm: $alarm)
             AlarmGeneralSection(alarm: $alarm)
 
 
             AlarmBGSection(
             AlarmBGSection(
-                header: String(localized: "Low Limit"),
-                footer: String(localized: "Alert when any reading or prediction is at or below this value."),
-                title: String(localized: "BG"),
+                header: "Low Limit",
+                footer: "Alert when any reading or forecast is at or below this value.",
+                title: "BG",
                 range: 40 ... 150,
                 range: 40 ... 150,
                 value: $alarm.belowBG
                 value: $alarm.belowBG
             )
             )
 
 
             AlarmStepperSection(
             AlarmStepperSection(
-                header: String(localized: "PERSISTENCE"),
-                footer: String(localized: "Glucose must stay below the threshold for this many minutes before the alert sounds. Set 0 to alert immediately."),
-                title: String(localized: "Persistent"),
+                header: "PERSISTENCE",
+                footer: "Glucose must stay below the threshold for this many minutes "
+                    + "before the alert sounds. Set 0 to alert immediately.",
+                title: "Persistent",
                 range: 0 ... 120,
                 range: 0 ... 120,
                 step: 5,
                 step: 5,
                 unitLabel: alarm.type.snoozeTimeUnit.label,
                 unitLabel: alarm.type.snoozeTimeUnit.label,
@@ -31,15 +32,25 @@ struct LowBgAlarmEditor: View {
             )
             )
 
 
             AlarmStepperSection(
             AlarmStepperSection(
-                header: String(localized: "PREDICTION"),
-                footer: String(localized: "Look ahead this many minutes in Loop’s prediction; if any future value is at or below the threshold, you’ll be warned early. Set 0 to disable."),
-                title: String(localized: "Predictive"),
+                header: "PREDICTION",
+                footer: "Look ahead this many minutes in the forecast; "
+                    + "if any future value is at or below the threshold, "
+                    + "you’ll be warned early. Set 0 to disable.",
+                title: "Predictive",
                 range: 0 ... 60,
                 range: 0 ... 60,
                 step: 5,
                 step: 5,
                 unitLabel: alarm.type.snoozeTimeUnit.label,
                 unitLabel: alarm.type.snoozeTimeUnit.label,
                 value: $alarm.predictiveMinutes
                 value: $alarm.predictiveMinutes
             )
             )
 
 
+            Section(
+                header: Text("RISING BG"),
+                footer: Text("Stay silent while BG is rising. The alert only sounds "
+                    + "when the latest reading is flat or still falling.")
+            ) {
+                Toggle("Skip if BG is rising", isOn: $alarm.suppressIfRising)
+            }
+
             AlarmActiveSection(alarm: $alarm)
             AlarmActiveSection(alarm: $alarm)
             AlarmAudioSection(alarm: $alarm)
             AlarmAudioSection(alarm: $alarm)
             AlarmSnoozeSection(alarm: $alarm)
             AlarmSnoozeSection(alarm: $alarm)

+ 8 - 0
LoopFollow/Alarm/AlarmEditing/Editors/PhoneBatteryAlarmEditor.swift

@@ -25,6 +25,14 @@ struct PhoneBatteryAlarmEditor: View {
                 value: $alarm.threshold
                 value: $alarm.threshold
             )
             )
 
 
+            Section(
+                header: Text("CHARGING"),
+                footer: Text("Stay silent while the phone is charging. Requires the "
+                    + "uploader to report charging status; if it doesn't, the alert still sounds.")
+            ) {
+                Toggle("Skip while charging", isOn: $alarm.suppressIfCharging)
+            }
+
             AlarmActiveSection(alarm: $alarm)
             AlarmActiveSection(alarm: $alarm)
             AlarmAudioSection(alarm: $alarm)
             AlarmAudioSection(alarm: $alarm)
             AlarmSnoozeSection(alarm: $alarm)
             AlarmSnoozeSection(alarm: $alarm)

+ 48 - 4
LoopFollow/Alarm/AlarmListView.swift

@@ -22,21 +22,41 @@ struct AlarmListView: View {
     @State private var sheetInfo: SheetInfo?
     @State private var sheetInfo: SheetInfo?
     @State private var deleteAfterDismiss: UUID?
     @State private var deleteAfterDismiss: UUID?
     @State private var selectedAlarm: Alarm?
     @State private var selectedAlarm: Alarm?
+    @State private var searchText = ""
+
+    // MARK: - Search
+
+    private func matches(_ alarm: Alarm) -> Bool {
+        let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !query.isEmpty else { return true }
+        return alarm.name.localizedCaseInsensitiveContains(query)
+            || alarm.type.rawValue.localizedCaseInsensitiveContains(query)
+    }
+
+    private var hasResults: Bool {
+        !snoozedAlarms.isEmpty || !activeAlarms.isEmpty || !inactiveAlarms.isEmpty
+    }
+
+    // Snapshot of "now" used to categorize snoozed vs. active alarms. SwiftUI does
+    // not re-render when the wall clock passes a snooze's expiry, so we refresh this
+    // whenever the screen appears or the app returns to the foreground.
+    @State private var now = Date()
+    @Environment(\.scenePhase) private var scenePhase
 
 
     // MARK: - Categorized Alarms
     // MARK: - Categorized Alarms
 
 
     private var snoozedAlarms: [Alarm] {
     private var snoozedAlarms: [Alarm] {
-        store.value.filter { $0.snoozedUntil ?? .distantPast > Date() && $0.isEnabled }
+        store.value.filter { $0.snoozedUntil ?? .distantPast > now && $0.isEnabled && matches($0) }
             .sorted(by: Alarm.byPriorityThenSpec)
             .sorted(by: Alarm.byPriorityThenSpec)
     }
     }
 
 
     private var activeAlarms: [Alarm] {
     private var activeAlarms: [Alarm] {
-        store.value.filter { $0.isEnabled && ($0.snoozedUntil ?? .distantPast <= Date()) }
+        store.value.filter { $0.isEnabled && ($0.snoozedUntil ?? .distantPast <= now) && matches($0) }
             .sorted(by: Alarm.byPriorityThenSpec)
             .sorted(by: Alarm.byPriorityThenSpec)
     }
     }
 
 
     private var inactiveAlarms: [Alarm] {
     private var inactiveAlarms: [Alarm] {
-        store.value.filter { !$0.isEnabled }
+        store.value.filter { !$0.isEnabled && matches($0) }
             .sorted(by: Alarm.byPriorityThenSpec)
             .sorted(by: Alarm.byPriorityThenSpec)
     }
     }
 
 
@@ -81,6 +101,23 @@ struct AlarmListView: View {
                 }
                 }
             }
             }
         }
         }
+        .overlay {
+            if !hasResults, !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+                VStack(spacing: 8) {
+                    Image(systemName: "magnifyingglass")
+                        .font(.largeTitle)
+                        .foregroundColor(.secondary)
+                    Text("No Results")
+                        .font(.headline)
+                    Text("No alarms match “\(searchText)”.")
+                        .font(.subheadline)
+                        .foregroundColor(.secondary)
+                        .multilineTextAlignment(.center)
+                }
+                .padding(.horizontal)
+            }
+        }
+        .searchable(text: $searchText, prompt: "Search alarms")
         .sheet(item: $sheetInfo, onDismiss: handleSheetDismiss) { info in
         .sheet(item: $sheetInfo, onDismiss: handleSheetDismiss) { info in
             sheetContent(for: info)
             sheetContent(for: info)
         }
         }
@@ -90,6 +127,10 @@ struct AlarmListView: View {
                 Button { sheetInfo = .picker } label: { Image(systemName: "plus") }
                 Button { sheetInfo = .picker } label: { Image(systemName: "plus") }
             }
             }
         }
         }
+        .onAppear { now = Date() }
+        .onChange(of: scenePhase) { phase in
+            if phase == .active { now = Date() }
+        }
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
     }
     }
 
 
@@ -111,7 +152,7 @@ struct AlarmListView: View {
                     Text(alarm.name)
                     Text(alarm.name)
                         .foregroundColor(.primary)
                         .foregroundColor(.primary)
 
 
-                    if let until = alarm.snoozedUntil, until > Date() {
+                    if let until = alarm.snoozedUntil, until > now {
                         HStack(spacing: 4) {
                         HStack(spacing: 4) {
                             Image(systemName: "zzz")
                             Image(systemName: "zzz")
                                 .font(.caption2)
                                 .font(.caption2)
@@ -154,6 +195,9 @@ struct AlarmListView: View {
             AddAlarmSheet { type in
             AddAlarmSheet { type in
                 let new = Alarm(type: type)
                 let new = Alarm(type: type)
                 store.value.append(new)
                 store.value.append(new)
+                // First alarm the user adds is the moment notifications become
+                // useful — request authorization here rather than at app launch.
+                NotificationAuthorization.requestIfNeeded()
                 sheetInfo = .editor(id: new.id, isNew: true)
                 sheetInfo = .editor(id: new.id, isNew: true)
             }
             }
 
 

+ 10 - 0
LoopFollow/Alarm/AlarmManager.swift

@@ -34,6 +34,7 @@ class AlarmManager {
             BatteryCondition.self,
             BatteryCondition.self,
             BatteryDropCondition.self,
             BatteryDropCondition.self,
             FutureCarbsCondition.self,
             FutureCarbsCondition.self,
+            DBSizeCondition.self,
         ]
         ]
     ) {
     ) {
         var dict = [AlarmType: AlarmCondition]()
         var dict = [AlarmType: AlarmCondition]()
@@ -45,6 +46,15 @@ class AlarmManager {
         let now = Date()
         let now = Date()
         var alarmTriggered = false
         var alarmTriggered = false
 
 
+        // No alarm activity while onboarding is on screen — existing or
+        // QR-imported alarms shouldn't sound mid-setup.
+        if Observable.shared.isOnboardingActive.value {
+            if Observable.shared.currentAlarm.value != nil {
+                stopAlarm()
+            }
+            return
+        }
+
         let config = Storage.shared.alarmConfiguration.value
         let config = Storage.shared.alarmConfiguration.value
 
 
         // Honor the "Snooze All" setting. If active, stop any current alarm and exit.
         // Honor the "Snooze All" setting. If active, stop any current alarm and exit.

+ 1 - 1
LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift

@@ -7,7 +7,7 @@ extension AlarmType {
     /// What “unit” we use for snoozeDuration for this alarmType.
     /// What “unit” we use for snoozeDuration for this alarmType.
     var snoozeTimeUnit: TimeUnit {
     var snoozeTimeUnit: TimeUnit {
         switch self {
         switch self {
-        case .buildExpire:
+        case .buildExpire, .dbSize:
             return .day
             return .day
         case .low, .high, .fastDrop, .fastRise,
         case .low, .high, .fastDrop, .fastRise,
              .missedReading, .notLooping, .missedBolus,
              .missedReading, .notLooping, .missedBolus,

+ 1 - 1
LoopFollow/Alarm/AlarmType/AlarmType+SortDirection.swift

@@ -37,7 +37,7 @@ extension AlarmType {
             return (direction: .descending,
             return (direction: .descending,
                     key: { $0.threshold })
                     key: { $0.threshold })
 
 
-        case .sensorChange:
+        case .sensorChange, .dbSize:
             return (direction: .ascending,
             return (direction: .ascending,
                     key: { $0.threshold })
                     key: { $0.threshold })
 
 

+ 1 - 1
LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift

@@ -12,7 +12,7 @@ extension AlarmType {
             return true
             return true
         // These are alarms without memory, if they only are acknowledged - they would alarm again immediately
         // These are alarms without memory, if they only are acknowledged - they would alarm again immediately
         case
         case
-            .batteryDrop, .missedReading, .notLooping, .battery, .pumpBattery, .buildExpire, .iob, .sensorChange, .pumpChange, .pump:
+            .batteryDrop, .missedReading, .notLooping, .battery, .pumpBattery, .buildExpire, .iob, .sensorChange, .pumpChange, .pump, .dbSize:
             return false
             return false
         }
         }
     }
     }

+ 1 - 0
LoopFollow/Alarm/AlarmType/AlarmType.swift

@@ -29,6 +29,7 @@ enum AlarmType: String, CaseIterable, Codable {
     case tempTargetStart = "Temp Target Started"
     case tempTargetStart = "Temp Target Started"
     case tempTargetEnd = "Temp Target Ended"
     case tempTargetEnd = "Temp Target Ended"
     case buildExpire = "Looping app expiration"
     case buildExpire = "Looping app expiration"
+    case dbSize = "Nightscout Database Size"
 }
 }
 
 
 extension AlarmType {
 extension AlarmType {

+ 237 - 0
LoopFollow/Alarm/CustomSoundStore.swift

@@ -0,0 +1,237 @@
+// LoopFollow
+// CustomSoundStore.swift
+
+import AVFoundation
+import Foundation
+
+/// A user-imported alarm sound stored inside the app's Documents directory.
+struct CustomSound: Identifiable, Hashable {
+    let id: UUID
+    let displayName: String
+    let url: URL
+}
+
+/// Manages the pool of user-imported alarm sounds.
+///
+/// Files live in `Documents/CustomSounds/<uuid>.<ext>`. A tiny sidecar `index.json`
+/// maps each UUID to its original filename so the picker can show something meaningful.
+/// Files dropped into `Documents/` via the Files app are picked up on next `list()` and
+/// moved into `CustomSounds/` with a fresh UUID.
+final class CustomSoundStore {
+    static let shared = CustomSoundStore()
+
+    /// Hard cap on imported audio file size. Alarm sounds should be short; this prevents
+    /// users from accidentally importing a full podcast episode.
+    static let maxFileBytes: Int = 2 * 1024 * 1024 // 2 MB
+    /// Hard cap on audio duration. Alarms loop or repeat via their own delay, so long clips
+    /// provide no benefit and bloat storage.
+    static let maxDurationSeconds: TimeInterval = 30
+
+    enum ImportError: LocalizedError {
+        case unreadable
+        case tooLarge(Int)
+        case tooLong(TimeInterval)
+        case notAudio
+
+        var errorDescription: String? {
+            switch self {
+            case .unreadable: return "Couldn't read the selected file."
+            case let .tooLarge(bytes):
+                let mb = Double(bytes) / (1024 * 1024)
+                return String(format: "File is too large (%.1f MB). Max %d MB.",
+                              mb, CustomSoundStore.maxFileBytes / (1024 * 1024))
+            case let .tooLong(seconds):
+                return String(format: "Audio is too long (%.1fs). Max %.0fs.",
+                              seconds, CustomSoundStore.maxDurationSeconds)
+            case .notAudio: return "That file isn't a supported audio format."
+            }
+        }
+    }
+
+    private let fileManager = FileManager.default
+    private let directory: URL
+    private let indexURL: URL
+    private let queue = DispatchQueue(label: "CustomSoundStore", qos: .userInitiated)
+    private var index: [UUID: String] = [:]
+
+    private init() {
+        let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
+        directory = documents.appendingPathComponent("CustomSounds", isDirectory: true)
+        indexURL = directory.appendingPathComponent("index.json")
+        try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+        loadIndex()
+    }
+
+    // MARK: - Public API
+
+    /// All custom sounds available, sorted by display name.
+    func list() -> [CustomSound] {
+        return queue.sync {
+            absorbDroppedFiles()
+            pruneMissing()
+            return index.compactMap { id, name in
+                guard let url = fileURL(for: id) else { return nil }
+                return CustomSound(id: id, displayName: name, url: url)
+            }
+            .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
+        }
+    }
+
+    /// Display name for a given custom sound, or nil if it's been deleted.
+    func displayName(for id: UUID) -> String? {
+        queue.sync { index[id] }
+    }
+
+    /// Resolve a custom sound to its on-disk URL, or nil if missing.
+    func url(for id: UUID) -> URL? {
+        queue.sync { fileURL(for: id) }
+    }
+
+    /// Import the audio at `sourceURL` into the store, validate it, and return a reference.
+    func importFile(at sourceURL: URL) throws -> CustomSound {
+        try queue.sync {
+            let needsScope = sourceURL.startAccessingSecurityScopedResource()
+            defer { if needsScope { sourceURL.stopAccessingSecurityScopedResource() } }
+
+            // Validate in place before touching the file so a rejected import never
+            // deletes the user's original.
+            let size = (try? sourceURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
+            if size > Self.maxFileBytes {
+                throw ImportError.tooLarge(size)
+            }
+            if !validateAudio(at: sourceURL) {
+                throw ImportError.notAudio
+            }
+            if let duration = audioDuration(at: sourceURL), duration > Self.maxDurationSeconds {
+                throw ImportError.tooLong(duration)
+            }
+
+            let newID = UUID()
+            let ext = sourceURL.pathExtension.isEmpty ? "audio" : sourceURL.pathExtension
+            let destURL = directory.appendingPathComponent("\(newID.uuidString).\(ext)")
+
+            // If the picked file already lives in our shared Documents folder, move it
+            // so absorbDroppedFiles() won't re-ingest the leftover original as a
+            // duplicate. Otherwise copy, leaving the source untouched.
+            let documentsRoot = directory.deletingLastPathComponent().resolvingSymlinksInPath().path
+            let sourceInDocuments = sourceURL.resolvingSymlinksInPath().path.hasPrefix(documentsRoot + "/")
+            do {
+                if sourceInDocuments {
+                    try fileManager.moveItem(at: sourceURL, to: destURL)
+                } else {
+                    try fileManager.copyItem(at: sourceURL, to: destURL)
+                }
+            } catch {
+                throw ImportError.unreadable
+            }
+
+            let displayName = sourceURL.deletingPathExtension().lastPathComponent
+            index[newID] = displayName.isEmpty ? "Custom Sound" : displayName
+            saveIndex()
+            return CustomSound(id: newID, displayName: index[newID]!, url: destURL)
+        }
+    }
+
+    /// Delete a custom sound. Alarms referencing it will fall back to the default built-in.
+    func delete(_ id: UUID) {
+        queue.sync {
+            if let url = fileURL(for: id) {
+                try? fileManager.removeItem(at: url)
+            }
+            index.removeValue(forKey: id)
+            saveIndex()
+        }
+    }
+
+    // MARK: - Internals
+
+    private func fileURL(for id: UUID) -> URL? {
+        guard let contents = try? fileManager.contentsOfDirectory(atPath: directory.path) else {
+            return nil
+        }
+        let prefix = id.uuidString + "."
+        guard let match = contents.first(where: { $0.hasPrefix(prefix) }) else { return nil }
+        return directory.appendingPathComponent(match)
+    }
+
+    private func loadIndex() {
+        guard
+            let data = try? Data(contentsOf: indexURL),
+            let decoded = try? JSONDecoder().decode([String: String].self, from: data)
+        else {
+            index = [:]
+            return
+        }
+        var result: [UUID: String] = [:]
+        for (key, value) in decoded {
+            if let uuid = UUID(uuidString: key) {
+                result[uuid] = value
+            }
+        }
+        index = result
+    }
+
+    private func saveIndex() {
+        let encodable = Dictionary(uniqueKeysWithValues: index.map { ($0.key.uuidString, $0.value) })
+        if let data = try? JSONEncoder().encode(encodable) {
+            try? data.write(to: indexURL, options: .atomic)
+        }
+    }
+
+    /// Drop entries from the index that point to files that no longer exist on disk
+    /// (e.g. the user deleted them via the Files app).
+    private func pruneMissing() {
+        var didChange = false
+        for id in Array(index.keys) where fileURL(for: id) == nil {
+            index.removeValue(forKey: id)
+            didChange = true
+        }
+        if didChange { saveIndex() }
+    }
+
+    /// Pick up audio files that were dropped into the Documents directory via the
+    /// Files app and move them into `CustomSounds/` with a fresh UUID.
+    ///
+    /// Each candidate is validated *in place* against the same size/duration/format
+    /// rules as `importFile(at:)` before being moved. A file that fails any check is
+    /// left untouched in Documents — we never move (and therefore never delete) a file
+    /// the user put there.
+    private func absorbDroppedFiles() {
+        let documents = directory.deletingLastPathComponent()
+        guard let entries = try? fileManager.contentsOfDirectory(at: documents, includingPropertiesForKeys: [.fileSizeKey]) else {
+            return
+        }
+        let audioExtensions: Set<String> = ["mp3", "wav", "m4a", "aac", "aif", "aiff", "caf"]
+        var didChange = false
+        for entry in entries {
+            var isDir: ObjCBool = false
+            guard fileManager.fileExists(atPath: entry.path, isDirectory: &isDir), !isDir.boolValue else { continue }
+            guard audioExtensions.contains(entry.pathExtension.lowercased()) else { continue }
+
+            let size = (try? entry.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
+            guard size <= Self.maxFileBytes, validateAudio(at: entry) else { continue }
+            if let duration = audioDuration(at: entry), duration > Self.maxDurationSeconds { continue }
+
+            let newID = UUID()
+            let dest = directory.appendingPathComponent("\(newID.uuidString).\(entry.pathExtension)")
+            do {
+                try fileManager.moveItem(at: entry, to: dest)
+            } catch {
+                continue
+            }
+            let baseName = entry.deletingPathExtension().lastPathComponent
+            index[newID] = baseName.isEmpty ? "Custom Sound" : baseName
+            didChange = true
+        }
+        if didChange { saveIndex() }
+    }
+
+    private func validateAudio(at url: URL) -> Bool {
+        return (try? AVAudioPlayer(contentsOf: url)) != nil
+    }
+
+    private func audioDuration(at url: URL) -> TimeInterval? {
+        guard let player = try? AVAudioPlayer(contentsOf: url) else { return nil }
+        return player.duration
+    }
+}

+ 0 - 9
LoopFollow/Alarm/SnoozeState.swift

@@ -1,9 +0,0 @@
-// LoopFollow
-// SnoozeState.swift
-
-import Foundation
-
-struct SnoozeState: Codable {
-    var isSnoozed: Bool = false
-    var snoozeUntil: Date?
-}

+ 67 - 62
LoopFollow/Application/AppDelegate.swift

@@ -2,7 +2,6 @@
 // AppDelegate.swift
 // AppDelegate.swift
 
 
 import AVFoundation
 import AVFoundation
-import EventKit
 import UIKit
 import UIKit
 import UserNotifications
 import UserNotifications
 
 
@@ -13,22 +12,40 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         LogManager.shared.log(category: .general, message: "App started")
         LogManager.shared.log(category: .general, message: "App started")
         LogManager.shared.cleanupOldLogs()
         LogManager.shared.cleanupOldLogs()
 
 
-        // Keep the AppleLanguages default in sync with the user's manual language choice.
-        AppLanguage.applyStored()
-
-        let options: UNAuthorizationOptions = [.alert, .sound, .badge]
-        notificationCenter.requestAuthorization(options: options) {
-            didAllow, _ in
-            if !didAllow {
-                LogManager.shared.log(category: .general, message: "User has declined notifications")
-            }
-        }
-
-        let store = EKEventStore()
-        store.requestCalendarAccess { granted, error in
-            if !granted {
-                LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))")
-                return
+        // Notification and calendar permissions are no longer requested here.
+        // They're deferred to the moment the user opts into the feature that
+        // needs them (alarms request notifications via NotificationAuthorization;
+        // the Calendar settings screen requests calendar access), so a fresh
+        // install isn't fronted with permission prompts before onboarding.
+
+        // Before-First-Unlock detection. isProtectedDataAvailable is false on ANY
+        // locked launch, so it alone isn't a BFU signal — post-first-unlock
+        // UserDefaults (class C) reads fine while locked. Only true BFU makes a key
+        // that should exist read as absent. Suspect only when ALL presence probes
+        // are absent, so an existing user who updated but hasn't foregrounded this
+        // build isn't misread on an ordinary locked launch. Probes cover every user
+        // shape (marker / migrated / consented / NS-configured); presence, not value.
+        _ = Storage.shared // ensure every StorageValue is registered before recovery
+        let storageConfirmedReadable = StorageReadiness.markerExists
+            || Storage.shared.migrationStep.exists
+            || Storage.shared.telemetryConsentDecisionMade.exists
+            || Storage.shared.url.exists
+        let suspectBFU = !UIApplication.shared.isProtectedDataAvailable && !storageConfirmedReadable
+        StorageReadiness.configure(suspectBFU: suspectBFU)
+        LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(UIApplication.shared.isProtectedDataAvailable), storageConfirmedReadable=\(storageConfirmedReadable), suspectBFU=\(suspectBFU)")
+
+        if suspectBFU {
+            // Driven here, not MainViewController: on a BG-only launch (BGAppRefreshTask,
+            // BLE wake) the home VC may not exist yet. protectedDataDidBecomeAvailable is
+            // authoritative; willEnterForeground is a fallback.
+            let nc = NotificationCenter.default
+            nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil)
+            nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
+
+            // Race guard: protected data may have become available between the check
+            // above and the observer registration just now.
+            if UIApplication.shared.isProtectedDataAvailable {
+                completeStorageRecovery()
             }
             }
         }
         }
 
 
@@ -38,7 +55,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
 
         UNUserNotificationCenter.current().delegate = self
         UNUserNotificationCenter.current().delegate = self
 
 
-        _ = BLEManager.shared
+        // Only spin up Bluetooth if the user has chosen a BLE-based background
+        // refresh. Initializing BLEManager creates a CBCentralManager, which
+        // triggers the Bluetooth permission prompt — deferring it keeps that
+        // prompt off fresh installs until the feature is actually enabled.
+        if Storage.shared.backgroundRefreshType.value.isBluetooth {
+            _ = BLEManager.shared
+        }
         // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
         // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
         _ = VolumeButtonHandler.shared
         _ = VolumeButtonHandler.shared
 
 
@@ -49,42 +72,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
 
         BackgroundRefreshManager.shared.register()
         BackgroundRefreshManager.shared.register()
 
 
-        // Telemetry: record this cold launch (used by the rolling
-        // coldLaunches7d signal). If the running build's SHA differs from
-        // the one we last sent for, fire an immediate ping — the scheduler
-        // alone can't notice an app update. Otherwise let the 24h scheduler
-        // handle cadence: its first run is lastSentAt + 24h, so a relaunch
-        // a few hours after the previous send simply waits out the
-        // remainder. See Helpers/Telemetry.swift.
-        TelemetryClient.shared.recordColdLaunch()
-        Task.detached {
-            if TelemetryClient.shared.buildShaChangedSinceLastSend() {
-                await TelemetryClient.shared.maybeSend()
+        // Telemetry mutates rolling history (coldLaunches7d), so defer past a BFU
+        // window — poisoned defaults would discard real history. Runs synchronously
+        // on a normal launch.
+        StorageReadiness.whenReady {
+            // Keep the bundle language aligned with the user's manual selection.
+            // Waiting for storage readiness avoids reading default values during
+            // a Before-First-Unlock background launch.
+            AppLanguage.applyStored()
+
+            // SHA change fires an immediate ping (the scheduler can't notice an app
+            // update); otherwise the 24h scheduler handles cadence. See Telemetry.swift.
+            TelemetryClient.shared.recordColdLaunch()
+            Task.detached {
+                if TelemetryClient.shared.buildShaChangedSinceLastSend() {
+                    await TelemetryClient.shared.maybeSend()
+                }
+                TelemetryClient.shared.scheduleRecurring()
             }
             }
-            TelemetryClient.shared.scheduleRecurring()
-        }
-
-        // Detect Before-First-Unlock launch. If protected data is unavailable here,
-        // StorageValues were cached from encrypted UserDefaults and need a reload
-        // once the device is unlocked.
-        let bfu = !UIApplication.shared.isProtectedDataAvailable
-        Storage.shared.needsBFUReload = bfu
-        LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)")
-
-        // Recovery is driven from AppDelegate (not MainViewController) because under
-        // the SwiftUI App lifecycle the home tab's UIHostingController is materialized
-        // lazily — on a BG-only launch (BGAppRefreshTask, BLE wake) MainViewController
-        // may not exist when the device is unlocked, and would miss willEnterForeground.
-        // protectedDataDidBecomeAvailable fires the moment file protection lifts and
-        // is the authoritative signal; willEnterForeground is a fallback.
-        let nc = NotificationCenter.default
-        nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil)
-        nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
-
-        // Race guard: protected data may have become available between the check
-        // above and the observer registration just now.
-        if Storage.shared.needsBFUReload, UIApplication.shared.isProtectedDataAvailable {
-            performBFUReloadIfNeeded()
         }
         }
 
 
         return true
         return true
@@ -93,19 +98,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
     // MARK: - BFU recovery
     // MARK: - BFU recovery
 
 
     @objc private func protectedDataDidBecomeAvailable() {
     @objc private func protectedDataDidBecomeAvailable() {
-        performBFUReloadIfNeeded()
+        completeStorageRecovery()
     }
     }
 
 
     @objc private func handleWillEnterForeground() {
     @objc private func handleWillEnterForeground() {
-        performBFUReloadIfNeeded()
+        completeStorageRecovery()
     }
     }
 
 
-    private func performBFUReloadIfNeeded() {
-        guard Storage.shared.needsBFUReload else { return }
-        Storage.shared.needsBFUReload = false
-        LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues")
-        Storage.shared.reloadAll()
-        LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(Storage.shared.url.value)'")
+    private func completeStorageRecovery() {
+        // recover() hydrates every value and opens the gate; true only on the one
+        // transition that did the work, so the notification fires exactly once.
+        guard StorageReadiness.recover() else { return }
+        LogManager.shared.log(category: .general, message: "BFU recovery complete: url='\(Storage.shared.url.value)'")
         NotificationCenter.default.post(name: .bfuReloadCompleted, object: nil)
         NotificationCenter.default.post(name: .bfuReloadCompleted, object: nil)
     }
     }
 
 
@@ -210,7 +214,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
 
 extension Notification.Name {
 extension Notification.Name {
     /// Posted by AppDelegate after a Before-First-Unlock recovery completes
     /// Posted by AppDelegate after a Before-First-Unlock recovery completes
-    /// (Storage.reloadAll has run with the now-decrypted UserDefaults).
+    /// (StorageReadiness.recover has hydrated every value from the now-decrypted
+    /// UserDefaults).
     static let bfuReloadCompleted = Notification.Name("LoopFollow.bfuReloadCompleted")
     static let bfuReloadCompleted = Notification.Name("LoopFollow.bfuReloadCompleted")
 }
 }
 
 

+ 18 - 9
LoopFollow/Application/LoopFollowApp.swift

@@ -6,18 +6,27 @@ import SwiftUI
 @main
 @main
 struct LoopFollowApp: App {
 struct LoopFollowApp: App {
     @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
     @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
+    @ObservedObject private var storageReady = StorageReadiness.ready
 
 
     var body: some Scene {
     var body: some Scene {
         WindowGroup {
         WindowGroup {
-            MainTabView()
-                .onOpenURL { url in
-                    guard url.scheme == AppGroupID.urlScheme, url.host == "la-tap" else { return }
-                    #if !targetEnvironment(macCatalyst)
-                        DispatchQueue.main.async {
-                            NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
-                        }
-                    #endif
-                }
+            // Gate the UI on storage readiness so nothing (bootstrap, telemetry
+            // consent, onboarding) is built against a poisoned cache. True
+            // synchronously on a normal launch; only false briefly while a BFU
+            // background launch is foregrounded mid-hydration.
+            if storageReady.value {
+                MainTabView()
+                    .onOpenURL { url in
+                        guard url.scheme == AppGroupID.urlScheme, url.host == "la-tap" else { return }
+                        #if !targetEnvironment(macCatalyst)
+                            DispatchQueue.main.async {
+                                NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
+                            }
+                        #endif
+                    }
+            } else {
+                StorageLoadingView()
+            }
         }
         }
     }
     }
 }
 }

+ 64 - 7
LoopFollow/Application/MainTabView.swift

@@ -13,14 +13,31 @@ struct MainTabView: View {
     @ObservedObject private var snoozerPosition = Storage.shared.snoozerPosition
     @ObservedObject private var snoozerPosition = Storage.shared.snoozerPosition
     @ObservedObject private var statisticsPosition = Storage.shared.statisticsPosition
     @ObservedObject private var statisticsPosition = Storage.shared.statisticsPosition
     @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition
     @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition
+    @ObservedObject private var activeBanner = Observable.shared.activeBanner
 
 
     @State private var showTelemetryConsent = false
     @State private var showTelemetryConsent = false
+    @State private var showOnboarding = false
 
 
     private var orderedItems: [TabItem] {
     private var orderedItems: [TabItem] {
         Storage.shared.orderedTabBarItems()
         Storage.shared.orderedTabBarItems()
     }
     }
 
 
     var body: some View {
     var body: some View {
+        // The banner sits in a VStack above the TabView (rather than a
+        // .safeAreaInset) so the UIKit-hosted Home content is physically
+        // pushed down too — safe-area changes don't propagate into
+        // UIViewControllerRepresentable.
+        VStack(spacing: 0) {
+            if let message = activeBanner.value {
+                AppBannerView(message: message)
+                    .transition(.move(edge: .top).combined(with: .opacity))
+            }
+            tabView
+        }
+        .animation(.easeInOut(duration: 0.25), value: activeBanner.value)
+    }
+
+    private var tabView: some View {
         TabView(selection: $selectedTab.value) {
         TabView(selection: $selectedTab.value) {
             ForEach(Array(orderedItems.prefix(4).enumerated()), id: \.element) { index, item in
             ForEach(Array(orderedItems.prefix(4).enumerated()), id: \.element) { index, item in
                 tabContent(for: item)
                 tabContent(for: item)
@@ -47,21 +64,61 @@ struct MainTabView: View {
             // onAppear (not app launch) keeps it off the BG-only refresh path.
             // onAppear (not app launch) keeps it off the BG-only refresh path.
             MainViewController.bootstrap()
             MainViewController.bootstrap()
 
 
-            // One-time consent prompt. Previously presented by SceneDelegate,
-            // which was removed in the storyboard→SwiftUI migration; without
-            // this, fresh installs stay permanently undecided and telemetry
-            // never sends. The storage flag keeps it to a single appearance.
-            if !Storage.shared.telemetryConsentDecisionMade.value {
-                showTelemetryConsent = true
+            // Show the first-run onboarding once for everyone. Returning users
+            // get a prominent Skip on the welcome screen. The telemetry consent
+            // prompt is deferred until onboarding is dismissed so the two never
+            // appear on top of one another.
+            if !Storage.shared.hasCompletedOnboarding.value {
+                Observable.shared.isOnboardingActive.value = true
+                showOnboarding = true
+            } else {
+                runPostOnboardingPrompts()
             }
             }
         }
         }
-        .sheet(isPresented: $showTelemetryConsent) {
+        .fullScreenCover(isPresented: $showOnboarding, onDismiss: {
+            Observable.shared.isOnboardingActive.value = false
+
+            // Covers both finishing and skipping onboarding — the telemetry and
+            // notification steps live inside the flow, so anyone who skips still
+            // needs these handled here.
+            runPostOnboardingPrompts()
+        }) {
+            OnboardingContainerView(onClose: { showOnboarding = false })
+        }
+        .sheet(isPresented: $showTelemetryConsent, onDismiss: {
+            // Ask for notifications only once telemetry is resolved, so the system
+            // prompt never stacks on top of the consent sheet.
+            requestNotificationsIfAlarmsEnabled()
+        }) {
             // User must explicitly choose — no swipe-to-dismiss.
             // User must explicitly choose — no swipe-to-dismiss.
             TelemetryConsentView()
             TelemetryConsentView()
                 .interactiveDismissDisabled(true)
                 .interactiveDismissDisabled(true)
         }
         }
     }
     }
 
 
+    /// Runs after onboarding closes, whether it was completed or skipped. Telemetry
+    /// consent and notification permission both live inside the onboarding flow, so
+    /// a skip would otherwise bypass them. Telemetry consent goes first (as a
+    /// sheet); the notification request follows on its dismissal so the two never
+    /// appear at once. When the user completed the flow these are already decided,
+    /// so both calls are no-ops.
+    private func runPostOnboardingPrompts() {
+        if !Storage.shared.telemetryConsentDecisionMade.value {
+            showTelemetryConsent = true // notifications requested on its dismiss
+        } else {
+            requestNotificationsIfAlarmsEnabled()
+        }
+    }
+
+    /// Deferred-permission policy: only ask for notifications once there's an
+    /// enabled alarm that needs them. Safe to call repeatedly — it's a no-op once
+    /// the status is determined.
+    private func requestNotificationsIfAlarmsEnabled() {
+        if Storage.shared.alarms.value.contains(where: { $0.isEnabled }) {
+            NotificationAuthorization.requestIfNeeded()
+        }
+    }
+
     @ViewBuilder
     @ViewBuilder
     private func tabContent(for item: TabItem) -> some View {
     private func tabContent(for item: TabItem) -> some View {
         switch item {
         switch item {

+ 19 - 0
LoopFollow/Application/StorageLoadingView.swift

@@ -0,0 +1,19 @@
+// LoopFollow
+// StorageLoadingView.swift
+
+import SwiftUI
+
+/// Shown at the root until `StorageReadiness.ready` — only while a BFU background
+/// launch is foregrounded mid-hydration; a normal launch opens straight to
+/// `MainTabView`. Must not read `Storage` (values are poisoned here) — system
+/// colors only.
+struct StorageLoadingView: View {
+    var body: some View {
+        ZStack {
+            Color(.systemBackground)
+                .ignoresSafeArea()
+            ProgressView()
+                .controlSize(.large)
+        }
+    }
+}

+ 95 - 5
LoopFollow/BackgroundRefresh/BT/BLEManager.swift

@@ -8,25 +8,63 @@ import Foundation
 class BLEManager: NSObject, ObservableObject {
 class BLEManager: NSObject, ObservableObject {
     static let shared = BLEManager()
     static let shared = BLEManager()
 
 
+    /// Whether the shared instance has been created (and therefore a
+    /// CBCentralManager exists / the Bluetooth prompt has been triggered).
+    /// Reading this does not instantiate `shared`, so callers can avoid forcing
+    /// Bluetooth initialization — and its permission prompt — when not needed.
+    private(set) static var isInitialized = false
+
     @Published private(set) var devices: [BLEDevice] = []
     @Published private(set) var devices: [BLEDevice] = []
 
 
     private var centralManager: CBCentralManager!
     private var centralManager: CBCentralManager!
     private var activeDevice: BluetoothDevice?
     private var activeDevice: BluetoothDevice?
+    private var readinessCancellable: AnyCancellable?
+
+    /// Arrival times of recent heartbeat dropouts. Main-queue-confined
+    /// (centralManager delivers on .main) and deliberately not persisted:
+    /// stale history must not resurrect the banner after a relaunch.
+    private var heartbeatDropoutTimes: [Date] = []
+
+    /// A beat this much later than expected counts as one dropout event.
+    /// Stricter than the 15% logging margin - normal jitter must not count.
+    private let dropoutFactor = 1.5
+
+    /// Sliding window over which dropout events are counted.
+    private let dropoutWindow: TimeInterval = 60 * 60
+
+    /// Dropout events within the window needed to raise the banner.
+    private let dropoutTriggerCount = 5
 
 
     override private init() {
     override private init() {
         super.init()
         super.init()
+        BLEManager.isInitialized = true
 
 
         centralManager = CBCentralManager(
         centralManager = CBCentralManager(
             delegate: self,
             delegate: self,
             queue: .main
             queue: .main
         )
         )
-        if let device = Storage.shared.selectedBLEDevice.value {
-            devices.append(device)
-            findAndUpdateDevice(with: device.id.uuidString) { device in
-                device.rssi = 0
+        connectSelectedDeviceIfNeeded()
+
+        // After BFU, selectedBLEDevice reads nil until hydration — reconnect when
+        // storage becomes ready. dropFirst skips the current value (init handled the
+        // already-ready case), so this fires only on the false→true recovery.
+        readinessCancellable = StorageReadiness.ready.$value
+            .dropFirst()
+            .filter { $0 }
+            .sink { [weak self] _ in
+                self?.connectSelectedDeviceIfNeeded()
             }
             }
-            connect(device: device)
+    }
+
+    private func connectSelectedDeviceIfNeeded() {
+        guard activeDevice == nil, let device = Storage.shared.selectedBLEDevice.value else { return }
+        if !devices.contains(where: { $0.id == device.id }) {
+            devices.append(device)
+        }
+        findAndUpdateDevice(with: device.id.uuidString) { device in
+            device.rssi = 0
         }
         }
+        connect(device: device)
     }
     }
 
 
     func getSelectedDevice() -> BLEDevice? {
     func getSelectedDevice() -> BLEDevice? {
@@ -49,6 +87,8 @@ class BLEManager: NSObject, ObservableObject {
             activeDevice = nil
             activeDevice = nil
             device.lastHeartbeatTime = nil
             device.lastHeartbeatTime = nil
         }
         }
+        heartbeatDropoutTimes.removeAll()
+        BannerManager.shared.clear(.heartbeat)
         Storage.shared.selectedBLEDevice.value = nil
         Storage.shared.selectedBLEDevice.value = nil
     }
     }
 
 
@@ -208,6 +248,7 @@ extension BLEManager: BluetoothDeviceDelegate {
                 let delay = elapsedTime - expectedInterval
                 let delay = elapsedTime - expectedInterval
                 LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)")
                 LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)")
             }
             }
+            recordHeartbeatOutcome(elapsed: elapsedTime, expectedInterval: expectedInterval, now: now)
         } else {
         } else {
             LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)")
             LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)")
         }
         }
@@ -216,6 +257,55 @@ extension BLEManager: BluetoothDeviceDelegate {
 
 
         TaskScheduler.shared.checkTasksNow()
         TaskScheduler.shared.checkTasksNow()
     }
     }
+
+    /// Counts late heartbeats over a sliding window and raises a banner when
+    /// dropouts become frequent - the typical symptom of a dying transmitter
+    /// battery. Detection is arrival-based on purpose: a struggling battery
+    /// still delivers (late) beats, whereas total silence usually means
+    /// out-of-range or a dead device and is already surfaced by the
+    /// connection status in Background Refresh settings and by BG alarms.
+    private func recordHeartbeatOutcome(elapsed: TimeInterval, expectedInterval: TimeInterval, now: Date) {
+        heartbeatDropoutTimes.removeAll { now.timeIntervalSince($0) > dropoutWindow }
+
+        // A gap this large means out-of-range, Bluetooth off or a suspended
+        // app - not a struggling battery. Discard the history collected
+        // before the blind spot instead of counting it.
+        let resetGap = max(6 * expectedInterval, 30 * 60)
+        if elapsed > resetGap {
+            if !heartbeatDropoutTimes.isEmpty {
+                heartbeatDropoutTimes.removeAll()
+                LogManager.shared.log(category: .bluetooth, message: "Heartbeat gap of \(Int(elapsed)) seconds, resetting dropout history")
+            }
+        } else if elapsed > expectedInterval * dropoutFactor {
+            heartbeatDropoutTimes.append(now)
+            LogManager.shared.log(category: .bluetooth, message: "Heartbeat dropout recorded (\(heartbeatDropoutTimes.count) in the last hour)")
+        }
+
+        // Hysteresis: raise at the trigger count, clear only once the window
+        // is completely clean, so the banner doesn't flap at the boundary.
+        if heartbeatDropoutTimes.count >= dropoutTriggerCount {
+            BannerManager.shared.report(source: .heartbeat, severity: .warning, text: heartbeatDropoutBannerText())
+        } else if heartbeatDropoutTimes.isEmpty {
+            BannerManager.shared.clear(.heartbeat)
+        }
+    }
+
+    /// The text must stay stable while the condition persists (no counts or
+    /// durations) so BannerManager's dedupe keeps the banner from
+    /// re-animating on every beat and user dismissal keeps working.
+    private func heartbeatDropoutBannerText() -> String {
+        let name = activeDevice?.deviceName ?? "heartbeat device"
+        let cause: String
+        switch Storage.shared.backgroundRefreshType.value {
+        case .rileyLink:
+            cause = "a low RileyLink battery"
+        case .omnipodDash:
+            cause = "a low pod battery"
+        default:
+            cause = "a low transmitter battery"
+        }
+        return "Heartbeat: repeated Bluetooth dropouts from \(name). This can be a sign of \(cause) or a weak Bluetooth connection."
+    }
 }
 }
 
 
 extension BLEManager {
 extension BLEManager {

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

@@ -4,7 +4,6 @@
 import CoreBluetooth
 import CoreBluetooth
 import Foundation
 import Foundation
 import os
 import os
-import UIKit
 
 
 class BluetoothDevice: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
 class BluetoothDevice: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
     weak var bluetoothDeviceDelegate: BluetoothDeviceDelegate?
     weak var bluetoothDeviceDelegate: BluetoothDeviceDelegate?

+ 0 - 31
LoopFollow/BackgroundRefresh/BT/DexcomG7HeartBeat.swift

@@ -1,31 +0,0 @@
-// LoopFollow
-// DexcomG7HeartBeat.swift
-
-// 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) {
-        blePeripheral = BLEPeripheral(
-            address: address,
-            name: name,
-            alias: alias,
-            peripheralType: .DexcomG7HeartBeatType
-        )
-    }
-}

+ 7 - 1
LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift

@@ -33,6 +33,12 @@ class BackgroundRefreshSettingsViewModel: ObservableObject {
     private func handleBackgroundRefreshTypeChange(_ newValue: BackgroundRefreshType) {
     private func handleBackgroundRefreshTypeChange(_ newValue: BackgroundRefreshType) {
         LogManager.shared.log(category: .general, message: "Background refresh type changed to: \(newValue.rawValue)")
         LogManager.shared.log(category: .general, message: "Background refresh type changed to: \(newValue.rawValue)")
 
 
-        BLEManager.shared.disconnect()
+        // Touch BLEManager only when switching to a Bluetooth mode (the user is
+        // opting in, so the permission prompt belongs here) or when it's already
+        // running and needs to be torn down. Switching between non-BLE modes must
+        // not initialize Bluetooth — that would prompt without cause.
+        if newValue.isBluetooth || BLEManager.isInitialized {
+            BLEManager.shared.disconnect()
+        }
     }
     }
 }
 }

+ 75 - 0
LoopFollow/Banner/AppBannerView.swift

@@ -0,0 +1,75 @@
+// LoopFollow
+// AppBannerView.swift
+
+import SwiftUI
+
+/// Dismissable banner shown at the top of the app, above the tab content.
+struct AppBannerView: View {
+    let message: BannerMessage
+
+    var body: some View {
+        HStack(alignment: .top, spacing: 12) {
+            Image(systemName: iconName)
+                .font(.title3)
+                .foregroundColor(iconColor)
+
+            Text(message.text)
+                .font(.callout)
+                .lineLimit(3)
+                .fixedSize(horizontal: false, vertical: true)
+                .frame(maxWidth: .infinity, alignment: .leading)
+
+            Button {
+                BannerManager.shared.dismissCurrent()
+            } label: {
+                Image(systemName: "xmark.circle.fill")
+                    .font(.title3)
+                    .foregroundColor(.secondary)
+            }
+            .accessibilityLabel("Dismiss")
+        }
+        .padding()
+        .background(
+            RoundedRectangle(cornerRadius: 12, style: .continuous)
+                .fill(tint)
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: 12, style: .continuous)
+                .stroke(border, lineWidth: 1)
+        )
+        .padding(.horizontal, 10)
+        .padding(.vertical, 4)
+    }
+
+    private var iconName: String {
+        switch message.severity {
+        case .info: return "info.circle.fill"
+        case .warning: return "exclamationmark.triangle.fill"
+        case .error: return "xmark.octagon.fill"
+        }
+    }
+
+    private var iconColor: Color {
+        switch message.severity {
+        case .info: return .blue
+        case .warning: return .orange
+        case .error: return .red
+        }
+    }
+
+    private var tint: Color {
+        switch message.severity {
+        case .info: return Color.blue.opacity(0.20)
+        case .warning: return Color.orange.opacity(0.20)
+        case .error: return Color.red.opacity(0.20)
+        }
+    }
+
+    private var border: Color {
+        switch message.severity {
+        case .info: return Color.blue.opacity(0.40)
+        case .warning: return Color.orange.opacity(0.40)
+        case .error: return Color.red.opacity(0.40)
+        }
+    }
+}

+ 172 - 0
LoopFollow/Banner/BannerManager.swift

@@ -0,0 +1,172 @@
+// LoopFollow
+// BannerManager.swift
+
+import Foundation
+import ShareClient
+
+/// Identifies who produced a banner message. Each source owns at most one
+/// message at a time; reporting a new message for a source replaces its old one.
+enum BannerSource: Hashable {
+    case nightscout
+    case dexcom
+    case heartbeat
+    case custom(String)
+}
+
+enum BannerSeverity: Int, Comparable {
+    case info
+    case warning
+    case error
+
+    static func < (lhs: BannerSeverity, rhs: BannerSeverity) -> Bool {
+        lhs.rawValue < rhs.rawValue
+    }
+}
+
+struct BannerMessage: Equatable, Identifiable {
+    let id: UUID
+    let source: BannerSource
+    let severity: BannerSeverity
+    let text: String
+    let timestamp: Date
+}
+
+/// App-wide banner state. Producers call `report`/`clear`; the view layer
+/// observes `Observable.shared.activeBanner` and calls `dismissCurrent()`.
+final class BannerManager {
+    static let shared = BannerManager()
+
+    /// How long a user dismissal suppresses a still-occurring error before it re-appears.
+    static let dismissCooldown: TimeInterval = 30 * 60
+
+    /// Minimum time between Nightscout diagnostic probes (status.json calls).
+    private static let diagnosticInterval: TimeInterval = 5 * 60
+
+    // All state is read and mutated on the main queue only.
+    private var messages: [BannerSource: BannerMessage] = [:]
+    private var dismissals: [BannerSource: (until: Date, text: String)] = [:]
+    private var lastNightscoutDiagnostic: Date?
+
+    private init() {}
+
+    func report(source: BannerSource, severity: BannerSeverity = .error, text: String) {
+        DispatchQueue.main.async {
+            if let existing = self.messages[source], existing.text == text, existing.severity == severity {
+                // Same problem re-reported (fetches retry every 10-60 s): keep the
+                // message untouched so the banner doesn't re-animate, but publish in
+                // case a dismissal cooldown has expired since the last report.
+            } else {
+                // A different problem: show it even if the previous one was dismissed.
+                self.dismissals[source] = nil
+                self.messages[source] = BannerMessage(
+                    id: UUID(),
+                    source: source,
+                    severity: severity,
+                    text: text,
+                    timestamp: Date()
+                )
+            }
+            self.publish()
+        }
+    }
+
+    func clear(_ source: BannerSource) {
+        DispatchQueue.main.async {
+            guard self.messages[source] != nil || self.dismissals[source] != nil else { return }
+            self.messages[source] = nil
+            self.dismissals[source] = nil
+            self.publish()
+        }
+    }
+
+    func dismissCurrent() {
+        DispatchQueue.main.async {
+            guard let current = Observable.shared.activeBanner.value else { return }
+            self.dismissals[current.source] = (Date().addingTimeInterval(Self.dismissCooldown), current.text)
+            self.publish()
+        }
+    }
+
+    /// Classifies a failed Nightscout fetch by probing status.json, so the banner
+    /// can say *why* it failed (invalid token, site not found, no network, ...)
+    /// instead of showing a generic decode/transport error.
+    func reportNightscoutFetchFailure(_ underlying: Error) {
+        DispatchQueue.main.async {
+            if let last = self.lastNightscoutDiagnostic, Date().timeIntervalSince(last) < Self.diagnosticInterval {
+                return
+            }
+            self.lastNightscoutDiagnostic = Date()
+
+            NightscoutUtils.verifyURLAndToken { error, _, _, _ in
+                if let error = error {
+                    if case .emptyAddress = error {
+                        // URL was removed while a fetch was in flight — not an error state.
+                        self.clear(.nightscout)
+                        return
+                    }
+                    self.report(
+                        source: .nightscout,
+                        severity: .error,
+                        text: "Nightscout: \(error.localizedDescription)"
+                    )
+                } else {
+                    // Server reachable and token accepted, yet the data fetch failed.
+                    LogManager.shared.log(
+                        category: .nightscout,
+                        message: "Nightscout diagnostic OK but data fetch failed: \(underlying)",
+                        limitIdentifier: "Nightscout diagnostic OK but data fetch failed"
+                    )
+                    self.report(
+                        source: .nightscout,
+                        severity: .warning,
+                        text: "Nightscout: data download failed, but the server is reachable. Retrying automatically."
+                    )
+                }
+            }
+        }
+    }
+
+    func reportDexcomFailure(_ error: ShareError, nightscoutFallback: Bool) {
+        var text: String
+        switch error {
+        case let .loginError(errorCode):
+            // Dexcom has returned both legacy "SSO_Authenticate…" codes and newer
+            // ones like "AccountPasswordInvalid" — match on the common substrings.
+            if errorCode.contains("AccountNotFound") {
+                text = "Dexcom Share: account not found — check your username."
+            } else if errorCode.contains("PasswordInvalid") {
+                text = "Dexcom Share: incorrect username or password."
+            } else if errorCode.contains("MaxAttempts") {
+                text = "Dexcom Share: too many failed login attempts — temporarily locked out."
+            } else {
+                text = "Dexcom Share: login failed (\(errorCode))."
+            }
+        case .httpError:
+            text = "Dexcom Share: network error while downloading."
+        case .fetchError, .dataError, .dateError:
+            text = "Dexcom Share: could not download readings."
+        }
+
+        if nightscoutFallback {
+            text += " Using Nightscout as backup."
+        }
+        report(source: .dexcom, severity: nightscoutFallback ? .warning : .error, text: text)
+    }
+
+    /// Pushes the highest-priority non-dismissed message to the UI.
+    private func publish() {
+        let now = Date()
+        let candidate = messages.values
+            .filter { message in
+                guard let dismissal = dismissals[message.source] else { return true }
+                return now >= dismissal.until || dismissal.text != message.text
+            }
+            .max { lhs, rhs in
+                (lhs.severity, lhs.timestamp) < (rhs.severity, rhs.timestamp)
+            }
+
+        if Observable.shared.activeBanner.value != candidate {
+            Observable.shared.activeBanner.set(candidate)
+        }
+    }
+}

+ 599 - 0
LoopFollow/Charts/BGChartModel.swift

@@ -0,0 +1,599 @@
+// LoopFollow
+// BGChartModel.swift
+
+import Foundation
+import SwiftUI
+
+/// Interaction state shared between the main chart (which owns the gestures)
+/// and the small overview chart (which shows a viewport box and navigates on
+/// tap). Kept separate from BGChartModel so high-frequency pan/zoom writes
+/// don't invalidate views that only observe the data.
+final class BGChartInteraction: ObservableObject {
+    /// Date at the leading (left) edge of the main chart's visible window.
+    @Published var scrollPosition: Date
+    /// Length of the visible x-axis window in seconds.
+    @Published var visibleSeconds: TimeInterval
+    /// True while the main chart should keep auto-scrolling to "now"; cleared
+    /// when the user pans back into history, re-armed when they return to the edge.
+    @Published var followLatest: Bool = true
+
+    init() {
+        let seconds = Self.visibleSeconds(forScale: Storage.shared.chartScaleX.value)
+        visibleSeconds = seconds
+        scrollPosition = Date().addingTimeInterval(-seconds * 0.7)
+    }
+
+    /// Maps the persisted zoom scale (24 h divided by the scale factor) to a
+    /// visible-window length.
+    static func visibleSeconds(forScale scale: Double) -> TimeInterval {
+        guard scale > 0 else { return 6 * 3600 }
+        return min(max(24 * 3600 / scale, 15 * 60), 24 * 3600)
+    }
+
+    /// Persists the current zoom back into the stored scale representation.
+    func persistZoom() {
+        Storage.shared.chartScaleX.value = 24 * 3600 / visibleSeconds
+    }
+}
+
+final class BGChartModel: ObservableObject {
+    struct BGPoint: Identifiable {
+        let date: Date
+        let value: Double
+        let color: Color
+        var id: Double { date.timeIntervalSince1970 }
+    }
+
+    struct TreatmentPoint: Identifiable {
+        let date: Date
+        let value: Double
+        let sgv: Double
+        let label: String
+        let pillText: String
+        /// Where the symbol is drawn. Equals `date` unless `spread` nudged it
+        /// left to keep a crowded run of treatments from stacking up.
+        var drawnDate: Date
+        var id: Double { date.timeIntervalSince1970 }
+
+        init(date: Date, value: Double, sgv: Double, label: String, pillText: String) {
+            self.date = date
+            self.value = value
+            self.sgv = sgv
+            self.label = label
+            self.pillText = pillText
+            drawnDate = date
+        }
+    }
+
+    struct BasalStep: Identifiable {
+        let start: Date
+        let end: Date
+        let rate: Double
+        var id: TimeInterval { start.timeIntervalSince1970 }
+    }
+
+    struct ScheduledBasalPoint: Identifiable {
+        let date: Date
+        let rate: Double
+        var id: TimeInterval { date.timeIntervalSince1970 }
+    }
+
+    struct BandRect: Identifiable {
+        let start: Date
+        let end: Date
+        let yBottom: Double
+        let yTop: Double
+        let label: String
+        let pillText: String
+        var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" }
+    }
+
+    struct ConePoint: Identifiable {
+        let date: Date
+        let yMin: Double
+        let yMax: Double
+        var id: Double { date.timeIntervalSince1970 }
+    }
+
+    /// A maximal stretch of consecutive BG readings sharing one range color.
+    /// Each run renders as a single LineMark series; runs share their boundary
+    /// point so the line stays visually continuous across color changes.
+    /// (One series per run — tens per day — instead of one per segment, which
+    /// was a Swift Charts layout hotspot at hundreds of series.)
+    struct BGRun: Identifiable {
+        let id: Int
+        let color: Color
+        let points: [BGPoint]
+    }
+
+    @Published var bg: [BGPoint] = []
+    @Published var bgRuns: [BGRun] = []
+    @Published var yesterday: [BGPoint] = []
+    @Published var prediction: [BGPoint] = []
+    @Published var ztPrediction: [BGPoint] = []
+    @Published var iobPrediction: [BGPoint] = []
+    @Published var cobPrediction: [BGPoint] = []
+    @Published var uamPrediction: [BGPoint] = []
+
+    /// Prediction cone band (min/max envelope). Set by updateOpenAPSPredictionDisplay;
+    /// preserved across rebuild() since it has no source array on the view controller.
+    /// The didSet keeps the canvas generation in sync for call sites that assign the
+    /// cone directly without triggering a rebuild.
+    @Published var cone: [ConePoint] = [] {
+        didSet { generation &+= 1 }
+    }
+
+    @Published var basal: [BasalStep] = []
+    @Published var basalScheduled: [ScheduledBasalPoint] = []
+
+    @Published var boluses: [TreatmentPoint] = []
+    @Published var carbs: [TreatmentPoint] = []
+    @Published var smbs: [TreatmentPoint] = []
+    @Published var bgChecks: [TreatmentPoint] = []
+    @Published var suspends: [TreatmentPoint] = []
+    @Published var resumes: [TreatmentPoint] = []
+    @Published var sensorStarts: [TreatmentPoint] = []
+    @Published var notes: [TreatmentPoint] = []
+
+    @Published var overrides: [BandRect] = []
+    @Published var tempTargets: [BandRect] = []
+
+    // Backend-aware band colors: Loop draws overrides green / temp targets purple,
+    // Trio (and other OpenAPS backends) use the inverse. Mirrors TreatmentGraphColors.
+    @Published var overrideColor: Color = .green
+    @Published var tempTargetColor: Color = .purple
+
+    @Published var maxBG: Double = 250
+    @Published var maxBasal: Double = 5
+    @Published var lowLine: Double = 70
+    @Published var highLine: Double = 180
+    @Published var domainStart: Date = .init(timeIntervalSince1970: 0)
+    @Published var domainEnd: Date = .init(timeIntervalSince1970: 0)
+
+    @Published var now: Date = .init()
+    @Published var diaMarkers: [Date] = []
+    @Published var midnightMarkers: [Date] = []
+    @Published var thirtyMinMark: Date? = nil
+    @Published var ninetyMinMark: Date? = nil
+
+    /// Shared pan/zoom/follow state (see BGChartInteraction). A separate object so
+    /// per-frame gesture writes don't invalidate views that only observe the data.
+    let interaction = BGChartInteraction()
+
+    /// Monotonic data version. Bumped whenever chart data changes; the chart
+    /// canvases use it (instead of comparing arrays) to decide whether a
+    /// re-layout is needed, so panning — which changes none of the data — can
+    /// provably skip their bodies.
+    private(set) var generation: Int = 0
+
+    private var rebuildScheduled = false
+
+    @Published var showLines: Bool = true
+    @Published var showDots: Bool = true
+    @Published var showDIA: Bool = true
+    @Published var show30Min: Bool = false
+    @Published var show90Min: Bool = false
+    @Published var showMidnight: Bool = false
+    @Published var smallGraphTreatments: Bool = true
+
+    private static let doseFormatter: NumberFormatter = {
+        let nf = NumberFormatter()
+        nf.locale = .current
+        nf.numberStyle = .decimal
+        nf.usesGroupingSeparator = false
+        nf.minimumIntegerDigits = 0
+        nf.minimumFractionDigits = 0
+        nf.maximumFractionDigits = 2
+        return nf
+    }()
+
+    private func formatDose(_ value: Double) -> String {
+        Self.doseFormatter.string(from: NSNumber(value: value)) ?? String(value)
+    }
+
+    /// Formatter for the time line at the bottom of every selection pill.
+    /// Recreated on each rebuild so 12/24-hour and graph-time-zone settings apply.
+    private var pillTimeFormatter = BGChartModel.makePillTimeFormatter()
+
+    private static func makePillTimeFormatter() -> DateFormatter {
+        let df = DateFormatter()
+        df.setLocalizedDateFormatFromTemplate(dateTimeUtils.is24Hour() ? "HH:mm" : "hh:mm")
+        if Storage.shared.graphTimeZoneEnabled.value,
+           let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
+        {
+            df.timeZone = tz
+        }
+        return df
+    }
+
+    /// The pill's time line for a given point in time.
+    func pillTimeString(for date: Date) -> String {
+        pillTimeFormatter.string(from: date)
+    }
+
+    /// Nightscout remote-command error notes embed a JSON payload after
+    /// the human-readable message ("Error text {\"bolus-entry\": 1.5, ...}").
+    /// Returns the message plus a compact summary of the payload, or nil when
+    /// the note contains no JSON.
+    private static func extractMessage(from note: String) -> String? {
+        guard let jsonStartIndex = note.range(of: "{\"")?.lowerBound else {
+            return nil
+        }
+
+        let errorMessage = String(note[..<jsonStartIndex])
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+
+        var actionContext = ""
+        if let jsonData = String(note[jsonStartIndex...]).data(using: .utf8),
+           let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
+        {
+            var actionParts: [String] = []
+            if let bolusAmount = json["bolus-entry"] as? Double {
+                actionParts.append("Bolus: \(bolusAmount) U")
+            }
+            if let carbsAmount = json["carbs-entry"] as? Double {
+                actionParts.append("Carbs: \(carbsAmount) g")
+            }
+            if let absorptionTime = json["absorption-time"] as? Double {
+                actionParts.append("Absorption: \(absorptionTime) hrs")
+            }
+            if let otp = json["otp"] as? String {
+                actionParts.append("OTP: \(otp)")
+            }
+            if let enteredBy = json["entered-by"] as? String {
+                actionParts.append("From: \(enteredBy)")
+            }
+            if !actionParts.isEmpty {
+                actionContext = " [" + actionParts.joined(separator: ", ") + "]"
+            }
+        }
+
+        let finalMessage = errorMessage + actionContext
+        return finalMessage.isEmpty ? nil : finalMessage
+    }
+
+    private func colorFor(_ sgv: Int, thresholds: (low: Double, high: Double)) -> Color {
+        if Double(sgv) >= thresholds.high {
+            return .yellow
+        } else if Double(sgv) <= thresholds.low {
+            return .red
+        } else {
+            return .green
+        }
+    }
+
+    /// Groups consecutive same-colored readings into line runs (see BGRun).
+    /// The segment between two points takes the color of the earlier point.
+    private static func makeRuns(_ points: [BGPoint]) -> [BGRun] {
+        guard let first = points.first else { return [] }
+        var runs: [BGRun] = []
+        var runColor = first.color
+        var runPoints: [BGPoint] = [first]
+        for pt in points.dropFirst() {
+            runPoints.append(pt)
+            if pt.color != runColor {
+                runs.append(BGRun(id: runs.count, color: runColor, points: runPoints))
+                runPoints = [pt]
+                runColor = pt.color
+            }
+        }
+        if runPoints.count > 1 {
+            runs.append(BGRun(id: runs.count, color: runColor, points: runPoints))
+        }
+        return runs
+    }
+
+    /// Minimum drawn spacing between two treatments of the same population, and
+    /// the furthest a treatment may be moved from its true time to reach it.
+    /// Boluses and SMBs share a y-anchor and symbol footprint, so they are
+    /// decluttered as one population; carbs carry a wider "30 3h" label, so
+    /// they need — and are allowed — more room.
+    private enum Spread {
+        static let bolusGap: TimeInterval = 240
+        static let bolusShift: TimeInterval = 240
+        static let carbGap: TimeInterval = 250
+        static let carbShift: TimeInterval = 250
+    }
+
+    /// Nudges crowded treatments left so their symbols don't stack. The newest
+    /// point in a run keeps its true time and earlier ones give way, never by
+    /// more than `maxShift` — so a treatment is at most `maxShift` from where it
+    /// really happened, and an isolated one never moves at all.
+    private static func spread(_ points: [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) -> [TreatmentPoint] {
+        var out = points.sorted { $0.date < $1.date }
+        spreadSorted(&out, minGap: minGap, maxShift: maxShift)
+        return out
+    }
+
+    /// Spreads two treatment kinds as a single population — a bolus dot and an
+    /// SMB triangle drawn at the same minute overlap just like two dots would —
+    /// then hands each kind back its own points.
+    private static func spreadTogether(_ first: [TreatmentPoint], _ second: [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) -> ([TreatmentPoint], [TreatmentPoint]) {
+        let tagged = (first.map { (isFirst: true, point: $0) } + second.map { (isFirst: false, point: $0) })
+            .sorted { $0.point.date < $1.point.date }
+        var points = tagged.map(\.point)
+        spreadSorted(&points, minGap: minGap, maxShift: maxShift)
+        var outFirst: [TreatmentPoint] = []
+        var outSecond: [TreatmentPoint] = []
+        for (tag, point) in zip(tagged, points) {
+            if tag.isFirst {
+                outFirst.append(point)
+            } else {
+                outSecond.append(point)
+            }
+        }
+        return (outFirst, outSecond)
+    }
+
+    /// `points` must be sorted ascending by `date`.
+    private static func spreadSorted(_ out: inout [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) {
+        guard out.count > 1 else { return }
+
+        // Walking left from the newest point, each point yields to its right
+        // neighbor until it hits its own left bound (`date - maxShift`).
+        var clamped = [Bool](repeating: false, count: out.count)
+        for i in stride(from: out.count - 2, through: 0, by: -1) {
+            let wanted = out[i + 1].drawnDate.addingTimeInterval(-minGap)
+            guard out[i].drawnDate > wanted else { continue }
+            let leftBound = out[i].date.addingTimeInterval(-maxShift)
+            if wanted <= leftBound {
+                out[i].drawnDate = leftBound
+                clamped[i] = true
+            } else {
+                out[i].drawnDate = wanted
+            }
+        }
+
+        // A chain of clamped points was squeezed against its left bound and may
+        // have piled up there (several same-time treatments all land at
+        // date - maxShift). Re-space each chain evenly between that bound and
+        // the first point to its right that still had room.
+        var i = 0
+        while i < out.count - 1 {
+            guard clamped[i] else {
+                i += 1
+                continue
+            }
+            var last = i
+            while clamped[last + 1] {
+                last += 1
+            }
+            let anchorIndex = last + 1
+            let anchor = out[anchorIndex].drawnDate
+            let leftBound = out[i].date.addingTimeInterval(-maxShift)
+            let spacing = anchor.timeIntervalSince(leftBound) / Double(anchorIndex - i)
+            for k in i ... last {
+                let ideal = anchor.addingTimeInterval(-spacing * Double(anchorIndex - k))
+                let boundK = out[k].date.addingTimeInterval(-maxShift)
+                out[k].drawnDate = min(max(ideal, boundK), out[k].date)
+            }
+            i = anchorIndex + 1
+        }
+    }
+
+    /// Schedules a rebuild, coalescing bursts: a refresh cycle calls a dozen
+    /// update*Graph() entry points back-to-back, and rebuilding once per
+    /// runloop turn is enough.
+    func rebuild() {
+        guard !rebuildScheduled else {
+            return
+        }
+        rebuildScheduled = true
+        DispatchQueue.main.async { [weak self] in
+            guard let self else { return }
+            self.rebuildScheduled = false
+            self.performRebuild()
+        }
+    }
+
+    private func performRebuild() {
+        guard let vc = MainViewController.shared else { return }
+
+        pillTimeFormatter = Self.makePillTimeFormatter()
+
+        let maxBGValue = Double(vc.calculateMaxBgGraphValue())
+        maxBG = max(maxBGValue, Storage.shared.minBGScale.value)
+
+        // Same thresholds the stats and main header use: fixed 70–180 / 70–140
+        // for the TIR/TITR range modes, the user's lines for custom mode.
+        let thresholds = UnitSettingsStore.shared.effectiveThresholds()
+        lowLine = thresholds.low
+        highLine = thresholds.high
+
+        showLines = Storage.shared.showLines.value
+        showDots = Storage.shared.showDots.value
+        showDIA = Storage.shared.showDIALines.value
+        show30Min = Storage.shared.show30MinLine.value
+        show90Min = Storage.shared.show90MinLine.value
+        showMidnight = Storage.shared.showMidnightLines.value
+        smallGraphTreatments = Storage.shared.smallGraphTreatments.value
+
+        let isLoop = Storage.shared.device.value == "Loop"
+        overrideColor = isLoop ? .green : .purple
+        tempTargetColor = isLoop ? .purple : .green
+
+        // Clamp plotted BG to the display range (see #600); color is still
+        // keyed off the true reading.
+        let minDisplay = globalVariables.minDisplayGlucose
+        let maxDisplay = globalVariables.maxDisplayGlucose
+        func clampSgv(_ sgv: Int) -> Double { Double(min(max(sgv, minDisplay), maxDisplay)) }
+
+        bg = vc.bgData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: colorFor($0.sgv, thresholds: thresholds)) }
+        bgRuns = Self.makeRuns(bg)
+
+        // Yesterday comparison overlay (#665): already +24h shifted, dimmed gray, no dots.
+        if Storage.shared.showYesterdayLine.value {
+            yesterday = vc.yesterdayBGData.map {
+                BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: Color(.systemGray).opacity(0.4))
+            }
+        } else {
+            yesterday = []
+        }
+
+        prediction = vc.predictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
+        ztPrediction = vc.ztPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
+        iobPrediction = vc.iobPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
+        cobPrediction = vc.cobPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
+        uamPrediction = vc.uamPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
+
+        let bolusPoints = vc.bolusData.map {
+            let dose = self.formatDose($0.value)
+            return TreatmentPoint(
+                date: Date(timeIntervalSince1970: $0.date),
+                value: $0.value,
+                sgv: Double($0.sgv),
+                label: dose,
+                pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
+            )
+        }
+        carbs = Self.spread(vc.carbData.map {
+            let grams = Int($0.value)
+            var label = "\(grams)"
+            if $0.absorptionTime > 0, Storage.shared.showAbsorption.value {
+                label += " \($0.absorptionTime / 60)h"
+            }
+            return TreatmentPoint(
+                date: Date(timeIntervalSince1970: $0.date),
+                value: $0.value,
+                sgv: Double($0.sgv),
+                label: label,
+                pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
+            )
+        }, minGap: Spread.carbGap, maxShift: Spread.carbShift)
+        let smbPoints = vc.smbData.map {
+            let dose = self.formatDose($0.value)
+            return TreatmentPoint(
+                date: Date(timeIntervalSince1970: $0.date),
+                value: $0.value,
+                sgv: Double($0.sgv),
+                label: dose,
+                pillText: "SMB\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
+            )
+        }
+        (boluses, smbs) = Self.spreadTogether(bolusPoints, smbPoints, minGap: Spread.bolusGap, maxShift: Spread.bolusShift)
+        bgChecks = vc.bgCheckData.map {
+            TreatmentPoint(
+                date: Date(timeIntervalSince1970: $0.date),
+                value: Double($0.sgv),
+                sgv: Double($0.sgv),
+                label: "",
+                pillText: "BG Check\n\(Localizer.toDisplayUnits(String($0.sgv)))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
+            )
+        }
+        suspends = vc.suspendGraphData.map {
+            TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Suspend\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
+        }
+        resumes = vc.resumeGraphData.map {
+            TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Resume\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
+        }
+        sensorStarts = vc.sensorStartGraphData.map {
+            TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Sensor Start\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
+        }
+        notes = vc.noteGraphData.map {
+            TreatmentPoint(
+                date: Date(timeIntervalSince1970: $0.date),
+                value: Double($0.sgv),
+                sgv: Double($0.sgv),
+                label: $0.note,
+                pillText: "\(Self.extractMessage(from: $0.note) ?? $0.note)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
+            )
+        }
+
+        basalScheduled = vc.basalScheduleData.map {
+            ScheduledBasalPoint(date: Date(timeIntervalSince1970: $0.date), rate: $0.basalRate)
+        }
+
+        var steps: [BasalStep] = []
+        let sortedBasal = vc.basalData.sorted { $0.date < $1.date }
+        for i in 0 ..< sortedBasal.count {
+            let start = sortedBasal[i].date
+            let end = i + 1 < sortedBasal.count
+                ? sortedBasal[i + 1].date
+                : dateTimeUtils.getNowTimeIntervalUTC()
+            guard end > start else { continue }
+            steps.append(BasalStep(
+                start: Date(timeIntervalSince1970: start),
+                end: Date(timeIntervalSince1970: end),
+                rate: sortedBasal[i].basalRate
+            ))
+        }
+        basal = steps
+        let computedMaxBasal = steps.map(\.rate).max() ?? 0
+        maxBasal = max(computedMaxBasal, Storage.shared.minBasalScale.value)
+
+        let yTop = maxBG - 5
+        let yBottom = maxBG - 25
+        overrides = vc.overrideGraphData.map {
+            let overrideName = $0.reason.trimmingCharacters(in: .whitespacesAndNewlines)
+            let displayName = overrideName.isEmpty ? "Override" : overrideName
+            return BandRect(
+                start: Date(timeIntervalSince1970: $0.date),
+                end: Date(timeIntervalSince1970: $0.endDate),
+                yBottom: yBottom,
+                yTop: yTop,
+                label: displayName,
+                pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
+            )
+        }
+        tempTargets = vc.tempTargetGraphData.map {
+            let target = $0.correctionRange.first.map { String($0) } ?? ""
+            // Temp targets render at the BG level they target (±5 mg/dL);
+            // only overrides live in the top strip.
+            let yCenter = Double($0.correctionRange.first ?? 0)
+            return BandRect(
+                start: Date(timeIntervalSince1970: $0.date),
+                end: Date(timeIntervalSince1970: $0.endDate),
+                yBottom: yCenter - 5,
+                yTop: yCenter + 5,
+                label: "Temp Target",
+                pillText: "Temp Target\n\(Localizer.toDisplayUnits(target))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
+            )
+        }
+
+        let currentNow = Date(timeIntervalSince1970: dateTimeUtils.getNowTimeIntervalUTC())
+        now = currentNow
+        let hoursBack = TimeInterval(Storage.shared.downloadDays.value * 24 * 3600)
+        domainStart = currentNow.addingTimeInterval(-hoursBack)
+        // Everything drawn in the future (predictions, cone, override/temp-target
+        // bands) is bounded by the "Hours of Prediction" setting, so the scale
+        // domain ends there too. The 15-minute floor keeps room for follow
+        // mode's right-side padding when predictions are set to 0 (and matches
+        // the floor Overrides.swift uses for future bands).
+        let hoursForward = max(Storage.shared.predictionToLoad.value, 0.25) * 3600
+        domainEnd = currentNow.addingTimeInterval(hoursForward)
+
+        // 30/90 min lookback markers
+        thirtyMinMark = currentNow.addingTimeInterval(-1800)
+        ninetyMinMark = currentNow.addingTimeInterval(-5400)
+
+        // DIA markers: every hour going back for 6 hours
+        var dia: [Date] = []
+        for i in 1 ... 6 {
+            dia.append(currentNow.addingTimeInterval(TimeInterval(-i * 3600)))
+        }
+        diaMarkers = dia
+
+        // Midnight markers: every local midnight within domain
+        var midnights: [Date] = []
+        let calendar: Calendar = {
+            var cal = Calendar(identifier: .gregorian)
+            if Storage.shared.graphTimeZoneEnabled.value,
+               let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
+            {
+                cal.timeZone = tz
+            }
+            return cal
+        }()
+        var cursor = calendar.startOfDay(for: domainStart)
+        while cursor <= domainEnd {
+            if cursor >= domainStart {
+                midnights.append(cursor)
+            }
+            guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
+            cursor = next
+        }
+        midnightMarkers = midnights
+
+        generation &+= 1
+    }
+}

+ 194 - 0
LoopFollow/Charts/BGChartStubs.swift

@@ -0,0 +1,194 @@
+// LoopFollow
+// BGChartStubs.swift
+
+import Foundation
+import SwiftUI
+import UIKit
+
+/// The `update*Graph()` / `createGraph()` entry points the rest of the app
+/// calls to refresh the chart. Rendering happens in BGChartModel +
+/// BGChartView (Apple Swift Charts); these update lightweight state
+/// (topBG / topPredictionBG / prediction arrays) and trigger a (coalesced)
+/// model rebuild.
+extension MainViewController {
+    func createGraph() {
+        chartModel.rebuild()
+        // The chart's follow/auto-return logic runs off model.now, so arm the
+        // once-a-minute "now" tick.
+        startGraphNowTimer()
+    }
+
+    func createSmallBGGraph() {}
+
+    func updateBGGraphSettings() {
+        chartModel.rebuild()
+    }
+
+    private func recomputeTopBG() {
+        let maxBGOffset: Double = 50
+        topBG = Storage.shared.minBGScale.value
+        for entry in bgData {
+            if Double(entry.sgv) > topBG - maxBGOffset {
+                topBG = Double(entry.sgv) + maxBGOffset
+            }
+        }
+    }
+
+    private func recomputeTopPredictionBG() {
+        let maxBGOffset: Double = 20
+        topPredictionBG = Storage.shared.minBGScale.value
+        let allPredictions = predictionData + ztPredictionData + iobPredictionData + cobPredictionData + uamPredictionData
+        for entry in allPredictions {
+            let v = Double(entry.sgv)
+            if v > topPredictionBG - maxBGOffset {
+                topPredictionBG = v + maxBGOffset
+            }
+        }
+    }
+
+    func updateBGGraph() {
+        recomputeTopBG()
+        // Profile processing defers building the scheduled-basal segments until
+        // the first BG render has happened (Profile.swift checks firstGraphLoad).
+        // Clear the flag here or the scheduled basal line never gets data.
+        if !bgData.isEmpty {
+            firstGraphLoad = false
+        }
+        chartModel.rebuild()
+    }
+
+    func updatePredictionGraph(color _: UIColor? = nil) {
+        recomputeTopPredictionBG()
+        chartModel.rebuild()
+    }
+
+    func updatePredictionGraphGeneric(
+        dataIndex: Int,
+        predictionData: [ShareGlucoseData],
+        chartLabel _: String,
+        color _: UIColor
+    ) {
+        switch dataIndex {
+        case 12: ztPredictionData = predictionData
+        case 13: iobPredictionData = predictionData
+        case 14: cobPredictionData = predictionData
+        case 15: uamPredictionData = predictionData
+        default: break
+        }
+        recomputeTopPredictionBG()
+        chartModel.rebuild()
+    }
+
+    // Removes the Loop forecast line. Used when the active system switches away from Loop.
+    func clearLoopPredictionGraph() {
+        guard !predictionData.isEmpty else { return }
+        predictionData.removeAll()
+        updatePredictionGraph()
+    }
+
+    // Removes the Trio/OpenAPS forecast (ZT/IOB/COB/UAM lines and the cone). Used when the active system switches away from Trio/OpenAPS.
+    // Also drops the stored predBGs: the device observer calls updateOpenAPSPredictionDisplay after this runs,
+    // and would otherwise redraw the cleared forecast from them.
+    func clearOpenAPSPredictionGraph() {
+        let hasLineData = !ztPredictionData.isEmpty || !iobPredictionData.isEmpty || !cobPredictionData.isEmpty || !uamPredictionData.isEmpty
+        guard hasLineData || !chartModel.cone.isEmpty || openAPSPredBGs != nil else { return }
+        openAPSPredBGs = nil
+        openAPSPredUpdatedTime = nil
+        ztPredictionData = []
+        iobPredictionData = []
+        cobPredictionData = []
+        uamPredictionData = []
+        chartModel.cone = []
+        recomputeTopPredictionBG()
+        chartModel.rebuild()
+    }
+
+    // Routes OpenAPS/Trio predictions either into a cone band or individual
+    // prediction lines. Cone is only for OpenAPS-based backends; Loop always
+    // uses lines.
+    func updateOpenAPSPredictionDisplay() {
+        guard let predBGs = openAPSPredBGs else {
+            chartModel.cone = []
+            return
+        }
+
+        let displayType: PredictionDisplayType = Storage.shared.device.value == "Loop"
+            ? .lines
+            : Storage.shared.predictionDisplayType.value
+        let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
+        let predictionStart = openAPSPredUpdatedTime ?? Date().timeIntervalSince1970
+        let types = ["ZT", "IOB", "COB", "UAM"]
+        let minDisplay = globalVariables.minDisplayGlucose
+        let maxDisplay = globalVariables.maxDisplayGlucose
+
+        topPredictionBG = Storage.shared.minBGScale.value
+
+        if displayType == .cone {
+            // Clear individual prediction lines.
+            ztPredictionData = []
+            iobPredictionData = []
+            cobPredictionData = []
+            uamPredictionData = []
+
+            let allArrays = types.compactMap { predBGs[$0] }.filter { !$0.isEmpty }
+            var coneData: [BGChartModel.ConePoint] = []
+            if !allArrays.isEmpty {
+                // Cap at the shortest predBG array length so every cone point uses
+                // the same set of contributing arrays. Matches Trio's ForecastSetup.
+                let coneLength = min(allArrays.map { $0.count }.min()!, toLoad + 1)
+                var t = predictionStart
+                for i in 0 ..< coneLength {
+                    let valuesAtIndex = allArrays.compactMap { i < $0.count ? $0[i] : nil }
+                    if !valuesAtIndex.isEmpty {
+                        var yMin = max(valuesAtIndex.min()!, Double(minDisplay))
+                        var yMax = min(valuesAtIndex.max()!, Double(maxDisplay))
+                        // Keep a minimum ±1 mg/dL band so the cone stays visible when predictions agree.
+                        if yMin == yMax {
+                            yMin -= 1
+                            yMax += 1
+                        }
+                        coneData.append(BGChartModel.ConePoint(
+                            date: Date(timeIntervalSince1970: t),
+                            yMin: yMin,
+                            yMax: yMax
+                        ))
+                        if yMax > topPredictionBG - 20 { topPredictionBG = yMax + 20 }
+                    }
+                    t += 300
+                }
+            }
+            chartModel.cone = coneData
+        } else {
+            chartModel.cone = []
+
+            // dataIndex mapping matches updatePredictionGraphGeneric: 12=ZT 13=IOB 14=COB 15=UAM
+            for (offset, type) in types.enumerated() {
+                var predictionData: [ShareGlucoseData] = []
+                if let graphData = predBGs[type] {
+                    var t = predictionStart
+                    for i in 0 ... toLoad where i < graphData.count {
+                        let clamped = min(max(Int(round(graphData[i])), minDisplay), maxDisplay)
+                        predictionData.append(ShareGlucoseData(sgv: clamped, date: t, direction: "flat"))
+                        t += 300
+                    }
+                }
+                updatePredictionGraphGeneric(dataIndex: 12 + offset, predictionData: predictionData, chartLabel: type, color: .clear)
+            }
+        }
+
+        chartModel.rebuild()
+    }
+
+    func updateBasalGraph() { chartModel.rebuild() }
+    func updateBolusGraph() { chartModel.rebuild() }
+    func updateCarbGraph() { chartModel.rebuild() }
+    func updateBasalScheduledGraph() { chartModel.rebuild() }
+    func updateOverrideGraph() { chartModel.rebuild() }
+    func updateBGCheckGraph() { chartModel.rebuild() }
+    func updateSuspendGraph() { chartModel.rebuild() }
+    func updateResumeGraph() { chartModel.rebuild() }
+    func updateSensorStart() { chartModel.rebuild() }
+    func updateNotes() { chartModel.rebuild() }
+    func updateSmbGraph() { chartModel.rebuild() }
+    func updateTempTargetGraph() { chartModel.rebuild() }
+}

Файловите разлики са ограничени, защото са твърде много
+ 1623 - 0
LoopFollow/Charts/BGChartView.swift


+ 24 - 1
LoopFollow/Controllers/AlarmSound.swift

@@ -58,7 +58,30 @@ class AlarmSound {
     }
     }
 
 
     static func setSoundFile(str: String) {
     static func setSoundFile(str: String) {
-        soundURL = Bundle.main.url(forResource: str, withExtension: "caf")!
+        setSoundFile(.builtin(str))
+    }
+
+    static func setSoundFile(_ file: SoundFile) {
+        if let resolved = resolveURL(for: file) {
+            soundURL = resolved
+            return
+        }
+        LogManager.shared.log(
+            category: .alarm,
+            message: "AlarmSound - missing sound for \(file.id); falling back to \(SoundFile.fallback.id)"
+        )
+        if let fallback = resolveURL(for: .fallback) {
+            soundURL = fallback
+        }
+    }
+
+    private static func resolveURL(for file: SoundFile) -> URL? {
+        switch file {
+        case let .builtin(name):
+            return Bundle.main.url(forResource: name, withExtension: "caf")
+        case let .custom(uuid):
+            return CustomSoundStore.shared.url(for: uuid)
+        }
     }
     }
 
 
     /*
     /*

+ 4 - 1
LoopFollow/Controllers/BackgroundAlertManager.swift

@@ -76,7 +76,10 @@ class BackgroundAlertManager {
         removeDeliveredNotifications()
         removeDeliveredNotifications()
 
 
         let isBluetoothActive = Storage.shared.backgroundRefreshType.value.isBluetooth
         let isBluetoothActive = Storage.shared.backgroundRefreshType.value.isBluetooth
-        let expectedHeartbeat = BLEManager.shared.expectedHeartbeatInterval()
+        // Only query BLEManager for a Bluetooth mode — touching it otherwise would
+        // initialize CoreBluetooth and trigger the permission prompt for users
+        // (e.g. Silent Tune) who never opted into Bluetooth.
+        let expectedHeartbeat = isBluetoothActive ? BLEManager.shared.expectedHeartbeatInterval() : nil
 
 
         // Define alerts
         // Define alerts
         let alerts: [BackgroundAlert] = [
         let alerts: [BackgroundAlert] = [

Файловите разлики са ограничени, защото са твърде много
+ 0 - 2182
LoopFollow/Controllers/Graphs.swift


+ 4 - 3
LoopFollow/Controllers/NightScout.swift

@@ -2,7 +2,6 @@
 // NightScout.swift
 // NightScout.swift
 
 
 import Foundation
 import Foundation
-import UIKit
 
 
 extension MainViewController {
 extension MainViewController {
     // NS Cage Struct
     // NS Cage Struct
@@ -59,7 +58,6 @@ extension MainViewController {
     func clearOldSmb() {
     func clearOldSmb() {
         smbData.removeAll()
         smbData.removeAll()
         updateSmbGraph()
         updateSmbGraph()
-        updateChartRenderers()
     }
     }
 
 
     func clearOldCarb() {
     func clearOldCarb() {
@@ -74,13 +72,16 @@ extension MainViewController {
 
 
     func clearOldOverride() {
     func clearOldOverride() {
         overrideGraphData.removeAll()
         overrideGraphData.removeAll()
+        Observable.shared.override.value = nil
+        Observable.shared.overrideEndAt.value = nil
         updateOverrideGraph()
         updateOverrideGraph()
     }
     }
 
 
     func clearOldTempTarget() {
     func clearOldTempTarget() {
         tempTargetGraphData.removeAll()
         tempTargetGraphData.removeAll()
+        Observable.shared.tempTarget.value = nil
+        Observable.shared.tempTargetEndAt.value = nil
         updateTempTargetGraph()
         updateTempTargetGraph()
-        updateChartRenderers()
     }
     }
 
 
     func clearOldSuspend() {
     func clearOldSuspend() {

+ 6 - 1
LoopFollow/Controllers/Nightscout/BGData.swift

@@ -2,7 +2,6 @@
 // BGData.swift
 // BGData.swift
 
 
 import Foundation
 import Foundation
-import UIKit
 
 
 extension MainViewController {
 extension MainViewController {
     /// Number of days of BG history to request from the source. One extra day is
     /// Number of days of BG history to request from the source. One extra day is
@@ -22,6 +21,7 @@ extension MainViewController {
         dexShare?.fetchData(count) { err, result in
         dexShare?.fetchData(count) { err, result in
             if let error = err {
             if let error = err {
                 LogManager.shared.log(category: .dexcom, message: "Error fetching Dexcom data: \(error.localizedDescription)", limitIdentifier: "Error fetching Dexcom data")
                 LogManager.shared.log(category: .dexcom, message: "Error fetching Dexcom data: \(error.localizedDescription)", limitIdentifier: "Error fetching Dexcom data")
+                BannerManager.shared.reportDexcomFailure(error, nightscoutFallback: IsNightscoutEnabled())
                 self.webLoadNSBGData()
                 self.webLoadNSBGData()
                 return
                 return
             }
             }
@@ -32,6 +32,8 @@ extension MainViewController {
                 return
                 return
             }
             }
 
 
+            BannerManager.shared.clear(.dexcom)
+
             // If Dex data is old, load from NS instead
             // If Dex data is old, load from NS instead
             let latestDate = data[0].date
             let latestDate = data[0].date
             let now = dateTimeUtils.getNowTimeIntervalUTC()
             let now = dateTimeUtils.getNowTimeIntervalUTC()
@@ -60,6 +62,7 @@ extension MainViewController {
     func webLoadNSBGData(dexData: [ShareGlucoseData] = []) {
     func webLoadNSBGData(dexData: [ShareGlucoseData] = []) {
         // This kicks it out in the instance where dexcom fails but they aren't using NS &&
         // This kicks it out in the instance where dexcom fails but they aren't using NS &&
         if !IsNightscoutEnabled() {
         if !IsNightscoutEnabled() {
+            BannerManager.shared.clear(.nightscout)
             Storage.shared.lastBGChecked.value = Date()
             Storage.shared.lastBGChecked.value = Date()
             return
             return
         }
         }
@@ -75,6 +78,7 @@ extension MainViewController {
         NightscoutUtils.executeRequest(eventType: .sgv, parameters: parameters) { (result: Result<[ShareGlucoseData], Error>) in
         NightscoutUtils.executeRequest(eventType: .sgv, parameters: parameters) { (result: Result<[ShareGlucoseData], Error>) in
             switch result {
             switch result {
             case let .success(entriesResponse):
             case let .success(entriesResponse):
+                BannerManager.shared.clear(.nightscout)
                 var nsData = entriesResponse
                 var nsData = entriesResponse
                 DispatchQueue.main.async {
                 DispatchQueue.main.async {
                     // transform NS data to look like Dex data
                     // transform NS data to look like Dex data
@@ -103,6 +107,7 @@ extension MainViewController {
                 }
                 }
             case let .failure(error):
             case let .failure(error):
                 LogManager.shared.log(category: .nightscout, message: "Failed to fetch bg data: \(error)", limitIdentifier: "Failed to fetch bg data")
                 LogManager.shared.log(category: .nightscout, message: "Failed to fetch bg data: \(error)", limitIdentifier: "Failed to fetch bg data")
+                BannerManager.shared.reportNightscoutFetchFailure(error)
                 DispatchQueue.main.async {
                 DispatchQueue.main.async {
                     TaskScheduler.shared.rescheduleTask(
                     TaskScheduler.shared.rescheduleTask(
                         id: .fetchBG,
                         id: .fetchBG,

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

@@ -51,7 +51,7 @@ extension MainViewController {
             formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale
             formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale
 
 
             if let formattedDuration = formatter.string(from: secondsAgo) {
             if let formattedDuration = formatter.string(from: secondsAgo) {
-                infoManager.updateInfoData(type: .cage, value: formattedDuration)
+                infoManager.updateInfoData(type: .cage, value: formattedDuration, numericValue: secondsAgo / 86400)
             }
             }
         }
         }
     }
     }

+ 62 - 0
LoopFollow/Controllers/Nightscout/DBSize.swift

@@ -0,0 +1,62 @@
+// LoopFollow
+// DBSize.swift
+
+import Foundation
+
+/// Response shape of `/api/v2/properties/dbsize`.
+///
+/// Nightscout's `dbsize` plugin reports the Mongo `dataSize + indexSize` sum
+/// against `DBSIZE_MAX` (defaults to 496 MiB when the site admin has not set it).
+/// The key is absent when the plugin has not produced a property yet.
+struct DBSizeProperties: Codable {
+    let dbsize: DBSizeData?
+}
+
+struct DBSizeData: Codable {
+    /// Used size, in MiB.
+    let totalDataSize: Double?
+    /// Used size as a percentage of `details.maxSize`, floored by the server.
+    let dataPercentage: Int?
+    let details: Details?
+
+    struct Details: Codable {
+        /// The configured limit in MiB (`DBSIZE_MAX`).
+        let maxSize: Double?
+        let dataSize: Double?
+    }
+}
+
+extension MainViewController {
+    // NS Database Size Web Call
+    func webLoadNSDBSize() {
+        NightscoutUtils.executeRequest(eventType: .dbSize, parameters: [:]) { (result: Result<DBSizeProperties, Error>) in
+            switch result {
+            case let .success(properties):
+                self.updateDBSize(data: properties.dbsize)
+            case let .failure(error):
+                LogManager.shared.log(category: .nightscout, message: "webLoadNSDBSize, error: \(error.localizedDescription)")
+            }
+        }
+    }
+
+    // NS Database Size Response Processor
+    func updateDBSize(data: DBSizeData?) {
+        // Nightscout still reports the property when `db.stats()` failed, but with a zero
+        // size. Its own pill hides on that, so treat it as "no reading" rather than 0%.
+        guard let data = data, let usedMiB = data.totalDataSize, usedMiB > 0 else {
+            infoManager.clearInfoData(type: .dbSize)
+            Observable.shared.dbSizePercentage.set(nil)
+            return
+        }
+
+        Observable.shared.dbSizePercentage.set(data.dataPercentage.map(Double.init))
+
+        let used = Localizer.formatToLocalizedString(usedMiB, maxFractionDigits: 0, minFractionDigits: 0)
+
+        if let percentage = data.dataPercentage {
+            infoManager.updateInfoData(type: .dbSize, value: "\(used) MiB (\(percentage)%)")
+        } else {
+            infoManager.updateInfoData(type: .dbSize, value: "\(used) MiB")
+        }
+    }
+}

+ 32 - 5
LoopFollow/Controllers/Nightscout/DeviceStatus.swift

@@ -66,6 +66,7 @@ extension MainViewController {
     // NS Device Status Response Processor
     // NS Device Status Response Processor
     func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) {
     func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) {
         let previousIOBText = Observable.shared.iobText.value
         let previousIOBText = Observable.shared.iobText.value
+        let previousDeviceWasLoop = Storage.shared.device.value == "Loop"
         infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd])
         infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd])
 
 
         // For Loop, clear the current override here - For Trio, it is handled using treatments
         // For Loop, clear the current override here - For Trio, it is handled using treatments
@@ -113,11 +114,13 @@ extension MainViewController {
 
 
                 if let reservoirData = lastPumpRecord["reservoir"] as? Double {
                 if let reservoirData = lastPumpRecord["reservoir"] as? Double {
                     latestPumpVolume = reservoirData
                     latestPumpVolume = reservoirData
-                    infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U")
+                    infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U", numericValue: reservoirData)
                     Storage.shared.lastPumpReservoirU.value = reservoirData
                     Storage.shared.lastPumpReservoirU.value = reservoirData
                 } else {
                 } else {
+                    // Pumps that only report "50+" get treated as exactly 50, both
+                    // for the volume alarm and for the info row's coloring.
                     latestPumpVolume = 50.0
                     latestPumpVolume = 50.0
-                    infoManager.updateInfoData(type: .pump, value: "50+U")
+                    infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0)
                     Storage.shared.lastPumpReservoirU.value = nil
                     Storage.shared.lastPumpReservoirU.value = nil
                 }
                 }
             }
             }
@@ -126,21 +129,23 @@ extension MainViewController {
             if let pumpBatteryRecord = lastPumpRecord["battery"] as? [String: AnyObject],
             if let pumpBatteryRecord = lastPumpRecord["battery"] as? [String: AnyObject],
                let pumpBatteryPercent = pumpBatteryRecord["percent"] as? Double
                let pumpBatteryPercent = pumpBatteryRecord["percent"] as? Double
             {
             {
-                infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%")
+                infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%", numericValue: pumpBatteryPercent)
                 Observable.shared.pumpBatteryLevel.value = pumpBatteryPercent
                 Observable.shared.pumpBatteryLevel.value = pumpBatteryPercent
             }
             }
 
 
             if let uploader = lastDeviceStatus?["uploader"] as? [String: AnyObject],
             if let uploader = lastDeviceStatus?["uploader"] as? [String: AnyObject],
                let upbat = uploader["battery"] as? Double
                let upbat = uploader["battery"] as? Double
             {
             {
+                let isCharging = uploader["isCharging"] as? Bool
                 let batteryText: String
                 let batteryText: String
-                if let isCharging = uploader["isCharging"] as? Bool, isCharging {
+                if isCharging == true {
                     batteryText = "⚡️ " + String(format: "%.0f", upbat) + "%"
                     batteryText = "⚡️ " + String(format: "%.0f", upbat) + "%"
                 } else {
                 } else {
                     batteryText = String(format: "%.0f", upbat) + "%"
                     batteryText = String(format: "%.0f", upbat) + "%"
                 }
                 }
-                infoManager.updateInfoData(type: .battery, value: batteryText)
+                infoManager.updateInfoData(type: .battery, value: batteryText, numericValue: upbat)
                 Observable.shared.deviceBatteryLevel.value = upbat
                 Observable.shared.deviceBatteryLevel.value = upbat
+                Observable.shared.deviceBatteryIsCharging.value = isCharging
 
 
                 let timestamp = uploader["timestamp"] as? Date ?? Date()
                 let timestamp = uploader["timestamp"] as? Date ?? Date()
                 let currentBattery = DataStructs.batteryStruct(batteryLevel: upbat, timestamp: timestamp)
                 let currentBattery = DataStructs.batteryStruct(batteryLevel: upbat, timestamp: timestamp)
@@ -155,6 +160,17 @@ extension MainViewController {
 
 
         // Loop - handle new data
         // Loop - handle new data
         if let lastLoopRecord = lastDeviceStatus?["loop"] as! [String: AnyObject]? {
         if let lastLoopRecord = lastDeviceStatus?["loop"] as! [String: AnyObject]? {
+            // Some pumps report no `pump.clock`; without it alertLastLoopTime stays 0
+            // and the forecast anchors to epoch 0. Fall back to the loop cycle timestamp.
+            if (lastDeviceStatus?["pump"] as? [String: AnyObject])?["clock"] == nil,
+               let loopTimestampString = lastLoopRecord["timestamp"] as? String,
+               let loopTimestamp = formatter.date(from: loopTimestampString)?.timeIntervalSince1970,
+               loopTimestamp > (Observable.shared.alertLastLoopTime.value ?? 0)
+            {
+                Observable.shared.alertLastLoopTime.value = loopTimestamp
+                Storage.shared.lastLoopTime.value = loopTimestamp
+            }
+
             DeviceStatusLoop(formatter: formatter, lastLoopRecord: lastLoopRecord)
             DeviceStatusLoop(formatter: formatter, lastLoopRecord: lastLoopRecord)
 
 
             var oText = ""
             var oText = ""
@@ -188,6 +204,17 @@ extension MainViewController {
             DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord)
             DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord)
         }
         }
 
 
+        // If the active looping system flipped (Loop ⇄ Trio/OpenAPS), drop the previous
+        // system's forecast so it doesn't linger next to the one just drawn above.
+        let currentDeviceIsLoop = Storage.shared.device.value == "Loop"
+        if currentDeviceIsLoop != previousDeviceWasLoop {
+            if currentDeviceIsLoop {
+                clearOpenAPSPredictionGraph()
+            } else {
+                clearLoopPredictionGraph()
+            }
+        }
+
         // Start the timer based on the timestamp
         // Start the timer based on the timestamp
         let now = dateTimeUtils.getNowTimeIntervalUTC()
         let now = dateTimeUtils.getNowTimeIntervalUTC()
         let secondsAgo = now - (Observable.shared.alertLastLoopTime.value ?? 0)
         let secondsAgo = now - (Observable.shared.alertLastLoopTime.value ?? 0)

+ 5 - 3
LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift

@@ -68,7 +68,7 @@ extension MainViewController {
                 let prediction = predictdata["values"] as! [Double]
                 let prediction = predictdata["values"] as! [Double]
                 Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(prediction.last!))))
                 Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(prediction.last!))))
                 Observable.shared.predictionColor.value = .purple
                 Observable.shared.predictionColor.value = .purple
-                if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime {
+                if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime || predictionData.isEmpty {
                     predictionData.removeAll()
                     predictionData.removeAll()
                     var predictionTime = lastLoopTime
                     var predictionTime = lastLoopTime
                     let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
                     let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
@@ -101,9 +101,11 @@ extension MainViewController {
                 updatePredictionGraph()
                 updatePredictionGraph()
             }
             }
             if let recBolus = lastLoopRecord["recommendedBolus"] as? Double {
             if let recBolus = lastLoopRecord["recommendedBolus"] as? Double {
-                let formattedRecBolus = String(format: "%.2fU", recBolus)
-                infoManager.updateInfoData(type: .recBolus, value: formattedRecBolus)
+                infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(recBolus), numericValue: recBolus)
                 Observable.shared.deviceRecBolus.value = recBolus
                 Observable.shared.deviceRecBolus.value = recBolus
+            } else {
+                infoManager.clearInfoData(type: .recBolus)
+                Observable.shared.deviceRecBolus.value = nil
             }
             }
             if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {
             if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {
                 if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 {
                 if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 {

+ 17 - 8
LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift

@@ -3,7 +3,6 @@
 
 
 import Foundation
 import Foundation
 import HealthKit
 import HealthKit
-import UIKit
 
 
 extension MainViewController {
 extension MainViewController {
     func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) {
     func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) {
@@ -20,7 +19,7 @@ extension MainViewController {
 
 
             var updatedTime: TimeInterval?
             var updatedTime: TimeInterval?
 
 
-            if let timestamp = enactedOrSuggested["timestamp"] as? String,
+            if let timestamp = enactedOrSuggested["deliverAt"] as? String ?? enactedOrSuggested["timestamp"] as? String,
                let parsedTime = formatter.date(from: timestamp)?.timeIntervalSince1970
                let parsedTime = formatter.date(from: timestamp)?.timeIntervalSince1970
             {
             {
                 updatedTime = parsedTime
                 updatedTime = parsedTime
@@ -32,7 +31,9 @@ extension MainViewController {
             // ISF
             // ISF
             let profileISF = profileManager.currentISF()
             let profileISF = profileManager.currentISF()
             var enactedISF: HKQuantity?
             var enactedISF: HKQuantity?
-            if let enactedISFValue = enactedOrSuggested["ISF"] as? Double {
+            // Some uploaders (e.g. Trio Swift oref with dynamic ISF) report a
+            // placeholder 0 in the structured ISF field; treat that as missing.
+            if let enactedISFValue = enactedOrSuggested["ISF"] as? Double, enactedISFValue != 0 {
                 enactedISF = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: enactedISFValue)
                 enactedISF = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: enactedISFValue)
             }
             }
             if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF {
             if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF {
@@ -44,9 +45,14 @@ extension MainViewController {
             }
             }
 
 
             // Carb Ratio (CR)
             // Carb Ratio (CR)
+            // Prefer the structured CR field; fall back to parsing it out of the
+            // reason string for uploaders that don't expose it (a CR of 0 is never
+            // valid, so it's treated as missing).
             let profileCR = profileManager.currentCarbRatio()
             let profileCR = profileManager.currentCarbRatio()
             var enactedCR: Double?
             var enactedCR: Double?
-            if let reasonString = enactedOrSuggested["reason"] as? String {
+            if let structuredCR = enactedOrSuggested["CR"] as? Double, structuredCR != 0 {
+                enactedCR = structuredCR
+            } else if let reasonString = enactedOrSuggested["reason"] as? String {
                 let pattern = "CR: (\\d+(?:\\.\\d+)?)"
                 let pattern = "CR: (\\d+(?:\\.\\d+)?)"
                 if let regex = try? NSRegularExpression(pattern: pattern) {
                 if let regex = try? NSRegularExpression(pattern: pattern) {
                     let nsString = reasonString as NSString
                     let nsString = reasonString as NSString
@@ -107,10 +113,11 @@ extension MainViewController {
             }
             }
 
 
             // Recommended Bolus
             // Recommended Bolus
-            if let rec = InsulinMetric(from: lastLoopRecord, key: "recommendedBolus") {
-                infoManager.updateInfoData(type: .recBolus, value: rec)
-                Observable.shared.deviceRecBolus.value = rec.value
+            if let rec = lastLoopRecord["recommendedBolus"] as? Double {
+                infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(rec))
+                Observable.shared.deviceRecBolus.value = rec
             } else {
             } else {
+                infoManager.clearInfoData(type: .recBolus)
                 Observable.shared.deviceRecBolus.value = nil
                 Observable.shared.deviceRecBolus.value = nil
             }
             }
 
 
@@ -155,7 +162,9 @@ extension MainViewController {
             }
             }
 
 
             // TDD
             // TDD
-            if let tddMetric = InsulinMetric(from: enactedOrSuggested, key: "TDD") {
+            if let tddMetric = InsulinMetric(from: enactedOrSuggested, key: "TDD")
+                ?? InsulinMetric(from: lastLoopRecord["enacted"], key: "TDD")
+            {
                 infoManager.updateInfoData(type: .tdd, value: tddMetric)
                 infoManager.updateInfoData(type: .tdd, value: tddMetric)
                 Storage.shared.lastTdd.value = tddMetric.value
                 Storage.shared.lastTdd.value = tddMetric.value
             }
             }

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

@@ -55,7 +55,7 @@ extension MainViewController {
             formatter.zeroFormattingBehavior = [.pad]
             formatter.zeroFormattingBehavior = [.pad]
 
 
             if let formattedDuration = formatter.string(from: secondsAgo) {
             if let formattedDuration = formatter.string(from: secondsAgo) {
-                infoManager.updateInfoData(type: .iage, value: formattedDuration)
+                infoManager.updateInfoData(type: .iage, value: formattedDuration, numericValue: secondsAgo / 86400)
             }
             }
         }
         }
     }
     }

+ 4 - 2
LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift

@@ -40,8 +40,10 @@ class NightscoutSocketManager {
             return
             return
         }
         }
 
 
-        let url = Storage.shared.url.value
-        let token = Storage.shared.token.value
+        // Sanitize defensively: values saved before this fix may still hold a stray
+        // whitespace/control char that crashes Socket.IO's URL builder on iOS 26.
+        let url = NightscoutUtils.sanitizeConnectionInput(Storage.shared.url.value)
+        let token = NightscoutUtils.sanitizeConnectionInput(Storage.shared.token.value)
 
 
         guard !url.isEmpty else {
         guard !url.isEmpty else {
             disconnect()
             disconnect()

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

@@ -72,7 +72,7 @@ extension MainViewController {
             formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale
             formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale
 
 
             if let formattedDuration = formatter.string(from: secondsAgo) {
             if let formattedDuration = formatter.string(from: secondsAgo) {
-                infoManager.updateInfoData(type: .sage, value: formattedDuration)
+                infoManager.updateInfoData(type: .sage, value: formattedDuration, numericValue: secondsAgo / 86400)
             }
             }
         }
         }
     }
     }

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

@@ -2,7 +2,6 @@
 // BGCheck.swift
 // BGCheck.swift
 
 
 import Foundation
 import Foundation
-import UIKit
 
 
 extension MainViewController {
 extension MainViewController {
     // NS BG Check Response Processor
     // NS BG Check Response Processor

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

@@ -10,6 +10,7 @@ extension MainViewController {
         carbData.removeAll()
         carbData.removeAll()
         var lastFoundIndex = 0
         var lastFoundIndex = 0
         var lastFoundBolus = 0
         var lastFoundBolus = 0
+        var lastFoundSmb = 0
 
 
         for currentEntry in entries.reversed() {
         for currentEntry in entries.reversed() {
             var carbDate: String
             var carbDate: String
@@ -35,7 +36,10 @@ extension MainViewController {
                 let bolusTime = findNearestBolusbyTime(timeWithin: 300, needle: dateTimeStamp, haystack: bolusData, startingIndex: lastFoundBolus)
                 let bolusTime = findNearestBolusbyTime(timeWithin: 300, needle: dateTimeStamp, haystack: bolusData, startingIndex: lastFoundBolus)
                 lastFoundBolus = bolusTime.foundIndex
                 lastFoundBolus = bolusTime.foundIndex
 
 
-                offset = bolusTime.offset ? 70 : 20
+                let smbTime = findNearestBolusbyTime(timeWithin: 300, needle: dateTimeStamp, haystack: smbData, startingIndex: lastFoundSmb)
+                lastFoundSmb = smbTime.foundIndex
+
+                offset = (bolusTime.offset || smbTime.offset) ? 70 : 20
             }
             }
 
 
             if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (3600 * Storage.shared.predictionToLoad.value)) {
             if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (3600 * Storage.shared.predictionToLoad.value)) {
@@ -81,7 +85,7 @@ extension MainViewController {
         }
         }
 
 
         let resultString = String(format: "%.0f", totalCarbs)
         let resultString = String(format: "%.0f", totalCarbs)
-        infoManager.updateInfoData(type: .carbsToday, value: resultString)
+        infoManager.updateInfoData(type: .carbsToday, value: resultString, numericValue: totalCarbs)
         Storage.shared.lastCarbsToday.value = totalCarbs
         Storage.shared.lastCarbsToday.value = totalCarbs
     }
     }
 }
 }

+ 1 - 1
LoopFollow/Controllers/Nightscout/Treatments/InsulinCartridgeChange.swift

@@ -9,7 +9,7 @@ extension MainViewController {
             updateIage(data: entries)
             updateIage(data: entries)
         } else if let iage = currentIage {
         } else if let iage = currentIage {
             updateIage(data: [iage])
             updateIage(data: [iage])
-        } else if Storage.shared.infoVisible.value[InfoType.iage.rawValue] {
+        } else if Storage.shared.infoDisplayItems.value.isVisible(.iage) {
             webLoadNSIage()
             webLoadNSIage()
         }
         }
     }
     }

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

@@ -2,7 +2,6 @@
 // Notes.swift
 // Notes.swift
 
 
 import Foundation
 import Foundation
-import UIKit
 
 
 extension MainViewController {
 extension MainViewController {
     // NS Note Response Processor
     // NS Note Response Processor

+ 33 - 13
LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift

@@ -2,12 +2,12 @@
 // Overrides.swift
 // Overrides.swift
 
 
 import Foundation
 import Foundation
-import UIKit
 
 
 extension MainViewController {
 extension MainViewController {
     func processNSOverrides(entries: [[String: AnyObject]]) {
     func processNSOverrides(entries: [[String: AnyObject]]) {
         overrideGraphData.removeAll()
         overrideGraphData.removeAll()
         var activeOverrideNote: String?
         var activeOverrideNote: String?
+        var activeOverrideEndAt: TimeInterval?
 
 
         let sorted = entries.sorted { lhs, rhs in
         let sorted = entries.sorted { lhs, rhs in
             guard
             guard
@@ -36,19 +36,28 @@ extension MainViewController {
 
 
             let start = max(startDate.timeIntervalSince1970, graphHorizon)
             let start = max(startDate.timeIntervalSince1970, graphHorizon)
 
 
-            var end: TimeInterval
-            if (e["durationType"] as? String) == "indefinite" { // Only for Loop overrides
-                end = maxEndDate
-            } else {
-                end = start + (e["duration"] as? Double ?? 5) * 60
-            }
+            let nextStart: TimeInterval? = {
+                guard i + 1 < sorted.count,
+                      let nextDateStr = (sorted[i + 1]["timestamp"] as? String) ?? (sorted[i + 1]["created_at"] as? String)
+                else { return nil }
+                return NightscoutUtils.parseDate(nextDateStr)?.timeIntervalSince1970
+            }()
+
+            let durationSeconds = (e["duration"] as? Double ?? 5) * 60
+            // Loop marks indefinite overrides explicitly; Trio represents them
+            // as a ~30-day duration. Treat a week or longer as indefinite.
+            let isIndefinite = (e["durationType"] as? String) == "indefinite"
+                || durationSeconds >= 7 * 24 * 3600
 
 
-            if i + 1 < sorted.count,
-               let nextDateStr = (sorted[i + 1]["timestamp"] as? String) ?? (sorted[i + 1]["created_at"] as? String),
-               let nextStart = NightscoutUtils.parseDate(nextDateStr)?
-               .timeIntervalSince1970
-            {
+            var end: TimeInterval = isIndefinite ? maxEndDate : start + durationSeconds
+
+            // True end for countdown display: based on the raw start and never
+            // clamped to the graph edge; nil while indefinite.
+            var trueEnd: TimeInterval? = isIndefinite ? nil : startDate.timeIntervalSince1970 + durationSeconds
+
+            if let nextStart = nextStart {
                 end = min(end, nextStart - 60) // avoid overlapping overrides
                 end = min(end, nextStart - 60) // avoid overlapping overrides
+                trueEnd = trueEnd.map { min($0, nextStart - 60) }
             }
             }
 
 
             end = min(end, maxEndDate)
             end = min(end, maxEndDate)
@@ -69,17 +78,24 @@ extension MainViewController {
                     return [lo, hi]
                     return [lo, hi]
                 }(),
                 }(),
                 enteredBy: e["enteredBy"] as? String ?? "unknown",
                 enteredBy: e["enteredBy"] as? String ?? "unknown",
-                reason: e["reason"] as? String ?? "",
+                // Loop stores the override name in "reason"; Trio stores it in
+                // "notes". Prefer notes so Trio override names surface on the
+                // graph, matching the info table and treatments list.
+                reason: (e["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
+                    ?? (e["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
+                    ?? "",
                 sgv: -20
                 sgv: -20
             )
             )
             overrideGraphData.append(dot)
             overrideGraphData.append(dot)
 
 
             if now >= start, now < end {
             if now >= start, now < end {
                 activeOverrideNote = e["notes"] as? String ?? e["reason"] as? String
                 activeOverrideNote = e["notes"] as? String ?? e["reason"] as? String
+                activeOverrideEndAt = trueEnd
             }
             }
         }
         }
 
 
         Observable.shared.override.value = activeOverrideNote
         Observable.shared.override.value = activeOverrideNote
+        Observable.shared.overrideEndAt.value = activeOverrideEndAt
         if Storage.shared.device.value != "Loop" {
         if Storage.shared.device.value != "Loop" {
             if let note = activeOverrideNote {
             if let note = activeOverrideNote {
                 infoManager.updateInfoData(type: .override, value: note)
                 infoManager.updateInfoData(type: .override, value: note)
@@ -90,5 +106,9 @@ extension MainViewController {
         if Storage.shared.graphOtherTreatments.value {
         if Storage.shared.graphOtherTreatments.value {
             updateOverrideGraph()
             updateOverrideGraph()
         }
         }
+
+        #if !targetEnvironment(macCatalyst)
+            LiveActivityManager.shared.refreshFromCurrentState(reason: "overrideChanged")
+        #endif
     }
     }
 }
 }

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

@@ -35,7 +35,6 @@ extension MainViewController {
 
 
         if Storage.shared.graphBolus.value {
         if Storage.shared.graphBolus.value {
             updateSmbGraph()
             updateSmbGraph()
-            updateChartRenderers()
         }
         }
     }
     }
 }
 }

+ 10 - 2
LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift

@@ -3,13 +3,13 @@
 
 
 import Foundation
 import Foundation
 import HealthKit
 import HealthKit
-import UIKit
 
 
 extension MainViewController {
 extension MainViewController {
     // NS Temporary Target Response Processor
     // NS Temporary Target Response Processor
     func processNSTemporaryTarget(entries: [[String: AnyObject]]) {
     func processNSTemporaryTarget(entries: [[String: AnyObject]]) {
         tempTargetGraphData.removeAll()
         tempTargetGraphData.removeAll()
         var activeTempTarget: Int?
         var activeTempTarget: Int?
+        var activeTempTargetEndAt: TimeInterval?
 
 
         for (index, currentEntry) in entries.reversed().enumerated() {
         for (index, currentEntry) in entries.reversed().enumerated() {
             guard let dateStr = currentEntry["timestamp"] as? String ?? currentEntry["created_at"] as? String else { continue }
             guard let dateStr = currentEntry["timestamp"] as? String ?? currentEntry["created_at"] as? String else { continue }
@@ -28,6 +28,7 @@ extension MainViewController {
                 if let activeIndex = tempTargetGraphData.lastIndex(where: { $0.endDate > dateTimeStamp }) {
                 if let activeIndex = tempTargetGraphData.lastIndex(where: { $0.endDate > dateTimeStamp }) {
                     tempTargetGraphData[activeIndex].endDate = dateTimeStamp
                     tempTargetGraphData[activeIndex].endDate = dateTimeStamp
                     activeTempTarget = nil
                     activeTempTarget = nil
+                    activeTempTargetEndAt = nil
                 }
                 }
                 continue
                 continue
             }
             }
@@ -59,12 +60,14 @@ extension MainViewController {
             let currentTime = Date().timeIntervalSince1970
             let currentTime = Date().timeIntervalSince1970
             if currentTime < endDate {
             if currentTime < endDate {
                 activeTempTarget = Int(targetValue!)
                 activeTempTarget = Int(targetValue!)
+                // Trio represents indefinite temp targets as a ~30-day duration;
+                // treat a week or longer as indefinite (no end to display).
+                activeTempTargetEndAt = duration >= 7 * 24 * 3600 ? nil : endDate
             }
             }
         }
         }
 
 
         if Storage.shared.graphOtherTreatments.value {
         if Storage.shared.graphOtherTreatments.value {
             updateTempTargetGraph()
             updateTempTargetGraph()
-            updateChartRenderers()
         }
         }
 
 
         if let target = activeTempTarget {
         if let target = activeTempTarget {
@@ -74,5 +77,10 @@ extension MainViewController {
         } else {
         } else {
             Observable.shared.tempTarget.value = nil
             Observable.shared.tempTarget.value = nil
         }
         }
+        Observable.shared.tempTargetEndAt.value = activeTempTargetEndAt
+
+        #if !targetEnvironment(macCatalyst)
+            LiveActivityManager.shared.refreshFromCurrentState(reason: "tempTargetChanged")
+        #endif
     }
     }
 }
 }

+ 20 - 25
LoopFollow/Controllers/StatsDisplayView.swift

@@ -15,7 +15,8 @@ struct StatsDisplayView: View {
                 pieRange: model.pieRange,
                 pieRange: model.pieRange,
                 pieHigh: model.pieHigh
                 pieHigh: model.pieHigh
             )
             )
-            .frame(width: 100, height: 100)
+            .frame(width: 80, height: 80)
+            .padding(.leading, 8)
 
 
             VStack(spacing: 10) {
             VStack(spacing: 10) {
                 HStack {
                 HStack {
@@ -48,37 +49,31 @@ struct StatsDisplayView: View {
     }
     }
 }
 }
 
 
-struct StatsPieChartView: UIViewRepresentable {
+struct StatsPieChartView: View {
     var pieLow: Double
     var pieLow: Double
     var pieRange: Double
     var pieRange: Double
     var pieHigh: Double
     var pieHigh: Double
 
 
-    func makeUIView(context _: Context) -> PieChartView {
-        let chart = PieChartView()
-        chart.legend.enabled = false
-        chart.drawEntryLabelsEnabled = false
-        chart.drawHoleEnabled = false
-        chart.rotationEnabled = false
-        chart.isUserInteractionEnabled = false
-        chart.backgroundColor = .clear
-        return chart
+    private struct Slice: Identifiable {
+        let id: String
+        let value: Double
+        let color: Color
     }
     }
 
 
-    func updateUIView(_ chart: PieChartView, context _: Context) {
-        let entries = [
-            PieChartDataEntry(value: max(pieLow, 0.1)),
-            PieChartDataEntry(value: max(pieRange, 0.1)),
-            PieChartDataEntry(value: max(pieHigh, 0.1)),
+    private var slices: [Slice] {
+        [
+            Slice(id: "low", value: max(pieLow, 0.1), color: .red),
+            Slice(id: "range", value: max(pieRange, 0.1), color: .green),
+            Slice(id: "high", value: max(pieHigh, 0.1), color: .yellow),
         ]
         ]
+    }
 
 
-        let dataSet = PieChartDataSet(entries: entries, label: "")
-        dataSet.drawIconsEnabled = false
-        dataSet.sliceSpace = 0
-        dataSet.drawValuesEnabled = false
-        dataSet.valueLineWidth = 0
-        dataSet.formLineWidth = 0
-        dataSet.colors = [.systemRed, .systemGreen, .systemYellow]
-
-        chart.data = PieChartData(dataSet: dataSet)
+    var body: some View {
+        Chart(slices) { slice in
+            SectorMark(angle: .value("share", slice.value))
+                .foregroundStyle(slice.color)
+        }
+        .chartLegend(.hidden)
+        .allowsHitTesting(false)
     }
     }
 }
 }

+ 2 - 2
LoopFollow/Controllers/Timers.swift

@@ -2,10 +2,10 @@
 // Timers.swift
 // Timers.swift
 
 
 import Foundation
 import Foundation
-import UIKit
 
 
 extension MainViewController {
 extension MainViewController {
     func startGraphNowTimer(time: TimeInterval = 60) {
     func startGraphNowTimer(time: TimeInterval = 60) {
+        graphNowTimer.invalidate()
         graphNowTimer = Timer.scheduledTimer(timeInterval: time,
         graphNowTimer = Timer.scheduledTimer(timeInterval: time,
                                              target: self,
                                              target: self,
                                              selector: #selector(MainViewController.graphNowTimerDidEnd(_:)),
                                              selector: #selector(MainViewController.graphNowTimerDidEnd(_:)),
@@ -14,6 +14,6 @@ extension MainViewController {
     }
     }
 
 
     @objc func graphNowTimerDidEnd(_: Timer) {
     @objc func graphNowTimerDidEnd(_: Timer) {
-        createVerticalLines()
+        chartModel.rebuild()
     }
     }
 }
 }

+ 2 - 8
LoopFollow/Extensions/EKEventStore+Extensions.swift

@@ -6,14 +6,8 @@ import Foundation
 
 
 extension EKEventStore {
 extension EKEventStore {
     func requestCalendarAccess(completion: @escaping (Bool, Error?) -> Void) {
     func requestCalendarAccess(completion: @escaping (Bool, Error?) -> Void) {
-        if #available(iOS 17, *) {
-            requestFullAccessToEvents { granted, error in
-                completion(granted, error)
-            }
-        } else {
-            requestAccess(to: .event) { granted, error in
-                completion(granted, error)
-            }
+        requestFullAccessToEvents { granted, error in
+            completion(granted, error)
         }
         }
     }
     }
 }
 }

+ 0 - 31
LoopFollow/Extensions/HKQuantity+AnyConvertible.swift

@@ -1,31 +0,0 @@
-// LoopFollow
-// HKQuantity+AnyConvertible.swift
-
-import HealthKit
-
-class HKQuantityWrapper: AnyConvertible {
-    let quantity: HKQuantity
-
-    init(quantity: HKQuantity) {
-        self.quantity = quantity
-    }
-
-    func toAny() -> Any {
-        return ["unit": UserDefaultsRepository.getPreferredUnit().unitString,
-                "value": quantity.doubleValue(for: UserDefaultsRepository.getPreferredUnit())]
-    }
-
-    static func fromAny(_ anyValue: Any) -> HKQuantityWrapper? {
-        // Convert dictionary back to HKQuantity
-        guard let dict = anyValue as? [String: Any],
-              let unitString = dict["unit"] as? String,
-              let value = dict["value"] as? Double
-        else {
-            return nil
-        }
-
-        let unit = HKUnit(from: unitString)
-        let quantity = HKQuantity(unit: unit, doubleValue: value)
-        return HKQuantityWrapper(quantity: quantity)
-    }
-}

+ 0 - 130
LoopFollow/Helpers/AnyConvertible.swift

@@ -1,130 +0,0 @@
-// LoopFollow
-// AnyConvertible.swift
-
-import Foundation
-
-/// A type that can be converted to/from Any
-protocol AnyConvertible {
-    func toAny() -> Any
-    static func fromAny(_ anyValue: Any) -> Self?
-}
-
-// common type implementations
-extension Bool: AnyConvertible {
-    func toAny() -> Any {
-        return self
-    }
-
-    static func fromAny(_ anyValue: Any) -> Bool? {
-        return anyValue as? Bool
-    }
-}
-
-extension String: AnyConvertible {
-    func toAny() -> Any {
-        return self
-    }
-
-    static func fromAny(_ anyValue: Any) -> String? {
-        return anyValue as? String
-    }
-}
-
-extension Int: AnyConvertible {
-    func toAny() -> Any {
-        return self
-    }
-
-    static func fromAny(_ anyValue: Any) -> Int? {
-        return anyValue as? Int
-    }
-}
-
-extension Float: AnyConvertible {
-    func toAny() -> Any {
-        return self
-    }
-
-    static func fromAny(_ anyValue: Any) -> Float? {
-        return anyValue as? Float
-    }
-}
-
-extension Double: AnyConvertible {
-    func toAny() -> Any {
-        return self
-    }
-
-    static func fromAny(_ anyValue: Any) -> Double? {
-        return anyValue as? Double
-    }
-}
-
-extension Date: AnyConvertible {
-    func toAny() -> Any {
-        return self
-    }
-
-    static func fromAny(_ anyValue: Any) -> Date? {
-        return anyValue as? Date
-    }
-}
-
-extension Data: AnyConvertible {
-    func toAny() -> Any {
-        return self
-    }
-
-    static func fromAny(_ anyValue: Any) -> Data? {
-        return anyValue as? Data
-    }
-}
-
-extension UUID: AnyConvertible {
-    func toAny() -> Any {
-        return uuidString
-    }
-
-    static func fromAny(_ anyValue: Any) -> UUID? {
-        guard let uuidString = anyValue as? String else {
-            return nil
-        }
-
-        return UUID(uuidString: uuidString)
-    }
-}
-
-// extension Array: AnyConvertible {
-//    func toAny() -> Any {
-//        return self
-//    }
-//
-//    static func fromAny(_ anyValue: Any) -> Array? {
-//        return anyValue as? Array
-//    }
-// }
-
-extension Array: AnyConvertible where Element: AnyConvertible {
-    func toAny() -> Any {
-        return map { $0.toAny() }
-    }
-
-    static func fromAny(_ anyValue: Any) -> Array? {
-        return (anyValue as? Array)?.compactMap { Element.fromAny($0) }
-    }
-}
-
-extension Optional: AnyConvertible where Wrapped: AnyConvertible {
-    func toAny() -> Any {
-        switch self {
-        case let .some(value):
-            return value.toAny()
-        case .none:
-            return self as Any
-        }
-    }
-
-    static func fromAny(_ anyValue: Any) -> Wrapped?? {
-        return Wrapped.fromAny(anyValue)
-    }
-}

+ 0 - 2
LoopFollow/Helpers/AppConstants.swift

@@ -5,8 +5,6 @@ import Foundation
 
 
 // Class that contains general constants used in different classes
 // Class that contains general constants used in different classes
 class AppConstants {
 class AppConstants {
-    static let APP_GROUP_ID = "group.com.$(unique_id).LoopFollow"
-
     /// Extracts the app suffix from the bundle identifier
     /// Extracts the app suffix from the bundle identifier
     /// Bundle identifier format: com.$(unique_id).LoopFollow$(app_suffix)
     /// Bundle identifier format: com.$(unique_id).LoopFollow$(app_suffix)
     /// Returns the suffix part (e.g., "2" for "com.example.LoopFollow2")
     /// Returns the suffix part (e.g., "2" for "com.example.LoopFollow2")

+ 1 - 1
LoopFollow/Helpers/BackgroundRefreshManager.swift

@@ -2,7 +2,7 @@
 // BackgroundRefreshManager.swift
 // BackgroundRefreshManager.swift
 
 
 import BackgroundTasks
 import BackgroundTasks
-import UIKit
+import Foundation
 
 
 class BackgroundRefreshManager {
 class BackgroundRefreshManager {
     static let shared = BackgroundRefreshManager()
     static let shared = BackgroundRefreshManager()

+ 0 - 216
LoopFollow/Helpers/Chart.swift

@@ -1,216 +0,0 @@
-// LoopFollow
-// Chart.swift
-
-import Charts
-import Foundation
-
-final class OverrideFillFormatter: FillFormatter {
-    func getFillLinePosition(dataSet: Charts.LineChartDataSetProtocol, dataProvider _: Charts.LineChartDataProvider) -> CGFloat {
-        return CGFloat(dataSet.entryForIndex(0)!.y)
-        // return 375
-    }
-}
-
-final class basalFillFormatter: FillFormatter {
-    func getFillLinePosition(dataSet _: Charts.LineChartDataSetProtocol, dataProvider _: Charts.LineChartDataProvider) -> CGFloat {
-        return 0
-    }
-}
-
-final class ChartXValueFormatter: AxisValueFormatter {
-    func stringForValue(_ value: Double, axis _: AxisBase?) -> String {
-        let dateFormatter = DateFormatter()
-        // let timezoneOffset = TimeZone.current.secondsFromGMT()
-        // let epochTimezoneOffset = value + Double(timezoneOffset)
-        if dateTimeUtils.is24Hour() {
-            dateFormatter.setLocalizedDateFormatFromTemplate("HH:mm")
-        } else {
-            dateFormatter.setLocalizedDateFormatFromTemplate("hh:mm")
-        }
-
-        if Storage.shared.graphTimeZoneEnabled.value,
-           let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
-        {
-            dateFormatter.timeZone = tz
-        }
-
-        // let date = Date(timeIntervalSince1970: epochTimezoneOffset)
-        let date = Date(timeIntervalSince1970: value)
-        let formattedDate = dateFormatter.string(from: date)
-
-        return formattedDate
-    }
-}
-
-final class ChartYDataValueFormatter: ValueFormatter {
-    func stringForValue(_: Double, entry: ChartDataEntry, dataSetIndex _: Int, viewPortHandler _: ViewPortHandler?) -> String {
-        guard let text = entry.data as? String else { return "" }
-        // Treatment entries store "label\r\rpillText" — extract only the label portion.
-        if let range = text.range(of: "\r\r") {
-            return String(text[..<range.lowerBound])
-        }
-        return text
-    }
-}
-
-final class ChartYOverrideValueFormatter: ValueFormatter {
-    func stringForValue(_: Double, entry: ChartDataEntry, dataSetIndex _: Int, viewPortHandler _: ViewPortHandler?) -> String {
-        if entry.data != nil {
-            return entry.data as? String ?? ""
-        } else {
-            return ""
-        }
-    }
-}
-
-final class ChartYMMOLValueFormatter: AxisValueFormatter {
-    func stringForValue(_ value: Double, axis _: AxisBase?) -> String {
-        return Localizer.toDisplayUnits(String(value))
-    }
-}
-
-class PillMarker: MarkerImage {
-    private(set) var color: UIColor
-    private(set) var font: UIFont
-    private(set) var textColor: UIColor
-    private var labelText: String = ""
-    private var attrs: [NSAttributedString.Key: AnyObject]!
-
-    static let formatter: DateComponentsFormatter = {
-        let f = DateComponentsFormatter()
-        f.allowedUnits = [.minute, .second]
-        f.unitsStyle = .short
-        return f
-    }()
-
-    init(color: UIColor, font: UIFont, textColor: UIColor) {
-        self.color = color
-        self.font = font
-        self.textColor = textColor
-
-        let paragraphStyle = NSMutableParagraphStyle()
-        paragraphStyle.alignment = .center
-        attrs = [.font: font, .paragraphStyle: paragraphStyle, .foregroundColor: textColor, .baselineOffset: NSNumber(value: -4)]
-        super.init()
-    }
-
-    override func draw(context: CGContext, point: CGPoint) {
-        // custom padding around text
-        let labelWidth = labelText.size(withAttributes: attrs).width + 10
-        // if you modify labelHeigh you will have to tweak baselineOffset in attrs
-        let labelHeight = labelText.size(withAttributes: attrs).height + 4
-
-        // place pill above the marker, centered along x
-        var rectangle = CGRect(x: point.x, y: point.y, width: labelWidth, height: labelHeight)
-        rectangle.origin.x -= rectangle.width / 2.0
-        var spacing: CGFloat = 20
-        if point.y < 300 { spacing = -40 }
-
-        rectangle.origin.y -= rectangle.height + spacing
-
-        // rounded rect
-        let clipPath = UIBezierPath(roundedRect: rectangle, cornerRadius: 6.0).cgPath
-        context.addPath(clipPath)
-        context.setFillColor(UIColor.secondarySystemBackground.cgColor)
-        context.setStrokeColor(UIColor.label.cgColor)
-        context.closePath()
-        context.drawPath(using: .fillStroke)
-
-        // add the text
-        labelText.draw(with: rectangle, options: .usesLineFragmentOrigin, attributes: attrs, context: nil)
-    }
-
-    private static let pillSeparator = "\r\r"
-
-    override func refreshContent(entry: ChartDataEntry, highlight _: Highlight) {
-        if let text = entry.data as? String {
-            // Treatment entries use pillSeparator to separate the value label from the marker text.
-            if let range = text.range(of: Self.pillSeparator) {
-                labelText = String(text[range.upperBound...])
-            } else {
-                labelText = text
-            }
-        } else {
-            labelText = String(entry.y)
-        }
-    }
-
-    private func customString(_ value: Double) -> String {
-        let formattedString = PillMarker.formatter.string(from: TimeInterval(value))!
-        // using this to convert the left axis values formatting, ie 2 min
-        return "\(formattedString)"
-    }
-}
-
-// MARK: - Cone of Uncertainty
-
-class ConeChartDataEntry: ChartDataEntry {
-    var yMin: Double = 0.0
-    var yMax: Double = 0.0
-
-    required init() {
-        super.init()
-    }
-
-    init(x: Double, yMin: Double, yMax: Double) {
-        self.yMin = yMin
-        self.yMax = yMax
-        super.init(x: x, y: yMax)
-    }
-
-    override func copy(with _: NSZone? = nil) -> Any {
-        let copy = ConeChartDataEntry(x: x, yMin: yMin, yMax: yMax)
-        copy.data = data
-        return copy
-    }
-}
-
-class ConeOfUncertaintyRenderer: LineChartRenderer {
-    let coneDataSetIndex: Int
-
-    init(dataProvider: LineChartDataProvider?, animator: Animator?, viewPortHandler: ViewPortHandler?, coneDataSetIndex: Int) {
-        self.coneDataSetIndex = coneDataSetIndex
-        super.init(dataProvider: dataProvider!, animator: animator!, viewPortHandler: viewPortHandler!)
-    }
-
-    override func drawExtras(context: CGContext) {
-        super.drawExtras(context: context)
-
-        guard let dataProvider = dataProvider,
-              dataProvider.lineData?.dataSets.count ?? 0 > coneDataSetIndex,
-              let lineDataSet = dataProvider.lineData?.dataSets[coneDataSetIndex] as? LineChartDataSet,
-              lineDataSet.entryCount > 1 else { return }
-
-        let trans = dataProvider.getTransformer(forAxis: lineDataSet.axisDependency)
-        let phaseY = animator.phaseY
-
-        var upperPoints = [CGPoint]()
-        var lowerPoints = [CGPoint]()
-
-        for i in 0 ..< lineDataSet.entryCount {
-            guard let entry = lineDataSet.entryForIndex(i) as? ConeChartDataEntry else { continue }
-            upperPoints.append(trans.pixelForValues(x: entry.x, y: entry.yMax * phaseY))
-            lowerPoints.append(trans.pixelForValues(x: entry.x, y: entry.yMin * phaseY))
-        }
-
-        guard upperPoints.count > 1 else { return }
-
-        context.saveGState()
-
-        let path = CGMutablePath()
-        path.move(to: upperPoints[0])
-        for i in 1 ..< upperPoints.count {
-            path.addLine(to: upperPoints[i])
-        }
-        for i in stride(from: lowerPoints.count - 1, through: 0, by: -1) {
-            path.addLine(to: lowerPoints[i])
-        }
-        path.closeSubpath()
-
-        context.addPath(path)
-        context.setFillColor(UIColor.systemBlue.withAlphaComponent(0.4).cgColor)
-        context.fillPath()
-
-        context.restoreGState()
-    }
-}

+ 184 - 3
LoopFollow/Helpers/NightscoutUtils.swift

@@ -1,6 +1,7 @@
 // LoopFollow
 // LoopFollow
 // NightscoutUtils.swift
 // NightscoutUtils.swift
 
 
+import CryptoKit
 import Foundation
 import Foundation
 
 
 class NightscoutUtils {
 class NightscoutUtils {
@@ -44,6 +45,7 @@ class NightscoutUtils {
         case iage = "Insulin Change"
         case iage = "Insulin Change"
         case temporaryOverride = "Temporary Override"
         case temporaryOverride = "Temporary Override"
         case temporaryOverrideCancel = "Temporary Override Cancel"
         case temporaryOverrideCancel = "Temporary Override Cancel"
+        case dbSize
 
 
         var endpoint: String {
         var endpoint: String {
             switch self {
             switch self {
@@ -57,6 +59,8 @@ class NightscoutUtils {
                 return "/api/v1/devicestatus.json"
                 return "/api/v1/devicestatus.json"
             case .temporaryOverride, .temporaryOverrideCancel:
             case .temporaryOverride, .temporaryOverrideCancel:
                 return "/api/v2/notifications/loop"
                 return "/api/v2/notifications/loop"
+            case .dbSize:
+                return "/api/v2/properties/dbsize"
             }
             }
         }
         }
     }
     }
@@ -162,6 +166,17 @@ class NightscoutUtils {
         return request
         return request
     }
     }
 
 
+    // Strip whitespace/newlines/control characters that can sneak in via paste.
+    // Neither a URL nor a token may legally contain them, and a stray one breaks
+    // WebSocket connect (invalid percent-encoded query traps on iOS 26) or makes
+    // URL parsing fall back to a lossy cleanup that mangles the address.
+    static func sanitizeConnectionInput(_ input: String) -> String {
+        input.unicodeScalars
+            .filter { !CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.controlCharacters.contains($0) }
+            .map(String.init)
+            .joined()
+    }
+
     static func constructURL(baseURL: String, token: String?, endpoint: String, parameters: [String: String]) -> URL? {
     static func constructURL(baseURL: String, token: String?, endpoint: String, parameters: [String: String]) -> URL? {
         var components = URLComponents(string: baseURL)
         var components = URLComponents(string: baseURL)
         components?.path = endpoint
         components?.path = endpoint
@@ -176,7 +191,10 @@ class NightscoutUtils {
             queryItems.append(URLQueryItem(name: key, value: value))
             queryItems.append(URLQueryItem(name: key, value: value))
         }
         }
 
 
-        components?.queryItems = queryItems
+        // Assigning an empty array would still append a bare "?" to the URL.
+        if !queryItems.isEmpty {
+            components?.queryItems = queryItems
+        }
 
 
         return components?.url
         return components?.url
     }
     }
@@ -240,8 +258,14 @@ class NightscoutUtils {
                     completion(.unknown, nil, false, false)
                     completion(.unknown, nil, false, false)
                 }
                 }
             } else {
             } else {
-                if let _ = error {
-                    completion(.siteNotFound, nil, false, false)
+                if let urlError = error as? URLError {
+                    switch urlError.code {
+                    case .notConnectedToInternet, .networkConnectionLost, .timedOut, .dataNotAllowed:
+                        completion(.networkError, nil, false, false)
+                    default:
+                        // cannotFindHost, cannotConnectToHost, etc. — the address is wrong or the site is down.
+                        completion(.siteNotFound, nil, false, false)
+                    }
                 } else {
                 } else {
                     completion(.networkError, nil, false, false)
                     completion(.networkError, nil, false, false)
                 }
                 }
@@ -385,6 +409,163 @@ class NightscoutUtils {
         return responseString
         return responseString
     }
     }
 
 
+    // MARK: - Token Provisioning
+
+    /// Name of the Nightscout authorization subject LoopFollow creates when a
+    /// user provisions a token from their API secret.
+    static let provisionedSubjectName = "LoopFollow"
+
+    private struct AuthSubject: Decodable {
+        let id: String?
+        let name: String?
+        let accessToken: String?
+        let roles: [String]?
+
+        enum CodingKeys: String, CodingKey {
+            case id = "_id"
+            case name, accessToken, roles
+        }
+    }
+
+    /// Creates (or reuses) a read-only Nightscout access token using the site's
+    /// API secret. The secret only authorizes these requests and is never
+    /// persisted. Returns the access token for a `readable` subject named
+    /// `provisionedSubjectName`.
+    ///
+    /// The full API secret authenticates as Nightscout's `admin` role (the `*`
+    /// permission), which includes `admin:api:subjects:create`.
+    ///
+    /// Nightscout serves the subjects list from an in-memory cache that doesn't
+    /// refresh promptly after a write, so a freshly-created subject (and its
+    /// token) can't be read back reliably right after creating it. Instead we
+    /// derive the token locally: it's a pure function of the subject's `_id`
+    /// (returned by the create call) and the API secret. See `accessToken(for:)`.
+    static func provisionReadOnlyToken(url: String, secret: String) async throws -> String {
+        let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedURL.isEmpty else { throw NightscoutError.emptyAddress }
+        guard let baseURL = URL(string: trimmedURL),
+              trimmedURL.hasPrefix("http://") || trimmedURL.hasPrefix("https://")
+        else { throw NightscoutError.invalidURL }
+
+        let secretHash = sha1Hex(secret)
+
+        // Reuse an existing subject if one is already visible (idempotent re-runs
+        // once the site's cache has caught up).
+        if let existing = try await fetchProvisionedToken(baseURL: baseURL, secretHash: secretHash) {
+            return existing
+        }
+
+        let id = try await createReadOnlySubject(baseURL: baseURL, secretHash: secretHash)
+        return accessToken(forName: provisionedSubjectName, id: id, secretHash: secretHash)
+    }
+
+    /// Returns `true` when `secret` authenticates as the site's API secret.
+    /// Read-only: it probes an admin-gated endpoint with the hashed secret and
+    /// creates nothing, so it's safe to call just to find out what the user pasted.
+    static func verifyAPISecret(url: String, secret: String) async -> Bool {
+        let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
+        let trimmedSecret = secret.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedSecret.isEmpty,
+              let baseURL = URL(string: trimmedURL),
+              trimmedURL.hasPrefix("http://") || trimmedURL.hasPrefix("https://")
+        else { return false }
+
+        let endpoint = baseURL.appendingPathComponent("api/v2/authorization/subjects")
+        var request = URLRequest(url: endpoint)
+        request.httpMethod = "GET"
+        request.setValue(sha1Hex(trimmedSecret), forHTTPHeaderField: "api-secret")
+        request.setValue("application/json", forHTTPHeaderField: "Accept")
+        request.cachePolicy = .reloadIgnoringLocalCacheData
+
+        do {
+            let (_, response) = try await URLSession.shared.data(for: request)
+            return (response as? HTTPURLResponse)?.statusCode == 200
+        } catch {
+            return false
+        }
+    }
+
+    private static func fetchProvisionedToken(baseURL: URL, secretHash: String) async throws -> String? {
+        let url = baseURL.appendingPathComponent("api/v2/authorization/subjects")
+        var request = URLRequest(url: url)
+        request.httpMethod = "GET"
+        request.setValue(secretHash, forHTTPHeaderField: "api-secret")
+        request.setValue("application/json", forHTTPHeaderField: "Accept")
+        request.cachePolicy = .reloadIgnoringLocalCacheData
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        try validateProvisioningResponse(response)
+
+        let subjects = try JSONDecoder().decode([AuthSubject].self, from: data)
+        return subjects.first(where: { $0.name == provisionedSubjectName })?.accessToken
+    }
+
+    /// Creates the subject and returns its `_id`.
+    private static func createReadOnlySubject(baseURL: URL, secretHash: String) async throws -> String {
+        let url = baseURL.appendingPathComponent("api/v2/authorization/subjects")
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue(secretHash, forHTTPHeaderField: "api-secret")
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.httpBody = try JSONSerialization.data(withJSONObject: [
+            "name": provisionedSubjectName,
+            "roles": ["readable"],
+        ])
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        try validateProvisioningResponse(response)
+
+        // Nightscout returns the created subject wrapped in an array
+        // (`[{…}]`) on current versions, but a bare object on some older ones,
+        // so accept either shape.
+        let subject = try decodeCreatedSubject(from: data)
+        guard let id = subject.id, !id.isEmpty else { throw NightscoutError.unknown }
+        return id
+    }
+
+    private static func decodeCreatedSubject(from data: Data) throws -> AuthSubject {
+        let decoder = JSONDecoder()
+        if let array = try? decoder.decode([AuthSubject].self, from: data) {
+            guard let first = array.first else { throw NightscoutError.unknown }
+            return first
+        }
+        return try decoder.decode(AuthSubject.self, from: data)
+    }
+
+    /// Reproduces Nightscout's subject-token derivation (`lib/authorization`):
+    ///   abbrev = name lowercased, non-`\w` characters removed, first 10 chars
+    ///   digest = sha1( sha1Hex(apiSecret) + subjectId )
+    ///   token  = "\(abbrev)-\(digest[0..<16])"
+    private static func accessToken(forName name: String, id: String, secretHash: String) -> String {
+        let allowed = Set("abcdefghijklmnopqrstuvwxyz0123456789_")
+        let abbrev = String(name.lowercased().filter { allowed.contains($0) }.prefix(10))
+        let digest = sha1Hex(secretHash + id)
+        return abbrev + "-" + String(digest.prefix(16))
+    }
+
+    private static func validateProvisioningResponse(_ response: URLResponse) throws {
+        guard let http = response as? HTTPURLResponse else {
+            throw NightscoutError.networkError
+        }
+        switch http.statusCode {
+        case 200 ..< 300:
+            return
+        case 401, 403:
+            // The API secret was missing or wrong.
+            throw NightscoutError.invalidToken
+        case 404:
+            throw NightscoutError.siteNotFound
+        default:
+            throw NightscoutError.unknown
+        }
+    }
+
+    private static func sha1Hex(_ string: String) -> String {
+        Insecure.SHA1.hash(data: Data(string.utf8))
+            .map { String(format: "%02x", $0) }
+            .joined()
+    }
+
     static func extractErrorReason(from responseString: String) -> String {
     static func extractErrorReason(from responseString: String) -> String {
         // 1) Try to parse the entire string as JSON and return the "message"
         // 1) Try to parse the entire string as JSON and return the "message"
         if let data = responseString.data(using: .utf8) {
         if let data = responseString.data(using: .utf8) {

+ 30 - 0
LoopFollow/Helpers/NotificationAuthorization.swift

@@ -0,0 +1,30 @@
+// LoopFollow
+// NotificationAuthorization.swift
+
+import UserNotifications
+
+/// Requests notification authorization lazily, the first time the user opts into
+/// a feature that needs it (alarms). This keeps the system prompt off the very
+/// first launch so it doesn't front the onboarding flow.
+enum NotificationAuthorization {
+    /// Asks for authorization only when the user hasn't decided yet. Safe to call
+    /// repeatedly — it's a no-op once the status is determined. `completion` runs
+    /// on the main queue after the prompt is dismissed (or immediately when the
+    /// status was already determined), so a caller can wait for the system prompt
+    /// before moving on.
+    static func requestIfNeeded(completion: @escaping () -> Void = {}) {
+        let center = UNUserNotificationCenter.current()
+        center.getNotificationSettings { settings in
+            guard settings.authorizationStatus == .notDetermined else {
+                DispatchQueue.main.async { completion() }
+                return
+            }
+            center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
+                if !granted {
+                    LogManager.shared.log(category: .general, message: "User has declined notifications")
+                }
+                DispatchQueue.main.async { completion() }
+            }
+        }
+    }
+}

+ 0 - 17
LoopFollow/Helpers/ObservationToken.swift

@@ -1,17 +0,0 @@
-// LoopFollow
-// ObservationToken.swift
-
-import Foundation
-
-/// The token received by an observe when subscribes to its subject. The observer can cancel observation, so the subject will remove it from its observers list.
-class ObservationToken {
-    private let cancellationClosure: () -> Void
-
-    init(cancellationClosure: @escaping () -> Void) {
-        self.cancellationClosure = cancellationClosure
-    }
-
-    func cancel() {
-        cancellationClosure()
-    }
-}

+ 1 - 1
LoopFollow/Helpers/Telemetry.swift

@@ -348,7 +348,7 @@ struct TelemetryConsentView: View {
                 VStack(alignment: .leading, spacing: 16) {
                 VStack(alignment: .leading, spacing: 16) {
                     Text("You can choose to share anonymous information with the developers to help improve LoopFollow—such as app and iOS version, device type, which app you're following, and a few settings. Your health data, credentials, time zone, and logs remain on your device.")
                     Text("You can choose to share anonymous information with the developers to help improve LoopFollow—such as app and iOS version, device type, which app you're following, and a few settings. Your health data, credentials, time zone, and logs remain on your device.")
 
 
-                    Text("You can change this any time in Settings → Diagnostics.")
+                    Text("You can change this any time in Settings → General → Diagnostics.")
                         .font(.subheadline)
                         .font(.subheadline)
                         .foregroundColor(.secondary)
                         .foregroundColor(.secondary)
 
 

+ 10 - 2
LoopFollow/Helpers/Views/NavigationRow.swift

@@ -4,7 +4,8 @@
 import SwiftUI
 import SwiftUI
 
 
 struct NavigationRow<Value: Hashable>: View {
 struct NavigationRow<Value: Hashable>: View {
-    let title: LocalizedStringKey
+    let title: String
+    var subtitle: String? = nil
     let icon: String
     let icon: String
     var iconTint: Color = .white
     var iconTint: Color = .white
     let value: Value
     let value: Value
@@ -13,7 +14,14 @@ struct NavigationRow<Value: Hashable>: View {
         NavigationLink(value: value) {
         NavigationLink(value: value) {
             HStack {
             HStack {
                 Glyph(symbol: icon, tint: iconTint)
                 Glyph(symbol: icon, tint: iconTint)
-                Text(title)
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(title)
+                    if let subtitle {
+                        Text(subtitle)
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                    }
+                }
             }
             }
         }
         }
     }
     }

+ 17 - 20
LoopFollow/Helpers/Views/TogglableSecureInput.swift

@@ -28,13 +28,14 @@ struct TogglableSecureInput: View {
                                 .submitLabel(.done)
                                 .submitLabel(.done)
                                 .focused($isFocused)
                                 .focused($isFocused)
                         } else {
                         } else {
-                            HStack {
-                                Spacer()
-                                Text(maskString)
-                                    .font(.body.monospaced())
-                                    .foregroundColor(.primary)
-                                    .allowsHitTesting(false)
-                            }
+                            // A real (masked) SecureField, not static text, so the
+                            // value stays editable while hidden and — crucially —
+                            // iOS AutoFill/keychain has a secure field to fill.
+                            SecureField(placeholder, text: $text)
+                                .multilineTextAlignment(.trailing)
+                                .textContentType(textContentType)
+                                .submitLabel(.done)
+                                .focused($isFocused)
                         }
                         }
                     }
                     }
 
 
@@ -82,19 +83,15 @@ struct TogglableSecureInput: View {
         }
         }
         .contentShape(Rectangle())
         .contentShape(Rectangle())
         .onTapGesture {
         .onTapGesture {
-            if !isVisible {
-                isVisible = true
-                if style == .singleLine {
-                    isFocused = true
-                } else if style == .multiLine {
-                    isMultilineFocused = true
-                }
-            } else {
-                if style == .singleLine {
-                    isFocused = true
-                } else if style == .multiLine {
-                    isMultilineFocused = true
-                }
+            switch style {
+            case .singleLine:
+                // The hidden state is already an editable SecureField, so tapping
+                // just focuses it — no need to reveal the plaintext to type.
+                isFocused = true
+            case .multiLine:
+                // The multi-line editor is only editable once revealed.
+                if !isVisible { isVisible = true }
+                isMultilineFocused = true
             }
             }
         }
         }
     }
     }

+ 0 - 28
LoopFollow/Helpers/carbBolusArrays.swift

@@ -32,32 +32,4 @@ extension MainViewController {
 
 
         return (false, 0)
         return (false, 0)
     }
     }
-
-    func findNextCarbTime(timeWithin: Int, needle: TimeInterval, haystack: [carbGraphStruct], startingIndex: Int) -> Bool {
-        if startingIndex > haystack.count - 2 { return false }
-        if haystack[startingIndex + 1].date - needle < Double(timeWithin) {
-            return true
-        }
-
-        return false
-    }
-
-    func findNextBolusTime(timeWithin: Int, needle: TimeInterval, haystack: [bolusGraphStruct], startingIndex: Int) -> Bool {
-        var last = false
-        var next = true
-        if startingIndex > haystack.count - 2 { return false }
-        if startingIndex == 0 { return false }
-
-        // Nothing to right that requires shift
-        if haystack[startingIndex + 1].date - needle > Double(timeWithin) {
-            return false
-        } else {
-            // Nothing to left preventing shift
-            if needle - haystack[startingIndex - 1].date > Double(timeWithin) {
-                return true
-            }
-        }
-
-        return false
-    }
 }
 }

+ 41 - 12
LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift

@@ -5,6 +5,7 @@ import SwiftUI
 
 
 struct InfoDisplaySettingsView: View {
 struct InfoDisplaySettingsView: View {
     @ObservedObject var viewModel: InfoDisplaySettingsViewModel
     @ObservedObject var viewModel: InfoDisplaySettingsViewModel
+    @State private var selectedID: Int?
 
 
     var body: some View {
     var body: some View {
         Form {
         Form {
@@ -17,28 +18,56 @@ struct InfoDisplaySettingsView: View {
                 }
                 }
             }
             }
 
 
-            Section(header: Text("Information Display Settings")) {
-                ForEach(viewModel.infoSort, id: \.self) { sortedIndex in
-                    HStack {
-                        Text(viewModel.getName(for: sortedIndex))
-                        Spacer()
-                        Toggle("", isOn: Binding(
-                            get: { viewModel.infoVisible[sortedIndex] },
-                            set: { _ in
-                                viewModel.toggleVisibility(for: sortedIndex)
-                            }
-                        ))
-                        .labelsHidden()
+            Section(
+                header: Text("Information Display Settings"),
+                footer: Text("Drag to reorder. Tap a row to set its visibility and colors.")
+            ) {
+                // The list stays in edit mode so rows are always draggable; a
+                // Button (unlike NavigationLink) still receives taps in edit
+                // mode, so reordering and navigation work at the same time.
+                ForEach(viewModel.items) { item in
+                    Button {
+                        selectedID = item.id
+                    } label: {
+                        rowLabel(for: item)
                     }
                     }
+                    .buttonStyle(.plain)
                 }
                 }
                 .onMove(perform: viewModel.move)
                 .onMove(perform: viewModel.move)
             }
             }
         }
         }
         .environment(\.editMode, .constant(.active))
         .environment(\.editMode, .constant(.active))
+        .navigationDestination(isPresented: Binding(
+            get: { selectedID != nil },
+            set: { if !$0 { selectedID = nil } }
+        )) {
+            if let id = selectedID {
+                InfoRowSettingsView(item: viewModel.binding(for: id))
+            }
+        }
         .onDisappear {
         .onDisappear {
             NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
             NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
         }
         }
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Information Display Settings", displayMode: .inline)
         .navigationBarTitle("Information Display Settings", displayMode: .inline)
     }
     }
+
+    private func rowLabel(for item: InfoDisplayItem) -> some View {
+        HStack {
+            Text(item.type.name)
+            Spacer()
+            if item.type.isColorable, item.coloring.enabled {
+                Circle()
+                    .fill(Color.orange)
+                    .frame(width: 8, height: 8)
+                    .accessibilityLabel("Coloring enabled")
+            }
+            Text(item.isVisible ? "On" : "Off")
+                .foregroundStyle(.secondary)
+            Image(systemName: "chevron.right")
+                .font(.footnote.weight(.semibold))
+                .foregroundStyle(.tertiary)
+        }
+        .contentShape(Rectangle())
+    }
 }
 }

+ 18 - 15
LoopFollow/InfoDisplaySettings/InfoDisplaySettingsViewModel.swift

@@ -5,28 +5,31 @@ import Foundation
 import SwiftUI
 import SwiftUI
 
 
 class InfoDisplaySettingsViewModel: ObservableObject {
 class InfoDisplaySettingsViewModel: ObservableObject {
-    @Published var infoSort: [Int]
-    @Published var infoVisible: [Bool]
+    @Published var items: [InfoDisplayItem]
 
 
     init() {
     init() {
-        infoSort = Storage.shared.infoSort.value
-        infoVisible = Storage.shared.infoVisible.value
+        items = Storage.shared.infoDisplayItems.value
     }
     }
 
 
-    func toggleVisibility(for sortedIndex: Int) {
-        infoVisible[sortedIndex].toggle()
-        Storage.shared.infoVisible.value = infoVisible
+    func move(from source: IndexSet, to destination: Int) {
+        items.move(fromOffsets: source, toOffset: destination)
+        persist()
     }
     }
 
 
-    func move(from source: IndexSet, to destination: Int) {
-        infoSort.move(fromOffsets: source, toOffset: destination)
-        Storage.shared.infoSort.value = infoSort
+    /// A binding to a single item, looked up by id so it survives reordering.
+    /// Writes persist to Storage immediately.
+    func binding(for id: Int) -> Binding<InfoDisplayItem> {
+        Binding(
+            get: { self.items.first(where: { $0.id == id }) ?? self.items[0] },
+            set: { newValue in
+                guard let index = self.items.firstIndex(where: { $0.id == id }) else { return }
+                self.items[index] = newValue
+                self.persist()
+            }
+        )
     }
     }
 
 
-    func getName(for index: Int) -> String {
-        guard let infoType = InfoType(rawValue: index) else {
-            return String(localized: "Unknown")
-        }
-        return infoType.name
+    private func persist() {
+        Storage.shared.infoDisplayItems.value = items
     }
     }
 }
 }

+ 99 - 0
LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift

@@ -0,0 +1,99 @@
+// LoopFollow
+// InfoRowSettingsView.swift
+
+import SwiftUI
+
+struct InfoRowSettingsView: View {
+    @Binding var item: InfoDisplayItem
+
+    var body: some View {
+        Form {
+            Section {
+                Toggle("Show in Info Display", isOn: $item.isVisible)
+            }
+
+            if let config = item.type.colorConfig {
+                Section(
+                    header: Text("Color"),
+                    footer: Text(colorFooter(config))
+                ) {
+                    Toggle("Enable coloring", isOn: enabledBinding(config))
+
+                    if item.coloring.enabled {
+                        thresholdRow(title: "Yellow at", value: $item.coloring.warning, default: config.defaultWarning, config: config)
+                        thresholdRow(title: "Red at", value: $item.coloring.urgent, default: config.defaultUrgent, config: config)
+
+                        if let thresholdWarning = thresholdWarning(config) {
+                            Label(thresholdWarning, systemImage: "exclamationmark.triangle.fill")
+                                .font(.footnote)
+                                .foregroundStyle(.orange)
+                        }
+                    }
+                }
+            }
+        }
+        .navigationBarTitle(item.type.name, displayMode: .inline)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
+    }
+
+    /// Turning coloring on seeds any unset threshold, so the steppers and the
+    /// stored values always agree.
+    private func enabledBinding(_ config: InfoColorConfig) -> Binding<Bool> {
+        Binding(
+            get: { item.coloring.enabled },
+            set: { isOn in
+                if isOn {
+                    item.coloring.warning = item.coloring.warning ?? config.defaultWarning
+                    item.coloring.urgent = item.coloring.urgent ?? config.defaultUrgent
+                }
+                item.coloring.enabled = isOn
+            }
+        )
+    }
+
+    private func thresholdRow(title: String, value: Binding<Double?>, default defaultValue: Double, config: InfoColorConfig) -> some View {
+        SettingsStepperRow(
+            title: title,
+            range: config.range,
+            step: config.step,
+            value: Binding(
+                get: { value.wrappedValue ?? defaultValue },
+                set: { value.wrappedValue = $0 }
+            ),
+            format: { formatted($0, config) }
+        )
+    }
+
+    private func formatted(_ value: Double, _ config: InfoColorConfig) -> String {
+        let number = Localizer.formatToLocalizedString(
+            value,
+            maxFractionDigits: config.fractionDigits,
+            minFractionDigits: config.fractionDigits
+        )
+        return "\(number) \(config.unit)"
+    }
+
+    /// Non-blocking sanity check: red should be more severe than yellow, in the
+    /// metric's fixed direction. `nil` when consistent or a threshold is unset.
+    private func thresholdWarning(_ config: InfoColorConfig) -> String? {
+        guard item.coloring.enabled,
+              let warning = item.coloring.warning,
+              let urgent = item.coloring.urgent
+        else { return nil }
+        switch config.direction {
+        case .above:
+            return urgent < warning ? "Red should be at or above yellow." : nil
+        case .below:
+            return urgent > warning ? "Red should be at or below yellow." : nil
+        }
+    }
+
+    private func colorFooter(_ config: InfoColorConfig) -> String {
+        switch config.direction {
+        case .above:
+            return "The value turns yellow at or above the yellow level and red at or above the red level. In range it shows green. A visual cue only — it never triggers an alarm."
+        case .below:
+            return "The value turns yellow at or below the yellow level and red at or below the red level. In range it shows green. A visual cue only — it never triggers an alarm."
+        }
+    }
+}

+ 5 - 1
LoopFollow/InfoTable/InfoData.swift

@@ -7,10 +7,14 @@ class InfoData: Identifiable {
     let id: Int
     let id: Int
     let name: String
     let name: String
     var value: String
     var value: String
+    /// Raw numeric value behind `value`, when the row carries a single number.
+    /// Used for threshold-based coloring; `nil` for text or combined values.
+    var numericValue: Double?
 
 
-    init(id: Int, name: String, value: String = "") {
+    init(id: Int, name: String, value: String = "", numericValue: Double? = nil) {
         self.id = id
         self.id = id
         self.name = name
         self.name = name
         self.value = value
         self.value = value
+        self.numericValue = numericValue
     }
     }
 }
 }

+ 73 - 0
LoopFollow/InfoTable/InfoDisplayItem.swift

@@ -0,0 +1,73 @@
+// LoopFollow
+// InfoDisplayItem.swift
+
+import SwiftUI
+
+/// One configurable row in the Info Display: which info it shows, whether it is
+/// visible, and its optional threshold-based coloring. The ordered array of
+/// these replaces the old parallel `infoSort` / `infoVisible` storage.
+struct InfoDisplayItem: Codable, Equatable, Identifiable {
+    var type: InfoType
+    var isVisible: Bool
+    var coloring: InfoColoring
+
+    var id: Int { type.rawValue }
+}
+
+extension Array where Element == InfoDisplayItem {
+    func item(for type: InfoType) -> InfoDisplayItem? {
+        first { $0.type == type }
+    }
+
+    func isVisible(_ type: InfoType) -> Bool {
+        item(for: type)?.isVisible ?? false
+    }
+}
+
+/// Which direction is "concerning" for a metric. Fixed per InfoType (battery is
+/// bad when low, sensor age is bad when high) rather than user-configurable.
+enum InfoColorDirection {
+    case above // color as the value rises (e.g. SAGE, IOB)
+    case below // color as the value falls (e.g. Battery, Pump)
+}
+
+/// What threshold coloring looks like for one InfoType: the concerning
+/// direction, the unit shown next to the value, and the bounds the threshold
+/// steppers move in. Only types that expose one of these can be colored.
+struct InfoColorConfig {
+    let direction: InfoColorDirection
+    let unit: String
+    let range: ClosedRange<Double>
+    let step: Double
+    let defaultWarning: Double
+    let defaultUrgent: Double
+
+    /// Decimals to show, derived from the step (0.5 → 1, 5 → 0).
+    var fractionDigits: Int {
+        step.truncatingRemainder(dividingBy: 1) == 0 ? 0 : 1
+    }
+}
+
+/// Per-row, opt-in color thresholds. Purely visual — never triggers an alarm.
+struct InfoColoring: Codable, Equatable {
+    var enabled: Bool = false
+    var warning: Double? = nil // yellow
+    var urgent: Double? = nil // red
+
+    /// Resolves the color for a raw numeric value, given the metric's fixed
+    /// direction. Returns `nil` only when coloring is off (callers map that to
+    /// the default text color); when enabled, an in-range value reads green,
+    /// mirroring the app's BG text coloring.
+    func color(for value: Double, direction: InfoColorDirection) -> Color? {
+        guard enabled else { return nil }
+        switch direction {
+        case .above:
+            if let urgent, value >= urgent { return .red }
+            if let warning, value >= warning { return .yellow }
+        case .below:
+            if let urgent, value <= urgent { return .red }
+            if let warning, value <= warning { return .yellow }
+        }
+        return .green
+    }
+}

+ 12 - 7
LoopFollow/InfoTable/InfoManager.swift

@@ -12,14 +12,16 @@ class InfoManager: ObservableObject {
         tableData = InfoType.allCases.map { InfoData(id: $0.rawValue, name: $0.name) }
         tableData = InfoType.allCases.map { InfoData(id: $0.rawValue, name: $0.name) }
     }
     }
 
 
-    func updateInfoData(type: InfoType, value: String) {
+    func updateInfoData(type: InfoType, value: String, numericValue: Double? = nil) {
         tableData[type.rawValue].value = value
         tableData[type.rawValue].value = value
+        tableData[type.rawValue].numericValue = numericValue
         objectWillChange.send()
         objectWillChange.send()
     }
     }
 
 
     func updateInfoData(type: InfoType, value: HKQuantity) {
     func updateInfoData(type: InfoType, value: HKQuantity) {
         let formattedValue = Localizer.formatQuantity(value)
         let formattedValue = Localizer.formatQuantity(value)
-        updateInfoData(type: type, value: formattedValue)
+        let numericValue = value.doubleValue(for: Localizer.getPreferredUnit())
+        updateInfoData(type: type, value: formattedValue, numericValue: numericValue)
     }
     }
 
 
     func updateInfoData(type: InfoType, firstValue: HKQuantity, secondValue: HKQuantity, separator: InfoDataSeparator) {
     func updateInfoData(type: InfoType, firstValue: HKQuantity, secondValue: HKQuantity, separator: InfoDataSeparator) {
@@ -35,7 +37,7 @@ class InfoManager: ObservableObject {
 
 
     func updateInfoData(type: InfoType, value: Double, maxFractionDigits: Int = 1, minFractionDigits: Int = 0) {
     func updateInfoData(type: InfoType, value: Double, maxFractionDigits: Int = 1, minFractionDigits: Int = 0) {
         let formattedValue = Localizer.formatToLocalizedString(value, maxFractionDigits: maxFractionDigits, minFractionDigits: minFractionDigits)
         let formattedValue = Localizer.formatToLocalizedString(value, maxFractionDigits: maxFractionDigits, minFractionDigits: minFractionDigits)
-        updateInfoData(type: type, value: formattedValue)
+        updateInfoData(type: type, value: formattedValue, numericValue: value)
     }
     }
 
 
     func updateInfoData(type: InfoType, value: Double, enactedValue: Double, separator: InfoDataSeparator, maxFractionDigits: Int = 1, minFractionDigits: Int = 0) {
     func updateInfoData(type: InfoType, value: Double, enactedValue: Double, separator: InfoDataSeparator, maxFractionDigits: Int = 1, minFractionDigits: Int = 0) {
@@ -48,25 +50,28 @@ class InfoManager: ObservableObject {
 
 
     func updateInfoData(type: InfoType, value: Metric) {
     func updateInfoData(type: InfoType, value: Metric) {
         let formattedValue = value.formattedValue()
         let formattedValue = value.formattedValue()
-        updateInfoData(type: type, value: formattedValue)
+        updateInfoData(type: type, value: formattedValue, numericValue: value.value)
     }
     }
 
 
     func clearInfoData(type: InfoType) {
     func clearInfoData(type: InfoType) {
         tableData[type.rawValue].value = ""
         tableData[type.rawValue].value = ""
+        tableData[type.rawValue].numericValue = nil
         objectWillChange.send()
         objectWillChange.send()
     }
     }
 
 
     func clearInfoData(types: [InfoType]) {
     func clearInfoData(types: [InfoType]) {
         for type in types {
         for type in types {
             tableData[type.rawValue].value = ""
             tableData[type.rawValue].value = ""
+            tableData[type.rawValue].numericValue = nil
         }
         }
         objectWillChange.send()
         objectWillChange.send()
     }
     }
 
 
     var visibleRows: [InfoData] {
     var visibleRows: [InfoData] {
-        Storage.shared.infoSort.value
-            .filter { $0 < Storage.shared.infoVisible.value.count && Storage.shared.infoVisible.value[$0] }
-            .compactMap { index in
+        Storage.shared.infoDisplayItems.value
+            .filter { $0.isVisible }
+            .compactMap { item in
+                let index = item.type.rawValue
                 guard index < tableData.count else { return nil }
                 guard index < tableData.count else { return nil }
                 return tableData[index]
                 return tableData[index]
             }
             }

+ 14 - 4
LoopFollow/InfoTable/InfoTableView.swift

@@ -16,14 +16,24 @@ struct InfoTableView: View {
                 row(name: "Time Zone", value: tz)
                 row(name: "Time Zone", value: tz)
             }
             }
             ForEach(infoManager.visibleRows) { item in
             ForEach(infoManager.visibleRows) { item in
-                row(name: item.name, value: item.value)
+                row(name: item.name, value: item.value, valueColor: color(for: item))
             }
             }
         }
         }
         .listStyle(.plain)
         .listStyle(.plain)
         .environment(\.defaultMinListRowHeight, rowHeight)
         .environment(\.defaultMinListRowHeight, rowHeight)
     }
     }
 
 
-    private func row(name: String, value: String) -> some View {
+    /// Threshold-based color for a row's value, or nil to use the default color.
+    private func color(for item: InfoData) -> Color? {
+        guard let numericValue = item.numericValue,
+              let type = InfoType(rawValue: item.id),
+              let config = type.colorConfig
+        else { return nil }
+        return Storage.shared.infoDisplayItems.value.item(for: type)?
+            .coloring.color(for: numericValue, direction: config.direction)
+    }
+
+    private func row(name: String, value: String, valueColor: Color? = nil) -> some View {
         // Show a placeholder for any field that has no value yet,
         // Show a placeholder for any field that has no value yet,
         // so the row reads as "no data" rather than appearing empty.
         // so the row reads as "no data" rather than appearing empty.
         let displayValue = value.isEmpty ? "—" : value
         let displayValue = value.isEmpty ? "—" : value
@@ -34,14 +44,14 @@ struct InfoTableView: View {
                 Text(name)
                 Text(name)
                 Spacer()
                 Spacer()
                 Text(displayValue)
                 Text(displayValue)
-                    .foregroundStyle(.primary)
+                    .foregroundStyle(valueColor ?? .primary)
             }
             }
 
 
             // Fallback when the single line won't fit: label over value
             // Fallback when the single line won't fit: label over value
             VStack(alignment: .leading, spacing: 0) {
             VStack(alignment: .leading, spacing: 0) {
                 Text(name)
                 Text(name)
                 Text(displayValue)
                 Text(displayValue)
-                    .foregroundStyle(.primary)
+                    .foregroundStyle(valueColor ?? .primary)
                     .frame(maxWidth: .infinity, alignment: .trailing)
                     .frame(maxWidth: .infinity, alignment: .trailing)
             }
             }
         }
         }

+ 55 - 22
LoopFollow/InfoTable/InfoType.swift

@@ -3,31 +3,32 @@
 
 
 import Foundation
 import Foundation
 
 
-enum InfoType: Int, CaseIterable {
-    case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage
+enum InfoType: Int, CaseIterable, Codable {
+    case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage, dbSize
 
 
     var name: String {
     var name: String {
         switch self {
         switch self {
-        case .iob: return String(localized: "IOB")
-        case .cob: return String(localized: "COB")
-        case .basal: return String(localized: "Basal")
-        case .override: return String(localized: "Override")
-        case .battery: return String(localized: "Battery")
-        case .pump: return String(localized: "Pump")
-        case .pumpBattery: return String(localized: "Pump Battery")
-        case .sage: return String(localized: "SAGE")
-        case .cage: return String(localized: "CAGE")
-        case .recBolus: return String(localized: "Rec. Bolus")
-        case .minMax: return String(localized: "Min/Max")
-        case .carbsToday: return String(localized: "Carbs today")
-        case .autosens: return String(localized: "Autosens")
-        case .profile: return String(localized: "Profile")
-        case .target: return String(localized: "Target")
-        case .isf: return String(localized: "ISF")
-        case .carbRatio: return String(localized: "CR")
-        case .updated: return String(localized: "Updated")
-        case .tdd: return String(localized: "TDD")
-        case .iage: return String(localized: "IAGE")
+        case .iob: return "IOB"
+        case .cob: return "COB"
+        case .basal: return "Basal"
+        case .override: return "Override"
+        case .battery: return "Battery"
+        case .pump: return "Pump"
+        case .pumpBattery: return "Pump Battery"
+        case .sage: return "SAGE"
+        case .cage: return "CAGE"
+        case .recBolus: return "Rec. Bolus"
+        case .minMax: return "Min/Max"
+        case .carbsToday: return "Carbs today"
+        case .autosens: return "Autosens"
+        case .profile: return "Profile"
+        case .target: return "Target"
+        case .isf: return "ISF"
+        case .carbRatio: return "CR"
+        case .updated: return "Updated"
+        case .tdd: return "TDD"
+        case .iage: return "IAGE"
+        case .dbSize: return "DB Size"
         }
         }
     }
     }
 
 
@@ -43,4 +44,36 @@ enum InfoType: Int, CaseIterable {
     var sortOrder: Int {
     var sortOrder: Int {
         return rawValue
         return rawValue
     }
     }
+
+    /// Rows that carry a single numeric value can offer color thresholds.
+    /// Combined rows (basal, min/max), BG-unit rows (target, ISF, CR) and
+    /// pure-text rows have no config and are therefore not colorable.
+    /// Steps mirror the equivalent alarm editors, so a value that takes decimals
+    /// in an alarm takes decimals here too.
+    var colorConfig: InfoColorConfig? {
+        switch self {
+        case .iob:
+            return InfoColorConfig(direction: .above, unit: "U", range: 0 ... 20, step: 0.5, defaultWarning: 3, defaultUrgent: 5)
+        case .cob:
+            return InfoColorConfig(direction: .above, unit: "g", range: 0 ... 200, step: 1, defaultWarning: 30, defaultUrgent: 60)
+        case .battery, .pumpBattery:
+            return InfoColorConfig(direction: .below, unit: "%", range: 0 ... 100, step: 5, defaultWarning: 30, defaultUrgent: 15)
+        case .pump:
+            return InfoColorConfig(direction: .below, unit: "U", range: 0 ... 50, step: 1, defaultWarning: 20, defaultUrgent: 10)
+        case .tdd:
+            return InfoColorConfig(direction: .above, unit: "U", range: 0 ... 200, step: 1, defaultWarning: 60, defaultUrgent: 80)
+        case .recBolus:
+            return InfoColorConfig(direction: .above, unit: "U", range: 0 ... 20, step: 0.1, defaultWarning: 1, defaultUrgent: 2)
+        case .carbsToday:
+            return InfoColorConfig(direction: .above, unit: "g", range: 0 ... 500, step: 5, defaultWarning: 150, defaultUrgent: 250)
+        case .sage:
+            return InfoColorConfig(direction: .above, unit: "days", range: 0.5 ... 15, step: 0.5, defaultWarning: 9, defaultUrgent: 9.5)
+        case .cage, .iage:
+            return InfoColorConfig(direction: .above, unit: "days", range: 0.5 ... 10, step: 0.5, defaultWarning: 2.5, defaultUrgent: 3)
+        default:
+            return nil
+        }
+    }
+
+    var isColorable: Bool { colorConfig != nil }
 }
 }

+ 3 - 0
LoopFollow/LiveActivity/APNSClient.swift

@@ -246,6 +246,9 @@ class APNSClient {
         if let cob = snapshot.cob { snapshotDict["cob"] = cob }
         if let cob = snapshot.cob { snapshotDict["cob"] = cob }
         if let projected = snapshot.projected { snapshotDict["projected"] = projected }
         if let projected = snapshot.projected { snapshotDict["projected"] = projected }
         if let override = snapshot.override { snapshotDict["override"] = override }
         if let override = snapshot.override { snapshotDict["override"] = override }
+        if let overrideEndAt = snapshot.overrideEndAt { snapshotDict["overrideEndAt"] = overrideEndAt }
+        if let tempTargetMgdl = snapshot.tempTargetMgdl { snapshotDict["tempTargetMgdl"] = tempTargetMgdl }
+        if let tempTargetEndAt = snapshot.tempTargetEndAt { snapshotDict["tempTargetEndAt"] = tempTargetEndAt }
         if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus }
         if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus }
         if let battery = snapshot.battery { snapshotDict["battery"] = battery }
         if let battery = snapshot.battery { snapshotDict["battery"] = battery }
         if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery }
         if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery }

+ 12 - 0
LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift

@@ -33,6 +33,15 @@ protocol CurrentGlucoseStateProviding {
     /// Active override name (nil if no active override).
     /// Active override name (nil if no active override).
     var override: String? { get }
     var override: String? { get }
 
 
+    /// End of the active override as Unix epoch seconds (nil = no override or indefinite).
+    var overrideEndAt: TimeInterval? { get }
+
+    /// Active temporary target in mg/dL (nil if no active temp target).
+    var tempTargetMgdl: Double? { get }
+
+    /// End of the active temporary target as Unix epoch seconds.
+    var tempTargetEndAt: TimeInterval? { get }
+
     /// Recommended bolus in units.
     /// Recommended bolus in units.
     var recBolus: Double? { get }
     var recBolus: Double? { get }
 
 
@@ -141,6 +150,9 @@ enum GlucoseSnapshotBuilder {
             cob: provider.cob,
             cob: provider.cob,
             projected: provider.projectedMgdl,
             projected: provider.projectedMgdl,
             override: provider.override,
             override: provider.override,
+            overrideEndAt: provider.overrideEndAt,
+            tempTargetMgdl: provider.tempTargetMgdl,
+            tempTargetEndAt: provider.tempTargetEndAt,
             recBolus: provider.recBolus,
             recBolus: provider.recBolus,
             battery: provider.battery,
             battery: provider.battery,
             pumpBattery: provider.pumpBattery,
             pumpBattery: provider.pumpBattery,

+ 51 - 235
LoopFollow/LiveActivity/LiveActivityManager.swift

@@ -10,19 +10,8 @@ import os
 import UIKit
 import UIKit
 import UserNotifications
 import UserNotifications
 
 
-// Live Activity manager for LoopFollow.
-//
-// iOS 17.2+:        every LA creation (initial start, renewal, forced
-//                   restart) goes through APNs push-to-start. Updates
-//                   ride the same APNs transport. One transport, one
-//                   credential failure mode that surfaces in settings.
-//
-// iOS 16.6 – 17.1:  legacy Activity.request() for everything;
-//                   renewal-failed notification when backgrounded.
-//                   The entry-point `if #available(iOS 17.2, *)` checks
-//                   isolate every iOS 17.2 code path, so the legacy
-//                   helpers can be deleted in one commit when the
-//                   deployment target reaches 17.2.
+// Live Activity manager for LoopFollow. Every LA creation (start, renewal,
+// restart) and update goes through APNs push-to-start.
 
 
 final class LiveActivityManager {
 final class LiveActivityManager {
     static let shared = LiveActivityManager()
     static let shared = LiveActivityManager()
@@ -55,50 +44,42 @@ final class LiveActivityManager {
         startActivityUpdatesObservation()
         startActivityUpdatesObservation()
     }
     }
 
 
-    // MARK: - Push-to-start observation (iOS 17.2+)
+    // MARK: - Push-to-start observation
 
 
-    /// Observes the type-level push-to-start token (iOS 17.2+) and persists it.
+    /// Observes the type-level push-to-start token and persists it.
     /// The token survives app relaunches but is reissued by iOS periodically or when
     /// The token survives app relaunches but is reissued by iOS periodically or when
     /// the user toggles LA permissions — each new delivery overwrites the stored value.
     /// the user toggles LA permissions — each new delivery overwrites the stored value.
     private func startPushToStartTokenObservation() {
     private func startPushToStartTokenObservation() {
-        if #available(iOS 17.2, *) {
-            pushToStartObservationTask?.cancel()
-            LogManager.shared.log(
-                category: .general,
-                message: "[LA] pushToStartTokenUpdates observation starting (iOS 17.2+)"
-            )
-            pushToStartObservationTask = Task {
-                var deliveries = 0
-                for await tokenData in Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates {
-                    deliveries += 1
-                    let token = tokenData.map { String(format: "%02x", $0) }.joined()
-                    let previousTail = Storage.shared.laPushToStartToken.value.isEmpty
-                        ? "nil"
-                        : String(Storage.shared.laPushToStartToken.value.suffix(8))
-                    let tail = String(token.suffix(8))
-                    let changed = tail != previousTail
-                    Storage.shared.laPushToStartToken.value = token
-                    LogManager.shared.log(
-                        category: .general,
-                        message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")"
-                    )
-                }
+        pushToStartObservationTask?.cancel()
+        LogManager.shared.log(
+            category: .general,
+            message: "[LA] pushToStartTokenUpdates observation starting"
+        )
+        pushToStartObservationTask = Task {
+            var deliveries = 0
+            for await tokenData in Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates {
+                deliveries += 1
+                let token = tokenData.map { String(format: "%02x", $0) }.joined()
+                let previousTail = Storage.shared.laPushToStartToken.value.isEmpty
+                    ? "nil"
+                    : String(Storage.shared.laPushToStartToken.value.suffix(8))
+                let tail = String(token.suffix(8))
+                let changed = tail != previousTail
+                Storage.shared.laPushToStartToken.value = token
                 LogManager.shared.log(
                 LogManager.shared.log(
                     category: .general,
                     category: .general,
-                    message: "[LA] pushToStartTokenUpdates stream ended after \(deliveries) deliveries — no further tokens will arrive"
+                    message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")"
                 )
                 )
             }
             }
-        } else {
             LogManager.shared.log(
             LogManager.shared.log(
                 category: .general,
                 category: .general,
-                message: "[LA] pushToStartTokenUpdates unavailable (iOS <17.2) — push-to-start will never fire"
+                message: "[LA] pushToStartTokenUpdates stream ended after \(deliveries) deliveries — no further tokens will arrive"
             )
             )
         }
         }
     }
     }
 
 
-    /// Observes new Activity creations. When an activity is started by
-    /// push-to-start (iOS 17.2+), the app discovers it through this stream and
-    /// adopts it via the same bind/update path as an app-initiated start.
+    /// Observes new Activity creations so push-to-start activities are adopted
+    /// via the same bind/update path as an app-initiated start.
     private func startActivityUpdatesObservation() {
     private func startActivityUpdatesObservation() {
         activityUpdatesObservationTask?.cancel()
         activityUpdatesObservationTask?.cancel()
         LogManager.shared.log(
         LogManager.shared.log(
@@ -471,7 +452,7 @@ final class LiveActivityManager {
     /// The actual end+restart is run from handleDidBecomeActive() because
     /// The actual end+restart is run from handleDidBecomeActive() because
     /// Activity.request() returns `visibility` during willEnterForeground.
     /// Activity.request() returns `visibility` during willEnterForeground.
     private var pendingForegroundRestart = false
     private var pendingForegroundRestart = false
-    /// Observes `pushToStartTokenUpdates` (iOS 17.2+) and persists the token.
+    /// Observes `pushToStartTokenUpdates` and persists the token.
     /// Long-lived — started once at init and never cancelled.
     /// Long-lived — started once at init and never cancelled.
     private var pushToStartObservationTask: Task<Void, Never>?
     private var pushToStartObservationTask: Task<Void, Never>?
     /// Observes `Activity<>.activityUpdates` so activities started out-of-band
     /// Observes `Activity<>.activityUpdates` so activities started out-of-band
@@ -515,118 +496,28 @@ final class LiveActivityManager {
         let startReason = nextStartReasonOverride ?? "user-start"
         let startReason = nextStartReasonOverride ?? "user-start"
         nextStartReasonOverride = nil
         nextStartReasonOverride = nil
 
 
-        if #available(iOS 17.2, *) {
-            // iOS 17.2+ uses push-to-start for every creation path. If an
-            // activity is already running and not stale we adopt/reuse it
-            // (covers warm starts where the LA survived a relaunch); only
-            // truly new starts dispatch APNs.
-            if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
-                let renewBy = Storage.shared.laRenewBy.value
-                let now = Date().timeIntervalSince1970
-                let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
-                let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
-                let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
-                if !needsRestart {
-                    bind(to: existing, logReason: "reuse")
-                    Storage.shared.laRenewalFailed.value = false
-                    return
-                }
-                LogManager.shared.log(
-                    category: .general,
-                    message: "[LA] existing activity is stale on startIfNeeded (iOS 17.2+) — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
-                )
-                attemptPushToStartCreate(reason: startReason, oldActivity: existing)
-                return
-            }
-            attemptPushToStartCreate(reason: startReason, oldActivity: nil)
-        } else {
-            startIfNeededLegacy()
-        }
-    }
-
-    /// Pre-17.2 path (iOS 16.6 – 17.1). Identical to dev's `startIfNeeded` —
-    /// Activity.request() for everything. Removable when the deployment target
-    /// reaches 17.2.
-    @MainActor
-    private func startIfNeededLegacy() {
+        // Push-to-start is used for every creation path. If an activity is
+        // already running and not stale we adopt/reuse it (covers warm starts
+        // where the LA survived a relaunch); only truly new starts dispatch APNs.
         if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
         if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
-            // Before reusing, check whether this activity needs a restart. This covers cold
-            // starts (app was killed while the overlay was showing — willEnterForeground is
-            // never sent, so handleForeground never runs) and any other path that lands here
-            // without first going through handleForeground.
             let renewBy = Storage.shared.laRenewBy.value
             let renewBy = Storage.shared.laRenewBy.value
             let now = Date().timeIntervalSince1970
             let now = Date().timeIntervalSince1970
             let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
             let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
             let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
             let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
             let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
             let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
-
-            if needsRestart {
-                LogManager.shared.log(
-                    category: .general,
-                    message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
-                )
-
-                endingForRestart = true
-                dismissedByUser = false
-
-                Storage.shared.laRenewBy.value = 0
+            if !needsRestart {
+                bind(to: existing, logReason: "reuse")
                 Storage.shared.laRenewalFailed.value = false
                 Storage.shared.laRenewalFailed.value = false
-                cancelRenewalFailedNotification()
-
-                Task {
-                    await existing.end(nil, dismissalPolicy: .immediate)
-                    await MainActor.run { self.startIfNeededLegacy() }
-                }
                 return
                 return
             }
             }
-
-            bind(to: existing, logReason: "reuse")
-            Storage.shared.laRenewalFailed.value = false
-            return
-        }
-
-        do {
-            let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
-
-            let provider = StorageCurrentGlucoseStateProvider()
-            let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider)
-                ?? GlucoseSnapshotStore.shared.load()
-                ?? GlucoseSnapshot(
-                    glucose: 0,
-                    delta: 0,
-                    trend: .unknown,
-                    updatedAt: Date(),
-                    iob: nil,
-                    cob: nil,
-                    projected: nil,
-                    unit: .mgdl,
-                    isNotLooping: false,
-                )
-
-            let initialState = GlucoseLiveActivityAttributes.ContentState(
-                snapshot: seedSnapshot,
-                seq: 0,
-                reason: "start",
-                producedAt: Date(),
-            )
-
-            let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
-            let content = ActivityContent(state: initialState, staleDate: renewDeadline)
-            LALivenessStore.clear()
-            let activity = try Activity.request(attributes: attributes, content: content, pushType: .token)
-
-            bind(to: activity, logReason: "start-new")
-            Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
-            Storage.shared.laRenewalFailed.value = false
-            LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)")
-        } catch {
-            let ns = error as NSError
-            let scene = isAppVisibleForLiveActivityStart()
             LogManager.shared.log(
             LogManager.shared.log(
                 category: .general,
                 category: .general,
-                message: "Live Activity failed to start: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), sceneActive=\(scene), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
+                message: "[LA] existing activity is stale on startIfNeeded — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
             )
             )
+            attemptPushToStartCreate(reason: startReason, oldActivity: existing)
+            return
         }
         }
+        attemptPushToStartCreate(reason: startReason, oldActivity: nil)
     }
     }
 
 
     /// Called from applicationWillTerminate. Ends the LA synchronously (blocking
     /// Called from applicationWillTerminate. Ends the LA synchronously (blocking
@@ -771,102 +662,31 @@ final class LiveActivityManager {
         let overdueBy = Date().timeIntervalSince1970 - renewBy
         let overdueBy = Date().timeIntervalSince1970 - renewBy
         LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA")
         LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA")
 
 
-        if #available(iOS 17.2, *) {
-            // iOS 17.2+: renewal goes through push-to-start. The dispatch hops
-            // to MainActor and returns immediately; adoption (or failure) lands
-            // in the observer. Return true so performRefresh stops processing
-            // this tick.
-            Task { @MainActor [weak self] in
-                self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot)
-            }
-            return true
-        } else {
-            return attemptLegacyRenewal(snapshot: snapshot, oldActivity: oldActivity)
-        }
-    }
-
-    /// Pre-17.2 renewal (iOS 16.6 – 17.1): foreground Activity.request, mark
-    /// renewal-failed if it throws. Removable when the deployment target
-    /// reaches 17.2.
-    private func attemptLegacyRenewal(
-        snapshot: GlucoseSnapshot,
-        oldActivity: Activity<GlucoseLiveActivityAttributes>
-    ) -> Bool {
-        let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
-        let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
-
-        // Build the fresh snapshot with showRenewalOverlay: false — the new LA has a
-        // fresh deadline so no overlay is needed from the first frame. We pass the
-        // deadline as staleDate to ActivityContent below, not to Storage yet; Storage
-        // is only updated after Activity.request succeeds so a crash between the two
-        // can't leave the deadline permanently stuck in the future.
-        let freshSnapshot = snapshot.withRenewalOverlay(false)
-
-        let state = GlucoseLiveActivityAttributes.ContentState(
-            snapshot: freshSnapshot,
-            seq: seq,
-            reason: "renew",
-            producedAt: Date(),
-        )
-        let content = ActivityContent(state: state, staleDate: renewDeadline)
-
-        do {
-            let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token)
-
-            Task {
-                await oldActivity.end(nil, dismissalPolicy: .immediate)
-            }
-
-            updateTask?.cancel()
-            updateTask = nil
-            tokenObservationTask?.cancel()
-            tokenObservationTask = nil
-            stateObserverTask?.cancel()
-            stateObserverTask = nil
-            pushToken = nil
-
-            // Write deadline only on success — avoids a stuck future deadline if we crash
-            // between the write and the Activity.request call.
-            Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
-            bind(to: newActivity, logReason: "renew")
-            Storage.shared.laRenewalFailed.value = false
-            cancelRenewalFailedNotification()
-            GlucoseSnapshotStore.shared.save(freshSnapshot)
-            LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)")
-            return true
-        } catch {
-            // Renewal failed — deadline was never written, so no rollback needed.
-            let isFirstFailure = !Storage.shared.laRenewalFailed.value
-            Storage.shared.laRenewalFailed.value = true
-            let ns = error as NSError
-            LogManager.shared.log(
-                category: .general,
-                message: "[LA] renewal failed, keeping existing LA: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
-            )
-            if isFirstFailure {
-                scheduleRenewalFailedNotification()
-            }
-            return false
+        // Renewal goes through push-to-start. The dispatch hops to MainActor
+        // and returns immediately; adoption (or failure) lands in the observer.
+        // Return true so performRefresh stops processing this tick.
+        Task { @MainActor [weak self] in
+            self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot)
         }
         }
+        return true
     }
     }
 
 
-    // MARK: - Push-to-start (iOS 17.2+)
+    // MARK: - Push-to-start
 
 
-    /// Single creation path for iOS 17.2+. Handles initial start, renewal, and
-    /// forced restart. Verifies token + APNs credentials, applies backoff, then
-    /// dispatches the APNs push-to-start call. The old activity is only ended
-    /// after a confirmed successful send, preserving it if the send fails.
-    /// Adoption is delivered via the `activityUpdates` observer —
-    /// `handlePushToStartResult` only updates backoff/state.
-    @available(iOS 17.2, *)
+    /// Single creation path. Handles initial start, renewal, and forced restart.
+    /// Verifies token + APNs credentials, applies backoff, then dispatches the
+    /// APNs push-to-start call. The old activity is only ended after a confirmed
+    /// successful send, preserving it if the send fails. Adoption is delivered
+    /// via the `activityUpdates` observer — `handlePushToStartResult` only
+    /// updates backoff/state.
     @MainActor
     @MainActor
     private func attemptPushToStartCreate(
     private func attemptPushToStartCreate(
         reason: String,
         reason: String,
         oldActivity: Activity<GlucoseLiveActivityAttributes>?,
         oldActivity: Activity<GlucoseLiveActivityAttributes>?,
         snapshot: GlucoseSnapshot? = nil
         snapshot: GlucoseSnapshot? = nil
     ) {
     ) {
-        // Validate APNs credentials up-front — push-to-start is the ONLY transport
-        // on iOS 17.2+, so missing/invalid creds mean the LA will never display.
+        // Validate APNs credentials up-front — push-to-start is the only
+        // transport, so missing/invalid creds mean the LA will never display.
         let keyId = Storage.shared.lfKeyId.value
         let keyId = Storage.shared.lfKeyId.value
         let apnsKey = Storage.shared.lfApnsKey.value
         let apnsKey = Storage.shared.lfApnsKey.value
         guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else {
         guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else {
@@ -919,7 +739,6 @@ final class LiveActivityManager {
         }
         }
     }
     }
 
 
-    @available(iOS 17.2, *)
     private func dispatchPushToStart(
     private func dispatchPushToStart(
         reason: String,
         reason: String,
         oldActivity: Activity<GlucoseLiveActivityAttributes>?,
         oldActivity: Activity<GlucoseLiveActivityAttributes>?,
@@ -1019,7 +838,6 @@ final class LiveActivityManager {
         }
         }
     }
     }
 
 
-    @available(iOS 17.2, *)
     @MainActor
     @MainActor
     private func handlePushToStartResult(
     private func handlePushToStartResult(
         _ result: APNSClient.PushToStartResult,
         _ result: APNSClient.PushToStartResult,
@@ -1306,10 +1124,8 @@ final class LiveActivityManager {
         // bind-existing path rebinds to the just-ended activity — clearing
         // bind-existing path rebinds to the just-ended activity — clearing
         // endingForRestart and turning the eventual iOS dismissal into a misclassified
         // endingForRestart and turning the eventual iOS dismissal into a misclassified
         // user swipe. Drive the restart synchronously instead.
         // user swipe. Drive the restart synchronously instead.
-        if #available(iOS 17.2, *) {
-            Task { @MainActor [weak self] in
-                self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
-            }
+        Task { @MainActor [weak self] in
+            self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
         }
         }
     }
     }
 
 

+ 33 - 27
LoopFollow/LiveActivity/RestartLiveActivityIntent.swift

@@ -1,39 +1,45 @@
 // LoopFollow
 // LoopFollow
 // RestartLiveActivityIntent.swift
 // RestartLiveActivityIntent.swift
 
 
-import AppIntents
-import UIKit
+#if !targetEnvironment(macCatalyst)
+    import AppIntents
+    import UIKit
 
 
-struct RestartLiveActivityIntent: AppIntent {
-    static var title: LocalizedStringResource = "Restart Live Activity"
-    static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.")
+    struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent {
+        static var title: LocalizedStringResource = "Restart Live Activity"
+        static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.")
 
 
-    func perform() async throws -> some IntentResult & ProvidesDialog {
-        Storage.shared.laEnabled.value = true
+        func perform() async throws -> some IntentResult & ProvidesDialog {
+            Storage.shared.laEnabled.value = true
 
 
-        let keyId = Storage.shared.lfKeyId.value
-        let apnsKey = Storage.shared.lfApnsKey.value
+            let keyId = Storage.shared.lfKeyId.value
+            let apnsKey = Storage.shared.lfApnsKey.value
 
 
-        if keyId.isEmpty || apnsKey.isEmpty {
-            if let url = URL(string: "\(AppGroupID.urlScheme)://settings/live-activity") {
-                await MainActor.run { UIApplication.shared.open(url) }
+            if keyId.isEmpty || apnsKey.isEmpty {
+                if let url = URL(string: "loopfollow://settings/live-activity") {
+                    await MainActor.run { UIApplication.shared.open(url) }
+                }
+                return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.")
+            }
+
+            if #available(iOS 26.0, *) {
+                try await continueInForeground()
             }
             }
-            return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.")
-        }
 
 
-        await MainActor.run { LiveActivityManager.shared.forceRestart() }
+            await MainActor.run { LiveActivityManager.shared.forceRestart() }
 
 
-        return .result(dialog: "Live Activity restarted.")
+            return .result(dialog: "Live Activity restarted.")
+        }
     }
     }
-}
-
-struct LoopFollowAppShortcuts: AppShortcutsProvider {
-    static var appShortcuts: [AppShortcut] {
-        AppShortcut(
-            intent: RestartLiveActivityIntent(),
-            phrases: ["Restart Live Activity in \(.applicationName)"],
-            shortTitle: "Restart Live Activity",
-            systemImageName: "dot.radiowaves.left.and.right",
-        )
+
+    struct LoopFollowAppShortcuts: AppShortcutsProvider {
+        static var appShortcuts: [AppShortcut] {
+            AppShortcut(
+                intent: RestartLiveActivityIntent(),
+                phrases: ["Restart Live Activity in \(.applicationName)"],
+                shortTitle: "Restart Live Activity",
+                systemImageName: "dot.radiowaves.left.and.right"
+            )
+        }
     }
     }
-}
+#endif

+ 17 - 13
LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift

@@ -2,6 +2,7 @@
 // StorageCurrentGlucoseStateProvider.swift
 // StorageCurrentGlucoseStateProvider.swift
 
 
 import Foundation
 import Foundation
+import HealthKit
 
 
 /// Reads the latest glucose state from LoopFollow's Storage and Observable layers.
 /// Reads the latest glucose state from LoopFollow's Storage and Observable layers.
 /// This is the only file in the pipeline that is allowed to touch Storage.shared
 /// This is the only file in the pipeline that is allowed to touch Storage.shared
@@ -47,6 +48,18 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
         Observable.shared.override.value
         Observable.shared.override.value
     }
     }
 
 
+    var overrideEndAt: TimeInterval? {
+        Observable.shared.overrideEndAt.value
+    }
+
+    var tempTargetMgdl: Double? {
+        Observable.shared.tempTarget.value?.doubleValue(for: .milligramsPerDeciliter)
+    }
+
+    var tempTargetEndAt: TimeInterval? {
+        Observable.shared.tempTargetEndAt.value
+    }
+
     var recBolus: Double? {
     var recBolus: Double? {
         Observable.shared.deviceRecBolus.value
         Observable.shared.deviceRecBolus.value
     }
     }
@@ -134,19 +147,10 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
         #if targetEnvironment(macCatalyst)
         #if targetEnvironment(macCatalyst)
             return false
             return false
         #else
         #else
-            // iOS 17.2+ renews silently via push-to-start at the deadline, so the
-            // pre-emptive 30-minute "tap to update" overlay would be misleading
-            // during normal operation. Only show it once renewal has actually
-            // failed (no token, bad creds, rate-limited) — that is genuinely
-            // user-actionable. iOS 16.x keeps the time-based warning because
-            // renewal there requires the user to foreground the app.
-            if #available(iOS 17.2, *) {
-                return Storage.shared.laRenewalFailed.value
-            } else {
-                let renewBy = Storage.shared.laRenewBy.value
-                let now = Date().timeIntervalSince1970
-                return renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
-            }
+            // Push-to-start renews silently at the deadline, so only show the
+            // "tap to update" overlay once renewal has actually failed (no token,
+            // bad creds, rate-limited) — that is genuinely user-actionable.
+            return Storage.shared.laRenewalFailed.value
         #endif
         #endif
     }
     }
 }
 }

+ 1 - 1
LoopFollow/LiveActivitySettingsView.swift

@@ -59,7 +59,7 @@
                             get: { slots[index] },
                             get: { slots[index] },
                             set: { selectSlot($0, at: index) }
                             set: { selectSlot($0, at: index) }
                         )) {
                         )) {
-                            ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in
+                            ForEach(LiveActivitySlotOption.gridCases, id: \.self) { option in
                                 Text(option.displayName).tag(option)
                                 Text(option.displayName).tag(option)
                             }
                             }
                         }
                         }

+ 29 - 2
LoopFollow/Nightscout/NightscoutSettingsView.swift

@@ -49,16 +49,43 @@ struct NightscoutSettingsView: View {
     }
     }
 
 
     private var tokenSection: some View {
     private var tokenSection: some View {
-        Section(header: Text("Token")) {
+        Section {
             HStack {
             HStack {
                 Text("Access Token")
                 Text("Access Token")
                 TogglableSecureInput(
                 TogglableSecureInput(
-                    placeholder: String(localized: "Enter Token"),
+                    placeholder: "Token or API secret",
                     text: $viewModel.nightscoutToken,
                     text: $viewModel.nightscoutToken,
                     style: .singleLine,
                     style: .singleLine,
                     textContentType: .password
                     textContentType: .password
                 )
                 )
             }
             }
+
+            if viewModel.tokenIsVerifiedSecret || viewModel.isProvisioningToken {
+                Button {
+                    viewModel.createReadOnlyToken(fromSecret: viewModel.nightscoutToken)
+                } label: {
+                    HStack {
+                        if viewModel.isProvisioningToken {
+                            ProgressView()
+                            Text("Creating read-only token…")
+                        } else {
+                            Image(systemName: "wand.and.stars")
+                            Text("That's your API secret — create a read-only token")
+                        }
+                    }
+                }
+                .disabled(viewModel.isProvisioningToken)
+            }
+
+            if let error = viewModel.tokenProvisionError {
+                Text(error)
+                    .font(.footnote)
+                    .foregroundColor(.red)
+            }
+        } header: {
+            Text("Token")
+        } footer: {
+            Text("Paste a Nightscout token. If your site needs one and you only have the API secret, paste that instead — LoopFollow can create a read-only token for you.")
         }
         }
     }
     }
 
 

+ 256 - 14
LoopFollow/Nightscout/NightscoutSettingsViewModel.swift

@@ -27,13 +27,36 @@ class NightscoutSettingsViewModel: ObservableObject {
     @Published var nightscoutToken: String = Storage.shared.token.value {
     @Published var nightscoutToken: String = Storage.shared.token.value {
         willSet {
         willSet {
             if newValue != nightscoutToken {
             if newValue != nightscoutToken {
-                Storage.shared.token.value = newValue
+                Storage.shared.token.value = NightscoutUtils.sanitizeConnectionInput(newValue)
                 triggerCheckStatus()
                 triggerCheckStatus()
             }
             }
         }
         }
     }
     }
 
 
-    @Published var nightscoutStatus: String = String(localized: "Checking...")
+    @Published var nightscoutStatus: String = "Checking..."
+
+    /// The most recent verification error, kept so the onboarding address page can
+    /// tell "reachable Nightscout that needs a token" apart from "can't reach it".
+    @Published var lastError: NightscoutUtils.NightscoutError?
+
+    /// True when the most recent error means the site is a reachable Nightscout
+    /// that simply needs (a different) token.
+    private var errorIsTokenRelated: Bool {
+        switch lastError {
+        case .tokenRequired, .invalidToken: return true
+        default: return false
+        }
+    }
+
+    /// The site responded as a Nightscout instance, even if it needs a token.
+    var addressReachable: Bool {
+        isConnected || errorIsTokenRelated
+    }
+
+    /// The site is reachable but requires a token we don't have yet.
+    var addressNeedsToken: Bool {
+        !isConnected && errorIsTokenRelated
+    }
 
 
     @Published var webSocketEnabled: Bool = Storage.shared.webSocketEnabled.value {
     @Published var webSocketEnabled: Bool = Storage.shared.webSocketEnabled.value {
         didSet {
         didSet {
@@ -47,7 +70,7 @@ class NightscoutSettingsViewModel: ObservableObject {
         }
         }
     }
     }
 
 
-    @Published var webSocketStatus: String = String(localized: "Disconnected")
+    @Published var webSocketStatus: String = "Disconnected"
 
 
     var webSocketStatusColor: Color {
     var webSocketStatusColor: Color {
         switch NightscoutSocketManager.shared.connectionState {
         switch NightscoutSocketManager.shared.connectionState {
@@ -62,6 +85,25 @@ class NightscoutSettingsViewModel: ObservableObject {
     private var checkStatusSubject = PassthroughSubject<Void, Never>()
     private var checkStatusSubject = PassthroughSubject<Void, Never>()
     private var checkStatusWorkItem: DispatchWorkItem?
     private var checkStatusWorkItem: DispatchWorkItem?
 
 
+    /// While confirming a freshly provisioned token, the retry loop owns the
+    /// status label, so the ordinary debounced check is suppressed to avoid
+    /// flickering "Invalid Token" before the server has caught up.
+    private var isConfirmingProvisionedToken = false
+
+    /// Set when a token we just created is correct (the create call returned its
+    /// id, so the secret was valid and the token is a deterministic function of
+    /// it) but the site hasn't started accepting it yet. Some hosts only reload
+    /// their auth cache on a restart, which can take minutes — far longer than we
+    /// can spin during onboarding — so this is treated as a success-pending state
+    /// the user can proceed from, not an error.
+    @Published private(set) var provisionedTokenPending = false
+
+    /// True while a read-only token is being created from the API secret.
+    @Published var isProvisioningToken = false
+
+    /// The most recent token-provisioning failure, for inline display.
+    @Published var tokenProvisionError: String?
+
     init() {
     init() {
         initialURL = Storage.shared.url.value
         initialURL = Storage.shared.url.value
         initialToken = Storage.shared.token.value
         initialToken = Storage.shared.token.value
@@ -84,7 +126,11 @@ class NightscoutSettingsViewModel: ObservableObject {
     private func triggerCheckStatus() {
     private func triggerCheckStatus() {
         checkStatusWorkItem?.cancel()
         checkStatusWorkItem?.cancel()
 
 
-        nightscoutStatus = String(localized: "Checking...")
+        // Any manual edit invalidates a pending-provisioned state and any earlier
+        // "this is your API secret" verdict.
+        provisionedTokenPending = false
+        tokenIsVerifiedSecret = false
+        nightscoutStatus = "Checking..."
 
 
         checkStatusWorkItem = DispatchWorkItem {
         checkStatusWorkItem = DispatchWorkItem {
             self.checkStatusSubject.send()
             self.checkStatusSubject.send()
@@ -93,6 +139,12 @@ class NightscoutSettingsViewModel: ObservableObject {
     }
     }
 
 
     func processURL(_ value: String) {
     func processURL(_ value: String) {
+        // Strip whitespace/newlines/control chars first. A URL can't legally contain
+        // them, and a stray one (e.g. a trailing newline from a paste) otherwise makes
+        // URLComponents parsing fail and falls through to the lossy fallback below,
+        // mangling a URL-with-embedded-token into an invalid address.
+        let value = NightscoutUtils.sanitizeConnectionInput(value)
+
         var useTokenUrl = false
         var useTokenUrl = false
 
 
         if let urlComponents = URLComponents(string: value), let queryItems = urlComponents.queryItems {
         if let urlComponents = URLComponents(string: value), let queryItems = urlComponents.queryItems {
@@ -121,6 +173,7 @@ class NightscoutSettingsViewModel: ObservableObject {
     }
     }
 
 
     func checkNightscoutStatus() {
     func checkNightscoutStatus() {
+        if isConfirmingProvisionedToken { return }
         NightscoutUtils.verifyURLAndToken { error, _, nsWriteAuth, nsAdminAuth in
         NightscoutUtils.verifyURLAndToken { error, _, nsWriteAuth, nsAdminAuth in
             DispatchQueue.main.async {
             DispatchQueue.main.async {
                 Storage.shared.nsWriteAuth.value = nsWriteAuth
                 Storage.shared.nsWriteAuth.value = nsWriteAuth
@@ -131,31 +184,174 @@ class NightscoutSettingsViewModel: ObservableObject {
         }
         }
     }
     }
 
 
+    /// Applies a token that LoopFollow just created and confirms it works.
+    ///
+    /// A freshly created subject isn't always recognized immediately: each
+    /// Nightscout server instance only reloads its in-memory subject cache on a
+    /// write, and multi-instance deployments don't share that cache — so the
+    /// first validation can be routed to an instance that hasn't caught up yet.
+    /// Rather than fail (and make the user tap again), we poll for a few seconds
+    /// with a reassuring status before surfacing any error.
+    func confirmProvisionedToken(_ token: String) {
+        isConfirmingProvisionedToken = true
+        provisionedTokenPending = false
+        isConnected = false
+        nightscoutStatus = "Finishing connection…"
+        nightscoutToken = token
+        verifyProvisionedTokenLoop(attempt: 0)
+    }
+
+    private func verifyProvisionedTokenLoop(attempt: Int) {
+        let maxAttempts = 8
+        NightscoutUtils.verifyURLAndToken { [weak self] error, _, nsWriteAuth, nsAdminAuth in
+            DispatchQueue.main.async {
+                guard let self else { return }
+                if error == nil {
+                    self.isConfirmingProvisionedToken = false
+                    self.provisionedTokenPending = false
+                    Storage.shared.nsWriteAuth.value = nsWriteAuth
+                    Storage.shared.nsAdminAuth.value = nsAdminAuth
+                    self.updateStatusLabel(error: nil)
+                } else if attempt + 1 < maxAttempts {
+                    self.nightscoutStatus = "Finishing connection…"
+                    let delay = min(0.5 + Double(attempt) * 0.25, 2.0)
+                    DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
+                        self.verifyProvisionedTokenLoop(attempt: attempt + 1)
+                    }
+                } else {
+                    // The token is correct but the site hasn't started accepting
+                    // it yet. Surface a calm "pending" state the user can proceed
+                    // from rather than a red error.
+                    self.isConfirmingProvisionedToken = false
+                    self.provisionedTokenPending = true
+                    self.lastError = nil
+                }
+            }
+        }
+    }
+
+    // MARK: - Token provisioning from API secret
+
+    /// Nightscout access tokens look like `name-<16 hex>`; anything else the user
+    /// puts in the token field — most often their API secret — won't match.
+    private static let tokenFormat = "^[^-\\s]+-[0-9a-fA-F]{16}$"
+
+    private func looksLikeToken(_ value: String) -> Bool {
+        value.range(of: Self.tokenFormat, options: .regularExpression) != nil
+    }
+
+    /// Set once the value in the token field has been confirmed to authenticate as
+    /// the site's API secret. Drives the "create a token from this" suggestion.
+    /// This is a verified result (an actual auth probe), not a format guess, so it
+    /// only appears when creating a token will genuinely work.
+    @Published private(set) var tokenIsVerifiedSecret = false
+
+    /// Bumped on every edit so a slow verification for an old value can't land on a
+    /// newer one.
+    private var secretCheckGeneration = 0
+
+    /// After a failed token check, find out whether what the user pasted is in fact
+    /// the API secret — verified against the server, not guessed from its shape.
+    private func verifyTokenIsSecret() {
+        let candidate = nightscoutToken
+        guard !candidate.isEmpty, !looksLikeToken(candidate) else {
+            tokenIsVerifiedSecret = false
+            return
+        }
+        secretCheckGeneration += 1
+        let generation = secretCheckGeneration
+        let url = nightscoutURL
+        Task {
+            let isSecret = await NightscoutUtils.verifyAPISecret(url: url, secret: candidate)
+            await MainActor.run {
+                guard generation == self.secretCheckGeneration,
+                      self.nightscoutToken == candidate else { return }
+                self.tokenIsVerifiedSecret = isSecret
+            }
+        }
+    }
+
+    /// Creates (or reuses) a read-only token from the given API secret and applies
+    /// it. The secret authorizes the create call only and is never stored.
+    func createReadOnlyToken(fromSecret secret: String) {
+        let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return }
+
+        tokenProvisionError = nil
+        isProvisioningToken = true
+        let url = nightscoutURL
+
+        Task {
+            do {
+                let token = try await NightscoutUtils.provisionReadOnlyToken(url: url, secret: trimmed)
+                await MainActor.run {
+                    self.isProvisioningToken = false
+                    self.confirmProvisionedToken(token)
+                }
+            } catch {
+                await MainActor.run {
+                    self.isProvisioningToken = false
+                    self.tokenProvisionError = NightscoutSettingsViewModel.provisioningMessage(for: error)
+                }
+            }
+        }
+    }
+
+    static func provisioningMessage(for error: Error) -> String {
+        guard let nsError = error as? NightscoutUtils.NightscoutError else {
+            return "Could not create a token. Please try again."
+        }
+        switch nsError {
+        case .invalidToken:
+            return "That API secret was rejected. Check it and try again."
+        case .invalidURL, .emptyAddress:
+            return "Please enter a valid site URL first."
+        case .siteNotFound:
+            return "Couldn't reach that site. Check the URL."
+        case .networkError:
+            return "Network error. Check your connection and try again."
+        case .tokenRequired, .unknown:
+            return "Could not create a token. Please try again."
+        }
+    }
+
     func updateStatusLabel(error: NightscoutUtils.NightscoutError?) {
     func updateStatusLabel(error: NightscoutUtils.NightscoutError?) {
+        lastError = error
         if let error = error {
         if let error = error {
             isConnected = false
             isConnected = false
             switch error {
             switch error {
             case .invalidURL:
             case .invalidURL:
-                nightscoutStatus = String(localized: "Invalid URL")
+                nightscoutStatus = "Invalid URL"
             case .networkError:
             case .networkError:
-                nightscoutStatus = String(localized: "Network Error")
+                nightscoutStatus = "Network Error"
             case .invalidToken:
             case .invalidToken:
-                nightscoutStatus = String(localized: "Invalid Token")
+                nightscoutStatus = "Invalid Token"
             case .tokenRequired:
             case .tokenRequired:
-                nightscoutStatus = String(localized: "Token Required")
+                nightscoutStatus = "Token Required"
             case .siteNotFound:
             case .siteNotFound:
-                nightscoutStatus = String(localized: "Site Not Found")
+                nightscoutStatus = "Site Not Found"
             case .unknown:
             case .unknown:
-                nightscoutStatus = String(localized: "Unknown Error")
+                nightscoutStatus = "Unknown Error"
             case .emptyAddress:
             case .emptyAddress:
-                nightscoutStatus = String(localized: "Address Empty")
+                nightscoutStatus = "Address Empty"
             }
             }
             NightscoutSocketManager.shared.disconnect()
             NightscoutSocketManager.shared.disconnect()
+
+            // A site that's reachable but rejects the value as a token is the one
+            // case where the user may have pasted their API secret — verify it so
+            // we can offer to turn it into a token.
+            switch error {
+            case .invalidToken, .tokenRequired:
+                verifyTokenIsSecret()
+            default:
+                tokenIsVerifiedSecret = false
+            }
         } else {
         } else {
             isConnected = true
             isConnected = true
+            tokenIsVerifiedSecret = false
             let authStatus: String
             let authStatus: String
             if Storage.shared.nsAdminAuth.value {
             if Storage.shared.nsAdminAuth.value {
-                authStatus = String(localized: "Admin")
+                authStatus = "Admin"
             } else {
             } else {
                 authStatus = "Read" + (Storage.shared.nsWriteAuth.value ? " & Write" : "")
                 authStatus = "Read" + (Storage.shared.nsWriteAuth.value ? " & Write" : "")
             }
             }
@@ -172,6 +368,52 @@ class NightscoutSettingsViewModel: ObservableObject {
         NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
         NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
     }
     }
 
 
+    // MARK: - Adaptive status (onboarding)
+
+    enum ConnectionStatusKind {
+        case idle
+        case checking
+        case needsToken
+        case pending
+        case connected
+        case error
+    }
+
+    /// A coarse status used to drive the onboarding status pill's color and icon.
+    var statusKind: ConnectionStatusKind {
+        if isConfirmingProvisionedToken { return .checking }
+        if nightscoutURL.isEmpty { return .idle }
+        if isConnected { return .connected }
+        // Token created and correct, just not accepted by the site yet.
+        if provisionedTokenPending { return .pending }
+        if nightscoutStatus == "Checking..." { return .checking }
+        // The site is reachable and simply needs a token — that's an expected
+        // step, not an error, so it's shown positively rather than red.
+        if addressNeedsToken { return .needsToken }
+        return .error
+    }
+
+    /// A friendly, contextual status line that updates as the user fills fields,
+    /// rather than a fixed "Status" label that can read as stale.
+    var friendlyStatus: String {
+        switch statusKind {
+        case .idle:
+            return "Enter your site address to connect."
+        case .checking:
+            return isConfirmingProvisionedToken ? "Finishing connection…" : "Checking your connection…"
+        case .needsToken:
+            return "Site found — it needs a token."
+        case .pending:
+            return "Token created. Your site can take a few minutes to start accepting it — you can continue."
+        case .connected:
+            if Storage.shared.nsAdminAuth.value { return "Connected — admin access" }
+            if Storage.shared.nsWriteAuth.value { return "Connected — read & write" }
+            return "Connected — read-only"
+        case .error:
+            return nightscoutStatus
+        }
+    }
+
     private func observeWebSocketState() {
     private func observeWebSocketState() {
         updateWebSocketStatus()
         updateWebSocketStatus()
         NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged)
         NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged)
@@ -184,8 +426,8 @@ class NightscoutSettingsViewModel: ObservableObject {
 
 
     private func updateWebSocketStatus() {
     private func updateWebSocketStatus() {
         switch NightscoutSocketManager.shared.connectionState {
         switch NightscoutSocketManager.shared.connectionState {
-        case .disconnected: webSocketStatus = String(localized: "Disconnected")
-        case .connecting: webSocketStatus = String(localized: "Connecting...")
+        case .disconnected: webSocketStatus = "Disconnected"
+        case .connecting: webSocketStatus = "Connecting..."
         case .connected: webSocketStatus = "Connected"
         case .connected: webSocketStatus = "Connected"
         case .authenticated: webSocketStatus = "Connected"
         case .authenticated: webSocketStatus = "Connected"
         case .error: webSocketStatus = "Error"
         case .error: webSocketStatus = "Error"

+ 49 - 0
LoopFollow/Onboarding/ConnectionStatusPill.swift

@@ -0,0 +1,49 @@
+// LoopFollow
+// ConnectionStatusPill.swift
+
+import SwiftUI
+
+/// A pinned, color-coded status banner shown at the top of the onboarding
+/// connect screens (Nightscout and Dexcom). It morphs as the connection state
+/// changes, giving both screens the same live, above-the-fold status treatment.
+///
+/// The pill itself is state-agnostic: each connect screen maps its own view
+/// model's status into a `color`, an optional `systemImage`, and whether to show
+/// a spinner, so the two different status enums can share one look.
+struct ConnectionStatusPill: View {
+    let color: Color
+    let message: String
+    var isLoading: Bool = false
+    var systemImage: String?
+
+    var body: some View {
+        HStack(spacing: 10) {
+            Group {
+                if isLoading {
+                    ProgressView().scaleEffect(0.85)
+                } else if let systemImage {
+                    Image(systemName: systemImage).foregroundColor(color)
+                }
+            }
+            .frame(width: 20)
+
+            Text(message)
+                .font(.subheadline.weight(.medium))
+                .foregroundColor(color)
+                .fixedSize(horizontal: false, vertical: true)
+
+            Spacer(minLength: 0)
+        }
+        .padding(.horizontal, 14)
+        .padding(.vertical, 12)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(
+            RoundedRectangle(cornerRadius: 14, style: .continuous)
+                .fill(color.opacity(0.12))
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: 14, style: .continuous)
+                .stroke(color.opacity(0.3), lineWidth: 1)
+        )
+    }
+}

+ 176 - 0
LoopFollow/Onboarding/LoopFollowLogo.swift

@@ -0,0 +1,176 @@
+// LoopFollow
+// LoopFollowLogo.swift
+
+import SwiftUI
+
+/// The LoopFollow mark, rebuilt in SwiftUI as the full app-icon face — a glassy
+/// rounded square with the blue "loop" ring — so it has real visual mass when
+/// tilted in 3D (a bare ring collapses to a line edge-on).
+///
+/// Geometry and layers mirror the icon source artwork (loopfollow-icon.svg,
+/// 1024×1024): all fractions below are the SVG coordinates divided by 1024.
+struct LoopFollowLogo: View {
+    var size: CGFloat = 120
+
+    // Colors sampled from the app icon (loopfollow-icon.svg).
+    private let lightBlue = Color(red: 0.357, green: 0.639, blue: 0.961) // #5BA3F5
+    private let midBlue = Color(red: 0.290, green: 0.565, blue: 0.886) // #4A90E2
+    private let darkBlue = Color(red: 0.227, green: 0.482, blue: 0.784) // #3A7BC8
+
+    var body: some View {
+        let corner = size * 0.225
+        let ringDiameter = size * 0.879 // outer r = 450
+        let holeDiameter = size * 0.615 // inner r = 315
+
+        ZStack {
+            // Glassy near-white card (the icon face).
+            RoundedRectangle(cornerRadius: corner, style: .continuous)
+                .fill(
+                    LinearGradient(
+                        colors: [
+                            Color(red: 0.973, green: 0.976, blue: 0.980), // #F8F9FA
+                            .white,
+                            Color(red: 0.941, green: 0.949, blue: 0.961), // #F0F2F5
+                        ],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+
+            // Soft highlight fading down the top half of the card.
+            LinearGradient(
+                stops: [
+                    .init(color: .white.opacity(0.6), location: 0),
+                    .init(color: .white.opacity(0.3), location: 0.2),
+                    .init(color: .clear, location: 0.5),
+                ],
+                startPoint: .top,
+                endPoint: .bottom
+            )
+
+            // Curved glass reflection over the upper half.
+            radialGlow(
+                width: size * 1.172, height: size * 0.781,
+                stops: [
+                    .init(color: .white.opacity(0.5), location: 0),
+                    .init(color: .white.opacity(0.2), location: 0.5),
+                    .init(color: .clear, location: 1),
+                ]
+            )
+            .offset(y: -size * 0.25)
+            .opacity(0.8)
+
+            // Blue glass disc.
+            Circle()
+                .fill(
+                    LinearGradient(
+                        stops: [
+                            .init(color: lightBlue, location: 0),
+                            .init(color: midBlue, location: 0.3),
+                            .init(color: midBlue, location: 0.7),
+                            .init(color: darkBlue, location: 1),
+                        ],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+                .frame(width: ringDiameter, height: ringDiameter)
+
+            // Darkening toward the disc's outer edge for depth.
+            Circle()
+                .fill(
+                    RadialGradient(
+                        stops: [
+                            .init(color: .clear, location: 0.7),
+                            .init(color: .black.opacity(0.15), location: 0.85),
+                            .init(color: .black.opacity(0.25), location: 1),
+                        ],
+                        center: .center,
+                        startRadius: 0,
+                        endRadius: ringDiameter / 2
+                    )
+                )
+                .frame(width: ringDiameter, height: ringDiameter)
+
+            // White hole that turns the disc into the loop ring.
+            Circle()
+                .fill(Color.white.opacity(0.98))
+                .frame(width: holeDiameter, height: holeDiameter)
+
+            // Glass highlight on the white hole.
+            radialGlow(
+                width: size * 0.742, height: size * 0.391,
+                stops: [
+                    .init(color: .white.opacity(0.4), location: 0),
+                    .init(color: .white.opacity(0.1), location: 0.6),
+                    .init(color: .clear, location: 1),
+                ]
+            )
+            .offset(y: -size * 0.129)
+            .opacity(0.7)
+
+            // Broad sheen across the top of the ring; its lower edge draws the
+            // glass "cut line" just below the middle of the icon.
+            Ellipse()
+                .fill(Color.white.opacity(0.25))
+                .frame(width: size * 0.82, height: size * 0.488)
+                .offset(y: -size * 0.188)
+                .blur(radius: size * 0.003)
+
+            // Subtle shadow pooling under the ring.
+            Ellipse()
+                .fill(Color.black.opacity(0.05))
+                .frame(width: size * 0.879, height: size * 0.195)
+                .offset(y: size * 0.184)
+                .blur(radius: size * 0.002)
+        }
+        .frame(width: size, height: size)
+        .clipShape(RoundedRectangle(cornerRadius: corner, style: .continuous))
+    }
+
+    /// Elliptical radial glow: SwiftUI's RadialGradient is circular, so draw it
+    /// in a circle and squash vertically to match the SVG's elliptical gradients.
+    private func radialGlow(width: CGFloat, height: CGFloat, stops: [Gradient.Stop]) -> some View {
+        Circle()
+            .fill(
+                RadialGradient(
+                    gradient: Gradient(stops: stops),
+                    center: .center,
+                    startRadius: 0,
+                    endRadius: width / 2
+                )
+            )
+            .frame(width: width, height: width)
+            .scaleEffect(x: 1, y: height / width)
+    }
+}
+
+/// LoopFollow logo that lands like a coin: it starts edge-on (rotated 90° about
+/// the vertical axis) and springs open to face the viewer, overshooting a little
+/// past flat and rocking back to rest. Respects Reduce Motion by rendering flat.
+struct AnimatedLoopFollowLogo: View {
+    var size: CGFloat = 140
+
+    @Environment(\.accessibilityReduceMotion) private var reduceMotion
+    @State private var angle: Double = 90
+
+    var body: some View {
+        LoopFollowLogo(size: size)
+            .rotation3DEffect(
+                .degrees(angle),
+                axis: (x: 0, y: 1, z: 0),
+                perspective: 0.7
+            )
+            // Grounding shadow so the landing reads as dimensional.
+            .shadow(color: .black.opacity(0.28), radius: size * 0.08, x: 0, y: size * 0.06)
+            .onAppear {
+                if reduceMotion {
+                    angle = 0
+                } else {
+                    withAnimation(.spring(response: 0.85, dampingFraction: 0.5).delay(0.15)) {
+                        angle = 0
+                    }
+                }
+            }
+    }
+}

+ 138 - 0
LoopFollow/Onboarding/OnboardingContainerView.swift

@@ -0,0 +1,138 @@
+// LoopFollow
+// OnboardingContainerView.swift
+
+import SwiftUI
+
+/// Root of the first-run onboarding wizard. Owns the shared chrome — progress
+/// bar and Back/Next footer — and swaps in the view for the current step.
+struct OnboardingContainerView: View {
+    @StateObject private var viewModel: OnboardingViewModel
+    @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+    init(onClose: @escaping () -> Void) {
+        _viewModel = StateObject(wrappedValue: OnboardingViewModel(onClose: onClose))
+    }
+
+    var body: some View {
+        VStack(spacing: 0) {
+            if viewModel.step.showsProgressHeader {
+                header
+            }
+
+            stepContent
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+            if viewModel.step.usesSharedFooter {
+                footer
+            }
+        }
+        .background(Color(.systemGroupedBackground).ignoresSafeArea())
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
+    }
+
+    // MARK: - Chrome
+
+    private var header: some View {
+        VStack(spacing: 6) {
+            HStack(spacing: 12) {
+                OnboardingProgressBar(progress: viewModel.progress)
+                Button("Skip") { viewModel.skip() }
+                    .font(.subheadline)
+                    .foregroundColor(.secondary)
+            }
+
+            if !viewModel.progressLabel.isEmpty {
+                Text(viewModel.progressLabel)
+                    .font(.caption)
+                    .foregroundColor(.secondary)
+                    .frame(maxWidth: .infinity, alignment: .leading)
+            }
+        }
+        .padding(.horizontal)
+        .padding(.top, 12)
+        .padding(.bottom, 4)
+    }
+
+    @ViewBuilder
+    private var stepContent: some View {
+        let content = Group {
+            switch viewModel.step {
+            case .welcome:
+                WelcomeStepView(viewModel: viewModel)
+            case .overview:
+                OverviewStepView(onboarding: viewModel)
+            case .dataSource:
+                DataSourceChoiceStepView(viewModel: viewModel)
+            case .connect:
+                switch viewModel.dataSource {
+                case .dexcom:
+                    DexcomConnectStepView(viewModel: viewModel.dexcomViewModel, onboarding: viewModel)
+                case .copyFromPhone:
+                    ConnectImportStepView(viewModel: viewModel)
+                default:
+                    NightscoutConnectStepView(viewModel: viewModel.nightscoutViewModel, onboarding: viewModel)
+                }
+            case .units:
+                UnitsStepView(onboarding: viewModel)
+            case .generalAlarms:
+                GeneralAlarmsStepView()
+            case .alarms:
+                AlarmsStepView(viewModel: viewModel)
+            case .tabOrder:
+                TabOrderStepView()
+            case .notifications:
+                NotificationsStepView(viewModel: viewModel)
+            case .telemetry:
+                TelemetryStepView(viewModel: viewModel)
+            case .completion:
+                CompletionStepView(viewModel: viewModel)
+            }
+        }
+
+        if reduceMotion {
+            content.id(viewModel.step)
+        } else {
+            content
+                .id(viewModel.step)
+                .transition(.asymmetric(
+                    insertion: .move(edge: .trailing).combined(with: .opacity),
+                    removal: .move(edge: .leading).combined(with: .opacity)
+                ))
+        }
+    }
+
+    private var footer: some View {
+        OnboardingNavFooter(
+            continueEnabled: viewModel.canProceed,
+            showBack: viewModel.canGoBack,
+            onBack: { withStepAnimation { viewModel.goBack() } },
+            onContinue: { withStepAnimation { viewModel.advance() } }
+        )
+    }
+
+    private func withStepAnimation(_ change: () -> Void) {
+        if reduceMotion {
+            change()
+        } else {
+            withAnimation(.easeInOut(duration: 0.3)) { change() }
+        }
+    }
+}
+
+/// Thin segmented progress indicator shown at the top of each chrome'd step.
+private struct OnboardingProgressBar: View {
+    let progress: Double
+
+    var body: some View {
+        GeometryReader { geo in
+            ZStack(alignment: .leading) {
+                Capsule()
+                    .fill(Color(.systemGray5))
+                Capsule()
+                    .fill(Color.accentColor)
+                    .frame(width: max(0, min(1, progress)) * geo.size.width)
+            }
+        }
+        .frame(height: 6)
+    }
+}

+ 39 - 0
LoopFollow/Onboarding/OnboardingNavFooter.swift

@@ -0,0 +1,39 @@
+// LoopFollow
+// OnboardingNavFooter.swift
+
+import SwiftUI
+
+/// The shared Back / Continue footer used both by the container's chrome and by
+/// phases that manage their own internal pages (connect, alarms), so the controls
+/// look and behave identically everywhere.
+struct OnboardingNavFooter: View {
+    var continueTitle: String = "Continue"
+    var continueEnabled: Bool = true
+    var showBack: Bool = true
+    var onBack: () -> Void
+    var onContinue: () -> Void
+
+    var body: some View {
+        HStack {
+            if showBack {
+                Button(action: onBack) {
+                    Label("Back", systemImage: "chevron.left")
+                        .font(.body.weight(.medium))
+                }
+                .buttonStyle(.bordered)
+            }
+
+            Spacer()
+
+            Button(action: onContinue) {
+                Text(continueTitle)
+                    .font(.body.weight(.semibold))
+                    .frame(minWidth: 120)
+            }
+            .buttonStyle(.borderedProminent)
+            .disabled(!continueEnabled)
+        }
+        .padding()
+        .background(.bar)
+    }
+}

+ 69 - 0
LoopFollow/Onboarding/OnboardingStep.swift

@@ -0,0 +1,69 @@
+// LoopFollow
+// OnboardingStep.swift
+
+import Foundation
+
+/// The phases of the first-run onboarding wizard.
+///
+/// Progress is tracked by phase, not by page: the set of phases is stable, while
+/// some phases contain a variable number of internal pages (the Nightscout connect
+/// phase can be one or two pages; the alarms phase is one page per offered alarm,
+/// which differs between Nightscout and Dexcom). Those phases report their
+/// within-phase position through `OnboardingViewModel.phaseProgress`, so the
+/// overall progress bar stays smooth no matter how many pages a phase has.
+///
+/// `connect` renders the Nightscout, Dexcom, or import view depending on the data
+/// source the user picks in `dataSource`.
+enum OnboardingStep: CaseIterable, Hashable {
+    case welcome
+    case overview
+    case dataSource
+    case connect
+    case units
+    case generalAlarms
+    case alarms
+    case tabOrder
+    case notifications
+    case telemetry
+    case completion
+
+    /// Steps that show the progress bar + phase label + Skip at the top. The
+    /// welcome and completion screens are full-bleed and provide their own CTA.
+    var showsProgressHeader: Bool {
+        switch self {
+        case .welcome, .completion:
+            return false
+        default:
+            return true
+        }
+    }
+
+    /// Steps that use the shared Back / Continue footer. Phases that contain their
+    /// own internal pages or custom primary buttons (overview, connect, units,
+    /// alarms, notifications, telemetry) supply their own footer instead, as do the
+    /// full-bleed welcome and completion screens.
+    var usesSharedFooter: Bool {
+        switch self {
+        case .dataSource, .generalAlarms, .tabOrder:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Short name shown in the progress header for this phase.
+    var phaseTitle: String {
+        switch self {
+        case .welcome, .completion: return ""
+        case .overview: return "Overview"
+        case .dataSource: return "Data source"
+        case .connect: return "Connect"
+        case .units: return "Units & metrics"
+        case .generalAlarms: return "Alarm basics"
+        case .alarms: return "Alarms"
+        case .tabOrder: return "Tabs"
+        case .notifications: return "Notifications"
+        case .telemetry: return "Privacy"
+        }
+    }
+}

+ 38 - 0
LoopFollow/Onboarding/OnboardingStepHeader.swift

@@ -0,0 +1,38 @@
+// LoopFollow
+// OnboardingStepHeader.swift
+
+import SwiftUI
+
+/// Consistent icon + title + subtitle header used at the top of each step body.
+///
+/// The header deliberately carries no horizontal margin: it shares whatever
+/// leading edge its container uses, so it lines up exactly with the content
+/// below it — the section cards in a `Form`, or the `.padding(.horizontal)`
+/// cards in a plain `VStack`. Callers add their own horizontal padding to match.
+struct OnboardingStepHeader: View {
+    let systemImage: String
+    let title: String
+    let subtitle: String
+
+    var body: some View {
+        // Left-aligned: justified/centered body copy is harder to read, so the
+        // header reads as a natural top-down intro.
+        VStack(alignment: .leading, spacing: 12) {
+            Image(systemName: systemImage)
+                .font(.system(size: 44, weight: .semibold))
+                .foregroundStyle(Color.accentColor)
+                .padding(.bottom, 2)
+
+            Text(title)
+                .font(.title2.weight(.bold))
+                .multilineTextAlignment(.leading)
+
+            Text(subtitle)
+                .font(.subheadline)
+                .foregroundColor(.secondary)
+                .multilineTextAlignment(.leading)
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(.top, 8)
+    }
+}

+ 0 - 0
LoopFollow/Onboarding/OnboardingViewModel.swift


Някои файлове не бяха показани, защото твърде много файлове са промени