فهرست منبع

合并 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'
         run: |
           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
       - 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"
           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
   # creates new certs if the repository variable ENABLE_NUKE_CERTS == 'true'
   # 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_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' )
 
-  # Builds LoopFollow
+  # Builds the app
   build:
     name: Build
     needs: [check_certs, check_status]
@@ -203,7 +227,7 @@ jobs:
       - name: Sync clock
         run: sudo sntp -sS time.windows.com
 
-      # Build signed LoopFollow IPA file
+      # Build signed IPA file
       - name: Fastlane Build & Archive
         run: bundle exec fastlane build_LoopFollow
         env:

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

@@ -17,6 +17,10 @@ on:
   push:
     branches:
       - 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:
   tag:

+ 1 - 1
Config.xcconfig

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

+ 1 - 1
Gemfile

@@ -1,2 +1,2 @@
 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)
     atomos (0.1.3)
     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-partitions (~> 1, >= 1.992.0)
       aws-sigv4 (~> 1.9)
@@ -17,10 +17,10 @@ GEM
       bigdecimal
       jmespath (~> 1, >= 1.6.1)
       logger
-    aws-sdk-kms (1.128.0)
+    aws-sdk-kms (1.129.0)
       aws-sdk-core (~> 3, >= 3.248.0)
       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-kms (~> 1)
       aws-sigv4 (~> 1.5)
@@ -42,8 +42,9 @@ GEM
     domain_name (0.6.20240107)
     dotenv (2.8.1)
     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_synchrony (~> 1.0)
       faraday-excon (~> 1.1)
@@ -72,10 +73,10 @@ GEM
     faraday_middleware (1.2.1)
       faraday (~> 1.0)
     fastimage (2.4.1)
-    fastlane (2.235.0)
+    fastlane (2.237.0)
       CFPropertyList (>= 2.3, < 5.0.0)
       abbrev (~> 0.1)
-      addressable (>= 2.8, < 3.0.0)
+      addressable (>= 2.9.0, < 3.0.0)
       artifactory (~> 3.0)
       aws-sdk-s3 (~> 1.197)
       babosa (>= 1.0.3, < 2.0.0)
@@ -87,7 +88,7 @@ GEM
       csv (~> 3.3)
       dotenv (>= 2.1.1, < 3.0.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-cookie_jar (~> 0.0.6)
       faraday_middleware (~> 1.0)
@@ -101,9 +102,10 @@ GEM
       highline (~> 2.0)
       http-cookie (~> 1.0.5)
       json (< 3.0.0)
-      jwt (>= 2.1.0, < 4)
+      jwt (>= 2.10.3, < 4)
       logger (>= 1.6, < 2.0)
       mini_magick (>= 4.9.4, < 5.0.0)
+      multi_json (~> 1.12)
       multipart-post (>= 2.0.0, < 3.0.0)
       mutex_m (~> 0.3)
       naturally (~> 2.2)
@@ -124,7 +126,7 @@ GEM
       xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
     fastlane-sirp (1.1.0)
     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.18.0)
       addressable (~> 2.5, >= 2.5.1)
@@ -134,20 +136,20 @@ GEM
       mutex_m
       representable (~> 3.0)
       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-playcustomapp_v1 (0.17.0)
+    google-apis-playcustomapp_v1 (0.18.0)
       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-cloud-core (1.8.0)
+    google-cloud-core (1.9.0)
       google-cloud-env (>= 1.0, < 3.a)
       google-cloud-errors (~> 1.0)
     google-cloud-env (2.2.2)
       base64 (~> 0.2)
       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)
       digest-crc (~> 0.4)
       google-apis-core (>= 0.18, < 2)
@@ -157,13 +159,13 @@ GEM
       googleauth (~> 1.9)
       mini_mime (~> 1.0)
     google-logging-utils (0.2.0)
-    googleauth (1.16.2)
+    googleauth (1.17.1)
       faraday (>= 1.0, < 3.a)
       google-cloud-env (~> 2.2)
       google-logging-utils (~> 0.1)
       jwt (>= 1.4, < 4.0)
-      multi_json (~> 1.11)
       os (>= 0.9, < 2.0)
+      pstore (~> 0.1)
       signet (>= 0.16, < 2.a)
     highline (2.0.3)
     http-cookie (1.0.8)
@@ -171,7 +173,7 @@ GEM
     httpclient (2.9.0)
       mutex_m
     jmespath (1.6.2)
-    json (2.19.7)
+    json (2.20.0)
     jwt (3.2.0)
       base64
     logger (1.7.0)
@@ -182,11 +184,12 @@ GEM
     mutex_m (0.3.0)
     nanaimo (0.4.0)
     naturally (2.3.0)
-    nkf (0.2.0)
+    nkf (0.3.0)
     optparse (0.8.1)
     os (1.1.4)
     ostruct (0.6.3)
     plist (3.7.2)
+    pstore (0.2.1)
     public_suffix (7.0.5)
     rake (13.4.2)
     representable (3.2.0)
@@ -199,11 +202,10 @@ GEM
     ruby2_keywords (0.0.5)
     rubyzip (2.4.1)
     security (0.1.5)
-    signet (0.21.0)
+    signet (0.22.0)
       addressable (~> 2.8)
       faraday (>= 0.17.5, < 3.a)
       jwt (>= 1.5, < 4.0)
-      multi_json (~> 1.10)
     simctl (1.6.10)
       CFPropertyList
       naturally
@@ -218,12 +220,14 @@ GEM
     uber (0.1.0)
     unicode-display_width (2.6.0)
     word_wrap (1.0.0)
-    xcodeproj (1.27.0)
+    xcodeproj (1.28.1)
       CFPropertyList (>= 2.3.3, < 4.0)
       atomos (~> 0.1.3)
+      base64
       claide (>= 1.0.2, < 2.0)
       colored2 (~> 3.1)
       nanaimo (~> 0.4.0)
+      nkf
       rexml (>= 3.3.6, < 4.0)
     xcpretty (0.4.1)
       rouge (~> 3.28.0)
@@ -235,7 +239,7 @@ PLATFORMS
   ruby
 
 DEPENDENCIES
-  fastlane (= 2.235.0)
+  fastlane (= 2.237.0)
 
 BUNDLED WITH
   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
     @Environment(\.dismiss) private var dismiss
 
+    @State private var searchText = ""
+
     private let columns = [
         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 {
         NavigationStack {
             ScrollView {
                 LazyVGrid(columns: columns, spacing: 16) {
                     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)
                                 .font(.headline)
                                 .frame(maxWidth: .infinity, alignment: .leading)
                                 .padding(.horizontal, 4)
                             ) {
-                                ForEach(AlarmType.allCases.filter { $0.group == group }, id: \.self) { type in
+                                ForEach(types, id: \.self) { type in
                                     AlarmTile(type: type) {
                                         onSelect(type)
                                     }
@@ -32,8 +43,25 @@ struct AddAlarmSheet: View {
                     }
                 }
                 .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")
+            .searchable(text: $searchText, prompt: "Search alarms")
             .toolbar {
                 ToolbarItem(placement: .cancellationAction) {
                     Button("Cancel") { dismiss() }

+ 60 - 37
LoopFollow/Alarm/Alarm.swift

@@ -11,13 +11,7 @@ protocol DayNightDisplayable {
 
 extension DayNightDisplayable where Self: RawRepresentable, Self.RawValue == 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
     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,
     var monitoringWindow: Int?
 
@@ -107,7 +112,8 @@ struct Alarm: Identifiable, Codable, Equatable {
     enum CodingKeys: String, CodingKey {
         case id, type, name, isEnabled, snoozedUntil
         case aboveBG, belowBG, threshold, predictiveMinutes, delta
-        case persistentMinutes, monitoringWindow, soundFile
+        case persistentMinutes, suppressIfRising, suppressIfFalling, suppressIfCharging
+        case monitoringWindow, soundFile
         case snoozeDuration, playSoundOption, repeatSoundOption
         case soundDelay, activeOption
         case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits
@@ -130,6 +136,9 @@ struct Alarm: Identifiable, Codable, Equatable {
         predictiveMinutes = try container.decodeIfPresent(Int.self, forKey: .predictiveMinutes)
         delta = try container.decodeIfPresent(Double.self, forKey: .delta)
         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)
         soundFile = try container.decode(SoundFile.self, forKey: .soundFile)
         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(delta, forKey: .delta)
         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.encode(soundFile, forKey: .soundFile)
         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 {
-            AlarmSound.setSoundFile(str: soundFile.rawValue)
+            AlarmSound.setSoundFile(soundFile)
             // Only use delay if repeating is enabled, otherwise delay doesn't make sense
             let delay = shouldRepeat ? soundDelay : 0
             AlarmSound.play(repeating: shouldRepeat, delay: delay)
@@ -306,8 +318,8 @@ struct Alarm: Identifiable, Codable, Equatable {
             persistentMinutes = 0
         case .fastDrop:
             soundFile = .bigClockTicking
-            delta = 18
-            monitoringWindow = 2
+            delta = 15
+            monitoringWindow = 3
         case .fastRise:
             soundFile = .cartoonFailStringsTrumpet
             delta = 10
@@ -317,7 +329,7 @@ struct Alarm: Identifiable, Codable, Equatable {
             threshold = 16
         case .notLooping:
             soundFile = .sciFiEngineShutDown
-            threshold = 31
+            threshold = 16
         case .missedBolus:
             soundFile = .dholShuffleloop
             monitoringWindow = 15
@@ -370,6 +382,15 @@ struct Alarm: Identifiable, Codable, Equatable {
             snoozeDuration = 0
             aboveBG = 180
             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:
             return .insulin
         case .battery, .batteryDrop, .pump, .pumpBattery, .pumpChange,
-             .sensorChange, .notLooping, .buildExpire:
+             .sensorChange, .notLooping, .buildExpire, .dbSize:
             return .device
         case .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd:
             return .other
@@ -416,6 +437,7 @@ extension AlarmType {
         case .sensorChange: return "sensor.tag.radiowaves.forward"
         case .notLooping: return "circle.slash"
         case .buildExpire: return "calendar.badge.exclamationmark"
+        case .dbSize: return "externaldrive.badge.exclamationmark"
         case .overrideStart: return "play.circle"
         case .overrideEnd: return "stop.circle"
         case .tempTargetStart: return "flag"
@@ -426,29 +448,30 @@ extension AlarmType {
 
     var blurb: String {
         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()
     /// pure, per-alarm logic against `AlarmData`
     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 {
+    var checksOwnBGLimits: Bool { false }
+
     /// Returns `true` when the alarm is allowed to continue evaluating
     /// after BG-limit checks; `false` blocks it immediately.
     func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool {
@@ -24,7 +30,14 @@ extension AlarmCondition {
         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 }
 

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

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

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

@@ -4,17 +4,23 @@
 import Foundation
 
 /// 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 {
     static let type: AlarmType = .low
     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 {
         // ────────────────────────────────
         // 0. sanity checks
         // ────────────────────────────────
         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 {
             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 recentBoluses: [BolusEntry]
     let latestBattery: Double?
+    let latestBatteryIsCharging: Bool?
     let latestPumpBattery: Double?
     let batteryHistory: [DataStructs.batteryStruct]
     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 .missedBolus: MissedBolusAlarmEditor(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
 
 import SwiftUI
+import UniformTypeIdentifiers
 
 struct AlarmAudioSection: View {
     @Binding var alarm: Alarm
@@ -111,27 +112,37 @@ private struct TonePickerSheet: View {
     @Binding var selected: SoundFile
     @Environment(\.dismiss) private var dismiss
 
+    @State private var customSounds: [CustomSound] = []
+    @State private var showingImporter = false
+    @State private var importError: String?
+
     var body: some View {
         NavigationView {
             ScrollViewReader { proxy in
                 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 {
-                            selected = tone
-                            AlarmSound.setSoundFile(str: tone.rawValue)
-                            AlarmSound.stop()
-                            AlarmSound.playTest()
+                            showingImporter = true
                         } 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")
@@ -144,9 +155,81 @@ private struct TonePickerSheet: View {
                     }
                 }
                 .onAppear {
+                    reloadCustomSounds()
                     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
 
-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 {
-        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
             )
 
+            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)
             AlarmAudioSection(alarm: $alarm)
             AlarmSnoozeSection(alarm: $alarm)

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

@@ -8,22 +8,23 @@ struct LowBgAlarmEditor: View {
 
     var body: some View {
         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)
 
             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,
                 value: $alarm.belowBG
             )
 
             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,
                 step: 5,
                 unitLabel: alarm.type.snoozeTimeUnit.label,
@@ -31,15 +32,25 @@ struct LowBgAlarmEditor: View {
             )
 
             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,
                 step: 5,
                 unitLabel: alarm.type.snoozeTimeUnit.label,
                 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)
             AlarmAudioSection(alarm: $alarm)
             AlarmSnoozeSection(alarm: $alarm)

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

@@ -25,6 +25,14 @@ struct PhoneBatteryAlarmEditor: View {
                 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)
             AlarmAudioSection(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 deleteAfterDismiss: UUID?
     @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
 
     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)
     }
 
     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)
     }
 
     private var inactiveAlarms: [Alarm] {
-        store.value.filter { !$0.isEnabled }
+        store.value.filter { !$0.isEnabled && matches($0) }
             .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
             sheetContent(for: info)
         }
@@ -90,6 +127,10 @@ struct AlarmListView: View {
                 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)
     }
 
@@ -111,7 +152,7 @@ struct AlarmListView: View {
                     Text(alarm.name)
                         .foregroundColor(.primary)
 
-                    if let until = alarm.snoozedUntil, until > Date() {
+                    if let until = alarm.snoozedUntil, until > now {
                         HStack(spacing: 4) {
                             Image(systemName: "zzz")
                                 .font(.caption2)
@@ -154,6 +195,9 @@ struct AlarmListView: View {
             AddAlarmSheet { type in
                 let new = Alarm(type: type)
                 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)
             }
 

+ 10 - 0
LoopFollow/Alarm/AlarmManager.swift

@@ -34,6 +34,7 @@ class AlarmManager {
             BatteryCondition.self,
             BatteryDropCondition.self,
             FutureCarbsCondition.self,
+            DBSizeCondition.self,
         ]
     ) {
         var dict = [AlarmType: AlarmCondition]()
@@ -45,6 +46,15 @@ class AlarmManager {
         let now = Date()
         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
 
         // 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.
     var snoozeTimeUnit: TimeUnit {
         switch self {
-        case .buildExpire:
+        case .buildExpire, .dbSize:
             return .day
         case .low, .high, .fastDrop, .fastRise,
              .missedReading, .notLooping, .missedBolus,

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

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

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

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

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

@@ -29,6 +29,7 @@ enum AlarmType: String, CaseIterable, Codable {
     case tempTargetStart = "Temp Target Started"
     case tempTargetEnd = "Temp Target Ended"
     case buildExpire = "Looping app expiration"
+    case dbSize = "Nightscout Database Size"
 }
 
 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
 
 import AVFoundation
-import EventKit
 import UIKit
 import UserNotifications
 
@@ -13,22 +12,40 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         LogManager.shared.log(category: .general, message: "App started")
         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
 
-        _ = 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
         _ = VolumeButtonHandler.shared
 
@@ -49,42 +72,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
         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
@@ -93,19 +98,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
     // MARK: - BFU recovery
 
     @objc private func protectedDataDidBecomeAvailable() {
-        performBFUReloadIfNeeded()
+        completeStorageRecovery()
     }
 
     @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)
     }
 
@@ -210,7 +214,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
 extension Notification.Name {
     /// 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")
 }
 

+ 18 - 9
LoopFollow/Application/LoopFollowApp.swift

@@ -6,18 +6,27 @@ import SwiftUI
 @main
 struct LoopFollowApp: App {
     @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
+    @ObservedObject private var storageReady = StorageReadiness.ready
 
     var body: some Scene {
         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 statisticsPosition = Storage.shared.statisticsPosition
     @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition
+    @ObservedObject private var activeBanner = Observable.shared.activeBanner
 
     @State private var showTelemetryConsent = false
+    @State private var showOnboarding = false
 
     private var orderedItems: [TabItem] {
         Storage.shared.orderedTabBarItems()
     }
 
     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) {
             ForEach(Array(orderedItems.prefix(4).enumerated()), id: \.element) { index, item in
                 tabContent(for: item)
@@ -47,21 +64,61 @@ struct MainTabView: View {
             // onAppear (not app launch) keeps it off the BG-only refresh path.
             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.
             TelemetryConsentView()
                 .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
     private func tabContent(for item: TabItem) -> some View {
         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 {
     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] = []
 
     private var centralManager: CBCentralManager!
     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() {
         super.init()
+        BLEManager.isInitialized = true
 
         centralManager = CBCentralManager(
             delegate: self,
             queue: .main
         )
-        if let device = Storage.shared.selectedBLEDevice.value {
-            devices.append(device)
-            findAndUpdateDevice(with: device.id.uuidString) { device in
-                device.rssi = 0
+        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? {
@@ -49,6 +87,8 @@ class BLEManager: NSObject, ObservableObject {
             activeDevice = nil
             device.lastHeartbeatTime = nil
         }
+        heartbeatDropoutTimes.removeAll()
+        BannerManager.shared.clear(.heartbeat)
         Storage.shared.selectedBLEDevice.value = nil
     }
 
@@ -208,6 +248,7 @@ extension BLEManager: BluetoothDeviceDelegate {
                 let delay = elapsedTime - expectedInterval
                 LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)")
             }
+            recordHeartbeatOutcome(elapsed: elapsedTime, expectedInterval: expectedInterval, now: now)
         } else {
             LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)")
         }
@@ -216,6 +257,55 @@ extension BLEManager: BluetoothDeviceDelegate {
 
         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 {

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

@@ -4,7 +4,6 @@
 import CoreBluetooth
 import Foundation
 import os
-import UIKit
 
 class BluetoothDevice: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
     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) {
         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) {
-        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()
 
         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
         let alerts: [BackgroundAlert] = [

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 2182
LoopFollow/Controllers/Graphs.swift


+ 4 - 3
LoopFollow/Controllers/NightScout.swift

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

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

@@ -2,7 +2,6 @@
 // BGData.swift
 
 import Foundation
-import UIKit
 
 extension MainViewController {
     /// 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
             if let error = err {
                 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()
                 return
             }
@@ -32,6 +32,8 @@ extension MainViewController {
                 return
             }
 
+            BannerManager.shared.clear(.dexcom)
+
             // If Dex data is old, load from NS instead
             let latestDate = data[0].date
             let now = dateTimeUtils.getNowTimeIntervalUTC()
@@ -60,6 +62,7 @@ extension MainViewController {
     func webLoadNSBGData(dexData: [ShareGlucoseData] = []) {
         // This kicks it out in the instance where dexcom fails but they aren't using NS &&
         if !IsNightscoutEnabled() {
+            BannerManager.shared.clear(.nightscout)
             Storage.shared.lastBGChecked.value = Date()
             return
         }
@@ -75,6 +78,7 @@ extension MainViewController {
         NightscoutUtils.executeRequest(eventType: .sgv, parameters: parameters) { (result: Result<[ShareGlucoseData], Error>) in
             switch result {
             case let .success(entriesResponse):
+                BannerManager.shared.clear(.nightscout)
                 var nsData = entriesResponse
                 DispatchQueue.main.async {
                     // transform NS data to look like Dex data
@@ -103,6 +107,7 @@ extension MainViewController {
                 }
             case let .failure(error):
                 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 {
                     TaskScheduler.shared.rescheduleTask(
                         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
 
             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
     func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) {
         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])
 
         // 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 {
                     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
                 } 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
-                    infoManager.updateInfoData(type: .pump, value: "50+U")
+                    infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0)
                     Storage.shared.lastPumpReservoirU.value = nil
                 }
             }
@@ -126,21 +129,23 @@ extension MainViewController {
             if let pumpBatteryRecord = lastPumpRecord["battery"] as? [String: AnyObject],
                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
             }
 
             if let uploader = lastDeviceStatus?["uploader"] as? [String: AnyObject],
                let upbat = uploader["battery"] as? Double
             {
+                let isCharging = uploader["isCharging"] as? Bool
                 let batteryText: String
-                if let isCharging = uploader["isCharging"] as? Bool, isCharging {
+                if isCharging == true {
                     batteryText = "⚡️ " + String(format: "%.0f", upbat) + "%"
                 } else {
                     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.deviceBatteryIsCharging.value = isCharging
 
                 let timestamp = uploader["timestamp"] as? Date ?? Date()
                 let currentBattery = DataStructs.batteryStruct(batteryLevel: upbat, timestamp: timestamp)
@@ -155,6 +160,17 @@ extension MainViewController {
 
         // Loop - handle new data
         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)
 
             var oText = ""
@@ -188,6 +204,17 @@ extension MainViewController {
             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
         let now = dateTimeUtils.getNowTimeIntervalUTC()
         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]
                 Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(prediction.last!))))
                 Observable.shared.predictionColor.value = .purple
-                if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime {
+                if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime || predictionData.isEmpty {
                     predictionData.removeAll()
                     var predictionTime = lastLoopTime
                     let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
@@ -101,9 +101,11 @@ extension MainViewController {
                 updatePredictionGraph()
             }
             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
+            } else {
+                infoManager.clearInfoData(type: .recBolus)
+                Observable.shared.deviceRecBolus.value = nil
             }
             if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {
                 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 HealthKit
-import UIKit
 
 extension MainViewController {
     func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) {
@@ -20,7 +19,7 @@ extension MainViewController {
 
             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
             {
                 updatedTime = parsedTime
@@ -32,7 +31,9 @@ extension MainViewController {
             // ISF
             let profileISF = profileManager.currentISF()
             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)
             }
             if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF {
@@ -44,9 +45,14 @@ extension MainViewController {
             }
 
             // 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()
             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+)?)"
                 if let regex = try? NSRegularExpression(pattern: pattern) {
                     let nsString = reasonString as NSString
@@ -107,10 +113,11 @@ extension MainViewController {
             }
 
             // 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 {
+                infoManager.clearInfoData(type: .recBolus)
                 Observable.shared.deviceRecBolus.value = nil
             }
 
@@ -155,7 +162,9 @@ extension MainViewController {
             }
 
             // 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)
                 Storage.shared.lastTdd.value = tddMetric.value
             }

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

@@ -55,7 +55,7 @@ extension MainViewController {
             formatter.zeroFormattingBehavior = [.pad]
 
             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
         }
 
-        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 {
             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
 
             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
 
 import Foundation
-import UIKit
 
 extension MainViewController {
     // NS BG Check Response Processor

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

@@ -10,6 +10,7 @@ extension MainViewController {
         carbData.removeAll()
         var lastFoundIndex = 0
         var lastFoundBolus = 0
+        var lastFoundSmb = 0
 
         for currentEntry in entries.reversed() {
             var carbDate: String
@@ -35,7 +36,10 @@ extension MainViewController {
                 let bolusTime = findNearestBolusbyTime(timeWithin: 300, needle: dateTimeStamp, haystack: bolusData, startingIndex: lastFoundBolus)
                 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)) {
@@ -81,7 +85,7 @@ extension MainViewController {
         }
 
         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
     }
 }

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

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

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

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

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

@@ -2,12 +2,12 @@
 // Overrides.swift
 
 import Foundation
-import UIKit
 
 extension MainViewController {
     func processNSOverrides(entries: [[String: AnyObject]]) {
         overrideGraphData.removeAll()
         var activeOverrideNote: String?
+        var activeOverrideEndAt: TimeInterval?
 
         let sorted = entries.sorted { lhs, rhs in
             guard
@@ -36,19 +36,28 @@ extension MainViewController {
 
             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
+                trueEnd = trueEnd.map { min($0, nextStart - 60) }
             }
 
             end = min(end, maxEndDate)
@@ -69,17 +78,24 @@ extension MainViewController {
                     return [lo, hi]
                 }(),
                 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
             )
             overrideGraphData.append(dot)
 
             if now >= start, now < end {
                 activeOverrideNote = e["notes"] as? String ?? e["reason"] as? String
+                activeOverrideEndAt = trueEnd
             }
         }
 
         Observable.shared.override.value = activeOverrideNote
+        Observable.shared.overrideEndAt.value = activeOverrideEndAt
         if Storage.shared.device.value != "Loop" {
             if let note = activeOverrideNote {
                 infoManager.updateInfoData(type: .override, value: note)
@@ -90,5 +106,9 @@ extension MainViewController {
         if Storage.shared.graphOtherTreatments.value {
             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 {
             updateSmbGraph()
-            updateChartRenderers()
         }
     }
 }

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

@@ -3,13 +3,13 @@
 
 import Foundation
 import HealthKit
-import UIKit
 
 extension MainViewController {
     // NS Temporary Target Response Processor
     func processNSTemporaryTarget(entries: [[String: AnyObject]]) {
         tempTargetGraphData.removeAll()
         var activeTempTarget: Int?
+        var activeTempTargetEndAt: TimeInterval?
 
         for (index, currentEntry) in entries.reversed().enumerated() {
             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 }) {
                     tempTargetGraphData[activeIndex].endDate = dateTimeStamp
                     activeTempTarget = nil
+                    activeTempTargetEndAt = nil
                 }
                 continue
             }
@@ -59,12 +60,14 @@ extension MainViewController {
             let currentTime = Date().timeIntervalSince1970
             if currentTime < endDate {
                 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 {
             updateTempTargetGraph()
-            updateChartRenderers()
         }
 
         if let target = activeTempTarget {
@@ -74,5 +77,10 @@ extension MainViewController {
         } else {
             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,
                 pieHigh: model.pieHigh
             )
-            .frame(width: 100, height: 100)
+            .frame(width: 80, height: 80)
+            .padding(.leading, 8)
 
             VStack(spacing: 10) {
                 HStack {
@@ -48,37 +49,31 @@ struct StatsDisplayView: View {
     }
 }
 
-struct StatsPieChartView: UIViewRepresentable {
+struct StatsPieChartView: View {
     var pieLow: Double
     var pieRange: 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
 
 import Foundation
-import UIKit
 
 extension MainViewController {
     func startGraphNowTimer(time: TimeInterval = 60) {
+        graphNowTimer.invalidate()
         graphNowTimer = Timer.scheduledTimer(timeInterval: time,
                                              target: self,
                                              selector: #selector(MainViewController.graphNowTimerDidEnd(_:)),
@@ -14,6 +14,6 @@ extension MainViewController {
     }
 
     @objc func graphNowTimerDidEnd(_: Timer) {
-        createVerticalLines()
+        chartModel.rebuild()
     }
 }

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

@@ -6,14 +6,8 @@ import Foundation
 
 extension EKEventStore {
     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 AppConstants {
-    static let APP_GROUP_ID = "group.com.$(unique_id).LoopFollow"
-
     /// Extracts the app suffix from the bundle identifier
     /// Bundle identifier format: com.$(unique_id).LoopFollow$(app_suffix)
     /// Returns the suffix part (e.g., "2" for "com.example.LoopFollow2")

+ 1 - 1
LoopFollow/Helpers/BackgroundRefreshManager.swift

@@ -2,7 +2,7 @@
 // BackgroundRefreshManager.swift
 
 import BackgroundTasks
-import UIKit
+import Foundation
 
 class 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
 // NightscoutUtils.swift
 
+import CryptoKit
 import Foundation
 
 class NightscoutUtils {
@@ -44,6 +45,7 @@ class NightscoutUtils {
         case iage = "Insulin Change"
         case temporaryOverride = "Temporary Override"
         case temporaryOverrideCancel = "Temporary Override Cancel"
+        case dbSize
 
         var endpoint: String {
             switch self {
@@ -57,6 +59,8 @@ class NightscoutUtils {
                 return "/api/v1/devicestatus.json"
             case .temporaryOverride, .temporaryOverrideCancel:
                 return "/api/v2/notifications/loop"
+            case .dbSize:
+                return "/api/v2/properties/dbsize"
             }
         }
     }
@@ -162,6 +166,17 @@ class NightscoutUtils {
         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? {
         var components = URLComponents(string: baseURL)
         components?.path = endpoint
@@ -176,7 +191,10 @@ class NightscoutUtils {
             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
     }
@@ -240,8 +258,14 @@ class NightscoutUtils {
                     completion(.unknown, nil, false, false)
                 }
             } 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 {
                     completion(.networkError, nil, false, false)
                 }
@@ -385,6 +409,163 @@ class NightscoutUtils {
         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 {
         // 1) Try to parse the entire string as JSON and return the "message"
         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) {
                     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)
                         .foregroundColor(.secondary)
 

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

@@ -4,7 +4,8 @@
 import SwiftUI
 
 struct NavigationRow<Value: Hashable>: View {
-    let title: LocalizedStringKey
+    let title: String
+    var subtitle: String? = nil
     let icon: String
     var iconTint: Color = .white
     let value: Value
@@ -13,7 +14,14 @@ struct NavigationRow<Value: Hashable>: View {
         NavigationLink(value: value) {
             HStack {
                 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)
                                 .focused($isFocused)
                         } 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())
         .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)
     }
-
-    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 {
     @ObservedObject var viewModel: InfoDisplaySettingsViewModel
+    @State private var selectedID: Int?
 
     var body: some View {
         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)
             }
         }
         .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 {
             NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
         }
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .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
 
 class InfoDisplaySettingsViewModel: ObservableObject {
-    @Published var infoSort: [Int]
-    @Published var infoVisible: [Bool]
+    @Published var items: [InfoDisplayItem]
 
     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 name: 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.name = name
         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) }
     }
 
-    func updateInfoData(type: InfoType, value: String) {
+    func updateInfoData(type: InfoType, value: String, numericValue: Double? = nil) {
         tableData[type.rawValue].value = value
+        tableData[type.rawValue].numericValue = numericValue
         objectWillChange.send()
     }
 
     func updateInfoData(type: InfoType, value: HKQuantity) {
         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) {
@@ -35,7 +37,7 @@ class InfoManager: ObservableObject {
 
     func updateInfoData(type: InfoType, value: Double, maxFractionDigits: Int = 1, minFractionDigits: Int = 0) {
         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) {
@@ -48,25 +50,28 @@ class InfoManager: ObservableObject {
 
     func updateInfoData(type: InfoType, value: Metric) {
         let formattedValue = value.formattedValue()
-        updateInfoData(type: type, value: formattedValue)
+        updateInfoData(type: type, value: formattedValue, numericValue: value.value)
     }
 
     func clearInfoData(type: InfoType) {
         tableData[type.rawValue].value = ""
+        tableData[type.rawValue].numericValue = nil
         objectWillChange.send()
     }
 
     func clearInfoData(types: [InfoType]) {
         for type in types {
             tableData[type.rawValue].value = ""
+            tableData[type.rawValue].numericValue = nil
         }
         objectWillChange.send()
     }
 
     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 }
                 return tableData[index]
             }

+ 14 - 4
LoopFollow/InfoTable/InfoTableView.swift

@@ -16,14 +16,24 @@ struct InfoTableView: View {
                 row(name: "Time Zone", value: tz)
             }
             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)
         .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,
         // so the row reads as "no data" rather than appearing empty.
         let displayValue = value.isEmpty ? "—" : value
@@ -34,14 +44,14 @@ struct InfoTableView: View {
                 Text(name)
                 Spacer()
                 Text(displayValue)
-                    .foregroundStyle(.primary)
+                    .foregroundStyle(valueColor ?? .primary)
             }
 
             // Fallback when the single line won't fit: label over value
             VStack(alignment: .leading, spacing: 0) {
                 Text(name)
                 Text(displayValue)
-                    .foregroundStyle(.primary)
+                    .foregroundStyle(valueColor ?? .primary)
                     .frame(maxWidth: .infinity, alignment: .trailing)
             }
         }

+ 55 - 22
LoopFollow/InfoTable/InfoType.swift

@@ -3,31 +3,32 @@
 
 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 {
         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 {
         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 projected = snapshot.projected { snapshotDict["projected"] = projected }
         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 battery = snapshot.battery { snapshotDict["battery"] = battery }
         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).
     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.
     var recBolus: Double? { get }
 
@@ -141,6 +150,9 @@ enum GlucoseSnapshotBuilder {
             cob: provider.cob,
             projected: provider.projectedMgdl,
             override: provider.override,
+            overrideEndAt: provider.overrideEndAt,
+            tempTargetMgdl: provider.tempTargetMgdl,
+            tempTargetEndAt: provider.tempTargetEndAt,
             recBolus: provider.recBolus,
             battery: provider.battery,
             pumpBattery: provider.pumpBattery,

+ 51 - 235
LoopFollow/LiveActivity/LiveActivityManager.swift

@@ -10,19 +10,8 @@ import os
 import UIKit
 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 {
     static let shared = LiveActivityManager()
@@ -55,50 +44,42 @@ final class LiveActivityManager {
         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 user toggles LA permissions — each new delivery overwrites the stored value.
     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(
                     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(
                 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() {
         activityUpdatesObservationTask?.cancel()
         LogManager.shared.log(
@@ -471,7 +452,7 @@ final class LiveActivityManager {
     /// The actual end+restart is run from handleDidBecomeActive() because
     /// Activity.request() returns `visibility` during willEnterForeground.
     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.
     private var pushToStartObservationTask: Task<Void, Never>?
     /// Observes `Activity<>.activityUpdates` so activities started out-of-band
@@ -515,118 +496,28 @@ final class LiveActivityManager {
         let startReason = nextStartReasonOverride ?? "user-start"
         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 {
-            // 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 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 {
-                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
-                cancelRenewalFailedNotification()
-
-                Task {
-                    await existing.end(nil, dismissalPolicy: .immediate)
-                    await MainActor.run { self.startIfNeededLegacy() }
-                }
                 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(
                 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
@@ -771,102 +662,31 @@ final class LiveActivityManager {
         let overdueBy = Date().timeIntervalSince1970 - renewBy
         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
     private func attemptPushToStartCreate(
         reason: String,
         oldActivity: Activity<GlucoseLiveActivityAttributes>?,
         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 apnsKey = Storage.shared.lfApnsKey.value
         guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else {
@@ -919,7 +739,6 @@ final class LiveActivityManager {
         }
     }
 
-    @available(iOS 17.2, *)
     private func dispatchPushToStart(
         reason: String,
         oldActivity: Activity<GlucoseLiveActivityAttributes>?,
@@ -1019,7 +838,6 @@ final class LiveActivityManager {
         }
     }
 
-    @available(iOS 17.2, *)
     @MainActor
     private func handlePushToStartResult(
         _ result: APNSClient.PushToStartResult,
@@ -1306,10 +1124,8 @@ final class LiveActivityManager {
         // bind-existing path rebinds to the just-ended activity — clearing
         // endingForRestart and turning the eventual iOS dismissal into a misclassified
         // 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
 // 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
 
 import Foundation
+import HealthKit
 
 /// 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
@@ -47,6 +48,18 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
         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? {
         Observable.shared.deviceRecBolus.value
     }
@@ -134,19 +147,10 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
         #if targetEnvironment(macCatalyst)
             return false
         #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
     }
 }

+ 1 - 1
LoopFollow/LiveActivitySettingsView.swift

@@ -59,7 +59,7 @@
                             get: { slots[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)
                             }
                         }

+ 29 - 2
LoopFollow/Nightscout/NightscoutSettingsView.swift

@@ -49,16 +49,43 @@ struct NightscoutSettingsView: View {
     }
 
     private var tokenSection: some View {
-        Section(header: Text("Token")) {
+        Section {
             HStack {
                 Text("Access Token")
                 TogglableSecureInput(
-                    placeholder: String(localized: "Enter Token"),
+                    placeholder: "Token or API secret",
                     text: $viewModel.nightscoutToken,
                     style: .singleLine,
                     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 {
         willSet {
             if newValue != nightscoutToken {
-                Storage.shared.token.value = newValue
+                Storage.shared.token.value = NightscoutUtils.sanitizeConnectionInput(newValue)
                 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 {
         didSet {
@@ -47,7 +70,7 @@ class NightscoutSettingsViewModel: ObservableObject {
         }
     }
 
-    @Published var webSocketStatus: String = String(localized: "Disconnected")
+    @Published var webSocketStatus: String = "Disconnected"
 
     var webSocketStatusColor: Color {
         switch NightscoutSocketManager.shared.connectionState {
@@ -62,6 +85,25 @@ class NightscoutSettingsViewModel: ObservableObject {
     private var checkStatusSubject = PassthroughSubject<Void, Never>()
     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() {
         initialURL = Storage.shared.url.value
         initialToken = Storage.shared.token.value
@@ -84,7 +126,11 @@ class NightscoutSettingsViewModel: ObservableObject {
     private func triggerCheckStatus() {
         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 {
             self.checkStatusSubject.send()
@@ -93,6 +139,12 @@ class NightscoutSettingsViewModel: ObservableObject {
     }
 
     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
 
         if let urlComponents = URLComponents(string: value), let queryItems = urlComponents.queryItems {
@@ -121,6 +173,7 @@ class NightscoutSettingsViewModel: ObservableObject {
     }
 
     func checkNightscoutStatus() {
+        if isConfirmingProvisionedToken { return }
         NightscoutUtils.verifyURLAndToken { error, _, nsWriteAuth, nsAdminAuth in
             DispatchQueue.main.async {
                 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?) {
+        lastError = error
         if let error = error {
             isConnected = false
             switch error {
             case .invalidURL:
-                nightscoutStatus = String(localized: "Invalid URL")
+                nightscoutStatus = "Invalid URL"
             case .networkError:
-                nightscoutStatus = String(localized: "Network Error")
+                nightscoutStatus = "Network Error"
             case .invalidToken:
-                nightscoutStatus = String(localized: "Invalid Token")
+                nightscoutStatus = "Invalid Token"
             case .tokenRequired:
-                nightscoutStatus = String(localized: "Token Required")
+                nightscoutStatus = "Token Required"
             case .siteNotFound:
-                nightscoutStatus = String(localized: "Site Not Found")
+                nightscoutStatus = "Site Not Found"
             case .unknown:
-                nightscoutStatus = String(localized: "Unknown Error")
+                nightscoutStatus = "Unknown Error"
             case .emptyAddress:
-                nightscoutStatus = String(localized: "Address Empty")
+                nightscoutStatus = "Address Empty"
             }
             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 {
             isConnected = true
+            tokenIsVerifiedSecret = false
             let authStatus: String
             if Storage.shared.nsAdminAuth.value {
-                authStatus = String(localized: "Admin")
+                authStatus = "Admin"
             } else {
                 authStatus = "Read" + (Storage.shared.nsWriteAuth.value ? " & Write" : "")
             }
@@ -172,6 +368,52 @@ class NightscoutSettingsViewModel: ObservableObject {
         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() {
         updateWebSocketStatus()
         NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged)
@@ -184,8 +426,8 @@ class NightscoutSettingsViewModel: ObservableObject {
 
     private func updateWebSocketStatus() {
         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 .authenticated: webSocketStatus = "Connected"
         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


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است