Przeglądaj źródła

Merge branch 'dev' into feat/dev-scrollable-settings-search

Deniz Cengiz 11 godzin temu
rodzic
commit
ca73ca6049
99 zmienionych plików z 30182 dodań i 12434 usunięć
  1. 3 1
      BuildDetails.plist
  2. 1 1
      CGMBLEKit
  3. 1 1
      Config.xcconfig
  4. 1 1
      DanaKit
  5. 3 1
      Gemfile
  6. 14 10
      Gemfile.lock
  7. 1 1
      MedtrumKit
  8. 1 0
      Model/Classes+Properties/TempTargetStored+CoreDataProperties.swift
  9. 0 46
      Model/Helper/GlucoseStored+helper.swift
  10. 1 0
      Model/Helper/PumpEvent+helper.swift
  11. 1 1
      Model/JSONImporter.swift
  12. 3 1
      Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
  13. 1 1
      OmniBLE
  14. 63 2
      PRIVACY_POLICY.md
  15. 124 0
      Trio.xcodeproj/project.pbxproj
  16. 218 1
      Trio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
  17. 1 1
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  18. 1006 382
      Trio/Resources/InfoPlist.xcstrings
  19. 1 1
      Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift
  20. 1 1
      Trio/Sources/APS/CGM/PluginSource.swift
  21. 1 1
      Trio/Sources/APS/DeviceDataManager.swift
  22. 4 2
      Trio/Sources/APS/FetchGlucoseManager.swift
  23. 26 63
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  24. 16 1
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  25. 3 2
      Trio/Sources/APS/Storage/TempTargetsStorage.swift
  26. 12 0
      Trio/Sources/Application/AppDelegate.swift
  27. 30 0
      Trio/Sources/Application/TrioApp.swift
  28. 26 0
      Trio/Sources/Helpers/PropertyPersistentFlags.swift
  29. 9 1
      Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift
  30. 24444 10131
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  31. 4 0
      Trio/Sources/Logger/Logger.swift
  32. 1 1
      Trio/Sources/Models/AlgorithmGlucose.swift
  33. 26 9
      Trio/Sources/Models/BloodGlucose.swift
  34. 4 0
      Trio/Sources/Models/CarbRatios.swift
  35. 4 0
      Trio/Sources/Models/InsulinSensitivities.swift
  36. 5 0
      Trio/Sources/Models/TrioSettings.swift
  37. 5 0
      Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift
  38. 110 0
      Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift
  39. 15 17
      Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift
  40. 15 15
      Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift
  41. 5 0
      Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift
  42. 30 11
      Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift
  43. 19 8
      Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift
  44. 5 0
      Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift
  45. 13 0
      Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift
  46. 49 43
      Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift
  47. 5 0
      Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsStateModel.swift
  48. 161 0
      Trio/Sources/Modules/History/HistoryDataFlow+Models.swift
  49. 3 158
      Trio/Sources/Modules/History/HistoryDataFlow.swift
  50. 65 0
      Trio/Sources/Modules/History/HistoryDeletionTarget.swift
  51. 2 2
      Trio/Sources/Modules/History/HistoryProvider.swift
  52. 291 0
      Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift
  53. 119 0
      Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Carbs.swift
  54. 55 0
      Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Glucose.swift
  55. 85 0
      Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Insulin.swift
  56. 0 533
      Trio/Sources/Modules/History/HistoryStateModel.swift
  57. 72 0
      Trio/Sources/Modules/History/View/HistoryRootView+AddGlucose.swift
  58. 133 0
      Trio/Sources/Modules/History/View/HistoryRootView+Adjustments.swift
  59. 44 0
      Trio/Sources/Modules/History/View/HistoryRootView+Confirmations.swift
  60. 103 0
      Trio/Sources/Modules/History/View/HistoryRootView+Filters.swift
  61. 79 0
      Trio/Sources/Modules/History/View/HistoryRootView+Glucose.swift
  62. 98 0
      Trio/Sources/Modules/History/View/HistoryRootView+Meals.swift
  63. 103 0
      Trio/Sources/Modules/History/View/HistoryRootView+Treatments.swift
  64. 61 745
      Trio/Sources/Modules/History/View/HistoryRootView.swift
  65. 8 29
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  66. 12 0
      Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift
  67. 22 11
      Trio/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift
  68. 27 12
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  69. 1 1
      Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift
  70. 22 11
      Trio/Sources/Modules/Onboarding/View/OnboardingSteps/DiagnosticsStepView.swift
  71. 47 3
      Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift
  72. 12 0
      Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift
  73. 2 1
      Trio/Sources/Modules/Settings/SettingItems.swift
  74. 0 1
      Trio/Sources/Modules/Settings/SettingsStateModel.swift
  75. 1 1
      Trio/Sources/Modules/Settings/View/TidepoolStartView.swift
  76. 7 0
      Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift
  77. 5 0
      Trio/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift
  78. 5 0
      Trio/Sources/Modules/Telemetry/TelemetryDataFlow.swift
  79. 3 0
      Trio/Sources/Modules/Telemetry/TelemetryProvider.swift
  80. 9 0
      Trio/Sources/Modules/Telemetry/TelemetryStateModel.swift
  81. 145 0
      Trio/Sources/Modules/Telemetry/View/TelemetryMigrationSheetView.swift
  82. 84 0
      Trio/Sources/Modules/Telemetry/View/TelemetryPreviewView.swift
  83. 54 0
      Trio/Sources/Modules/Telemetry/View/TelemetryPrivacyView.swift
  84. 70 17
      Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift
  85. 126 36
      Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift
  86. 4 0
      Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift
  87. 23 0
      Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift
  88. 0 1
      Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift
  89. 10 5
      Trio/Sources/Services/Network/Nightscout/NightscoutAPI.swift
  90. 6 90
      Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift
  91. 0 1
      Trio/Sources/Services/Network/Nightscout/NightscoutUploadPipeline.swift
  92. 304 2
      Trio/Sources/Services/Network/TidepoolManager.swift
  93. 338 0
      Trio/Sources/Services/Telemetry/TelemetryAttestor.swift
  94. 352 0
      Trio/Sources/Services/Telemetry/TelemetryClient.swift
  95. 27 0
      Trio/Sources/Views/BolusProgressBar.swift
  96. 0 15
      TrioTests/CoreDataTests/GlucoseStorageTests.swift
  97. 67 0
      TrioTests/GlucoseSmoothingTests.swift
  98. 583 0
      TrioTests/TidepoolTherapySettingsTests.swift
  99. 1 1
      scripts/define_common_trio.sh

+ 3 - 1
BuildDetails.plist

@@ -3,6 +3,8 @@
 <plist version="1.0">
 <plist version="1.0">
 <dict>
 <dict>
 	<key>TidepoolServiceClientId</key>
 	<key>TidepoolServiceClientId</key>
-	<string>diy-loop</string>
+	<string>nightscout-trio</string>
+	<key>TidepoolServiceRedirectURL</key>
+	<string>org.nightscout.trio.tidepoolkit.auth://redirect</string>
 </dict>
 </dict>
 </plist>
 </plist>

+ 1 - 1
CGMBLEKit

@@ -1 +1 @@
-Subproject commit ba5d0b7daf83d282b587c8ff0e835162b8c75846
+Subproject commit 98fae7929c8c8e4e849d18a70c1f249dd6c09e5f

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 
 // The developers set the version numbers, please leave them alone
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.7.0
 APP_VERSION = 0.7.0
-APP_DEV_VERSION = 0.7.0
+APP_DEV_VERSION = 0.7.0.19
 APP_BUILD_NUMBER = 1
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 COPYRIGHT_NOTICE =
 
 

+ 1 - 1
DanaKit

@@ -1 +1 @@
-Subproject commit 0158fc85391725bb1855ea34469d48cb65667850
+Subproject commit de8ef83124230bb43b3937041538bba1f5e38cec

+ 3 - 1
Gemfile

@@ -1,3 +1,5 @@
 source "https://rubygems.org"
 source "https://rubygems.org"
 
 
-gem "fastlane", "2.231.0"
+gem "fastlane", "2.233.1"
+gem "json", ">=2.19.2"
+gem "addressable", ">=2.9.0"

+ 14 - 10
Gemfile.lock

@@ -3,7 +3,7 @@ GEM
   specs:
   specs:
     CFPropertyList (3.0.8)
     CFPropertyList (3.0.8)
     abbrev (0.1.2)
     abbrev (0.1.2)
-    addressable (2.8.8)
+    addressable (2.9.0)
       public_suffix (>= 2.0.2, < 8.0)
       public_suffix (>= 2.0.2, < 8.0)
     artifactory (3.0.17)
     artifactory (3.0.17)
     atomos (0.1.3)
     atomos (0.1.3)
@@ -28,6 +28,7 @@ GEM
       aws-eventstream (~> 1, >= 1.0.2)
       aws-eventstream (~> 1, >= 1.0.2)
     babosa (1.0.4)
     babosa (1.0.4)
     base64 (0.2.0)
     base64 (0.2.0)
+    benchmark (0.5.0)
     bigdecimal (4.0.1)
     bigdecimal (4.0.1)
     claide (1.1.0)
     claide (1.1.0)
     colored (1.2)
     colored (1.2)
@@ -67,14 +68,15 @@ GEM
     faraday_middleware (1.2.1)
     faraday_middleware (1.2.1)
       faraday (~> 1.0)
       faraday (~> 1.0)
     fastimage (2.4.0)
     fastimage (2.4.0)
-    fastlane (2.231.0)
+    fastlane (2.233.1)
       CFPropertyList (>= 2.3, < 4.0.0)
       CFPropertyList (>= 2.3, < 4.0.0)
       abbrev (~> 0.1.2)
       abbrev (~> 0.1.2)
       addressable (>= 2.8, < 3.0.0)
       addressable (>= 2.8, < 3.0.0)
       artifactory (~> 3.0)
       artifactory (~> 3.0)
-      aws-sdk-s3 (~> 1.0)
+      aws-sdk-s3 (~> 1.197)
       babosa (>= 1.0.3, < 2.0.0)
       babosa (>= 1.0.3, < 2.0.0)
       base64 (~> 0.2.0)
       base64 (~> 0.2.0)
+      benchmark (>= 0.1.0)
       bundler (>= 1.17.3, < 5.0.0)
       bundler (>= 1.17.3, < 5.0.0)
       colored (~> 1.2)
       colored (~> 1.2)
       commander (~> 4.6)
       commander (~> 4.6)
@@ -86,11 +88,11 @@ GEM
       faraday-cookie_jar (~> 0.0.6)
       faraday-cookie_jar (~> 0.0.6)
       faraday_middleware (~> 1.0)
       faraday_middleware (~> 1.0)
       fastimage (>= 2.1.0, < 3.0.0)
       fastimage (>= 2.1.0, < 3.0.0)
-      fastlane-sirp (>= 1.0.0)
+      fastlane-sirp (>= 1.1.0)
       gh_inspector (>= 1.1.2, < 2.0.0)
       gh_inspector (>= 1.1.2, < 2.0.0)
       google-apis-androidpublisher_v3 (~> 0.3)
       google-apis-androidpublisher_v3 (~> 0.3)
       google-apis-playcustomapp_v1 (~> 0.1)
       google-apis-playcustomapp_v1 (~> 0.1)
-      google-cloud-env (>= 1.6.0, < 2.0.0)
+      google-cloud-env (>= 1.6.0, <= 2.1.1)
       google-cloud-storage (~> 1.31)
       google-cloud-storage (~> 1.31)
       highline (~> 2.0)
       highline (~> 2.0)
       http-cookie (~> 1.0.5)
       http-cookie (~> 1.0.5)
@@ -103,6 +105,7 @@ GEM
       naturally (~> 2.2)
       naturally (~> 2.2)
       nkf (~> 0.2.0)
       nkf (~> 0.2.0)
       optparse (>= 0.1.1, < 1.0.0)
       optparse (>= 0.1.1, < 1.0.0)
+      ostruct (>= 0.1.0)
       plist (>= 3.1.0, < 4.0.0)
       plist (>= 3.1.0, < 4.0.0)
       rubyzip (>= 2.0.0, < 3.0.0)
       rubyzip (>= 2.0.0, < 3.0.0)
       security (= 0.1.5)
       security (= 0.1.5)
@@ -115,8 +118,7 @@ GEM
       xcodeproj (>= 1.13.0, < 2.0.0)
       xcodeproj (>= 1.13.0, < 2.0.0)
       xcpretty (~> 0.4.1)
       xcpretty (~> 0.4.1)
       xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
       xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
-    fastlane-sirp (1.0.0)
-      sysrandom (~> 1.0)
+    fastlane-sirp (1.1.0)
     gh_inspector (1.1.3)
     gh_inspector (1.1.3)
     google-apis-androidpublisher_v3 (0.54.0)
     google-apis-androidpublisher_v3 (0.54.0)
       google-apis-core (>= 0.11.0, < 2.a)
       google-apis-core (>= 0.11.0, < 2.a)
@@ -160,7 +162,7 @@ GEM
     httpclient (2.9.0)
     httpclient (2.9.0)
       mutex_m
       mutex_m
     jmespath (1.6.2)
     jmespath (1.6.2)
-    json (2.18.0)
+    json (2.19.4)
     jwt (2.10.2)
     jwt (2.10.2)
       base64
       base64
     logger (1.7.0)
     logger (1.7.0)
@@ -174,6 +176,7 @@ GEM
     nkf (0.2.0)
     nkf (0.2.0)
     optparse (0.8.1)
     optparse (0.8.1)
     os (1.1.4)
     os (1.1.4)
+    ostruct (0.6.3)
     plist (3.7.2)
     plist (3.7.2)
     public_suffix (7.0.2)
     public_suffix (7.0.2)
     rake (13.3.1)
     rake (13.3.1)
@@ -195,7 +198,6 @@ GEM
     simctl (1.6.10)
     simctl (1.6.10)
       CFPropertyList
       CFPropertyList
       naturally
       naturally
-    sysrandom (1.0.5)
     terminal-notifier (2.0.0)
     terminal-notifier (2.0.0)
     terminal-table (3.0.2)
     terminal-table (3.0.2)
       unicode-display_width (>= 1.1.1, < 3)
       unicode-display_width (>= 1.1.1, < 3)
@@ -224,7 +226,9 @@ PLATFORMS
   ruby
   ruby
 
 
 DEPENDENCIES
 DEPENDENCIES
-  fastlane (= 2.231.0)
+  addressable (>= 2.9.0)
+  fastlane (= 2.233.1)
+  json (>= 2.19.2)
 
 
 BUNDLED WITH
 BUNDLED WITH
   4.0.4
   4.0.4

+ 1 - 1
MedtrumKit

@@ -1 +1 @@
-Subproject commit b7f3d44c06bb7c580be897e0414e64de2d6dd995
+Subproject commit 7a3cb276b65ebaee7a3d6485878baa525399c3ee

+ 1 - 0
Model/Classes+Properties/TempTargetStored+CoreDataProperties.swift

@@ -17,6 +17,7 @@ public extension TempTargetStored {
     @NSManaged var orderPosition: Int16
     @NSManaged var orderPosition: Int16
     @NSManaged var target: NSDecimalNumber?
     @NSManaged var target: NSDecimalNumber?
     @NSManaged var tempTargetRun: TempTargetRunStored?
     @NSManaged var tempTargetRun: TempTargetRunStored?
+    @NSManaged var enteredBy: String?
 }
 }
 
 
 extension TempTargetStored: Identifiable {}
 extension TempTargetStored: Identifiable {}

+ 0 - 46
Model/Helper/GlucoseStored+helper.swift

@@ -93,16 +93,6 @@ extension NSPredicate {
         return NSPredicate(format: "date >= %@ AND isUploadedToTidepool == %@", date as NSDate, false as NSNumber)
         return NSPredicate(format: "date >= %@ AND isUploadedToTidepool == %@", date as NSDate, false as NSNumber)
     }
     }
 
 
-    static var manualGlucoseNotYetUploadedToNightscout: NSPredicate {
-        let date = Date.oneDayAgo
-        return NSPredicate(
-            format: "date >= %@ AND isUploadedToNS == %@ AND isManual == %@",
-            date as NSDate,
-            false as NSNumber,
-            true as NSNumber
-        )
-    }
-
     static var manualGlucoseNotYetUploadedToHealth: NSPredicate {
     static var manualGlucoseNotYetUploadedToHealth: NSPredicate {
         let date = Date.oneDayAgo
         let date = Date.oneDayAgo
         return NSPredicate(
         return NSPredicate(
@@ -124,42 +114,6 @@ extension NSPredicate {
     }
     }
 }
 }
 
 
-extension GlucoseStored: Encodable {
-    enum CodingKeys: String, CodingKey {
-        case date
-        case dateString
-        case sgv
-        case glucose
-        case direction
-        case id
-        case type
-    }
-
-    public func encode(to encoder: Encoder) throws {
-        var container = encoder.container(keyedBy: CodingKeys.self)
-
-        let dateFormatter = ISO8601DateFormatter()
-        dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-
-        try container.encode(dateFormatter.string(from: date ?? Date()), forKey: .dateString)
-
-        let dateAsUnixTimestamp = String(format: "%.0f", (date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000)
-        try container.encode(dateAsUnixTimestamp, forKey: .date)
-
-        try container.encode(direction, forKey: .direction)
-        try container.encode(id, forKey: .id)
-
-        // TODO: Handle the type of the glucose entry conditionally not hardcoded
-        try container.encode("sgv", forKey: .type)
-
-        if isManual {
-            try container.encode(glucose, forKey: .glucose)
-        } else {
-            try container.encode(glucose, forKey: .sgv)
-        }
-    }
-}
-
 // In order to show the correct direction in the bobble we convert the direction property of the NSManagedObject GlucoseStored back to the Direction type
 // In order to show the correct direction in the bobble we convert the direction property of the NSManagedObject GlucoseStored back to the Direction type
 extension GlucoseStored {
 extension GlucoseStored {
     var directionEnum: BloodGlucose.Direction? {
     var directionEnum: BloodGlucose.Direction? {

+ 1 - 0
Model/Helper/PumpEvent+helper.swift

@@ -58,6 +58,7 @@ public extension PumpEventStored {
         case rewind = "Rewind"
         case rewind = "Rewind"
         case prime = "Prime"
         case prime = "Prime"
         case journalCarbs = "JournalEntryMealMarker"
         case journalCarbs = "JournalEntryMealMarker"
+        case siteChange = "SiteChange"
 
 
         case nsNote = "Note"
         case nsNote = "Note"
         case nsTempBasal = "Temp Basal"
         case nsTempBasal = "Temp Basal"

+ 1 - 1
Model/JSONImporter.swift

@@ -355,7 +355,7 @@ extension BloodGlucose {
         }
         }
 
 
         let glucoseEntry = GlucoseStored(context: context)
         let glucoseEntry = GlucoseStored(context: context)
-        glucoseEntry.id = _id.flatMap({ UUID(uuidString: $0) }) ?? UUID()
+        glucoseEntry.id = UUID(uuidString: id) ?? UUID()
         glucoseEntry.date = dateString
         glucoseEntry.date = dateString
         glucoseEntry.glucose = Int16(glucoseValue)
         glucoseEntry.glucose = Int16(glucoseValue)
         glucoseEntry.direction = direction?.rawValue
         glucoseEntry.direction = direction?.rawValue

+ 3 - 1
Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="25D2128" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="25B78" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@@ -237,6 +237,7 @@
     </entity>
     </entity>
     <entity name="TempTargetRunStored" representedClassName="TempTargetRunStored" syncable="YES">
     <entity name="TempTargetRunStored" representedClassName="TempTargetRunStored" syncable="YES">
         <attribute name="endDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
         <attribute name="endDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
+        <attribute name="enteredBy" optional="YES" attributeType="String"/>
         <attribute name="id" optional="YES" attributeType="UUID" defaultValueString="empy" usesScalarValueType="NO"/>
         <attribute name="id" optional="YES" attributeType="UUID" defaultValueString="empy" usesScalarValueType="NO"/>
         <attribute name="isUploadedToNS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToNS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="name" optional="YES" attributeType="String"/>
         <attribute name="name" optional="YES" attributeType="String"/>
@@ -251,6 +252,7 @@
         <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
         <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
         <attribute name="duration" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
         <attribute name="duration" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
         <attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
         <attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
+        <attribute name="enteredBy" optional="YES" attributeType="String"/>
         <attribute name="halfBasalTarget" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
         <attribute name="halfBasalTarget" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
         <attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
         <attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
         <attribute name="isPreset" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isPreset" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>

+ 1 - 1
OmniBLE

@@ -1 +1 @@
-Subproject commit b0b78e66a6962677970a00e5d37ae4157e548b8a
+Subproject commit 1912793284b736754a0f25cf3d828e2eecd9ff2f

+ 63 - 2
PRIVACY_POLICY.md

@@ -34,6 +34,60 @@ The following information may be sent to Crashlytics when Trio crashes:
 - Device model and OS version (example: "iPhone 14 Pro running iOS 17.4.1")
 - Device model and OS version (example: "iPhone 14 Pro running iOS 17.4.1")
 - A generated unique identifier (a random code like "A7B2C9D3" that doesn't identify you personally)
 - A generated unique identifier (a random code like "A7B2C9D3" that doesn't identify you personally)
 
 
+### Anonymous Usage Telemetry (Opt-In by default, with ability to Opt-Out)
+
+Trio can periodically send a small anonymous usage report to a
+self-hosted telemetry endpoint operated by the Trio team. No
+third-party analytics service is involved. You are asked about this
+choice during onboarding (alongside crash reporting); existing users
+upgrading from a pre-telemetry build are prompted once on the first
+app launch after the update. You can change your choice at any time
+in Settings → App Diagnostics, and you can inspect the exact JSON
+that would be sent under "What's sent" on that same screen.
+
+Telemetry requests are authenticated with Apple App Attest. This
+means Apple cryptographically vouches for the fact that the request
+came from a genuine, unmodified copy of Trio running on a real
+Apple device. App Attest does not transmit any personal data,
+device identifiers, or location information; it produces a one-way
+attestation that the server validates with Apple. Devices that do
+not support App Attest (e.g. the iOS Simulator) silently skip
+sending telemetry.
+
+The diagnostics-sharing selection offers three options:
+
+- **Enable Full Sharing** — crash reports AND anonymous usage telemetry.
+- **Crash Reports Only** — crash reports, no usage telemetry.
+- **Disable Sharing** — neither.
+
+The following information is included in the telemetry payload:
+
+- App version, build date, branch, and commit SHA
+- Whether the build is a TestFlight or App Store / sideload build
+- An Apple-supplied per-vendor identifier (IDFV) and a per-install UUID
+- Device hardware identifier (e.g. "iPhone15,2"), platform, and iOS version
+- The paired pump model (when a pump is configured)
+- The paired CGM type and model (when a CGM is configured)
+- Whether Nightscout, Tidepool, and Apple Health are configured (yes/no — no URLs, tokens, or credentials)
+- A small set of preference flags: units (mg/dL or mmol/L), closed-loop
+  on/off, Live Activity enabled, calendar integration enabled
+- A rolling 7-day count of how often the app was cold-launched
+- The commit SHAs of pinned submodules (e.g. LoopKit, OmniBLE)
+
+The payload sends once every 24 hours while the app is running, plus
+once after a new build is installed. Sending failures simply retry on
+the next launch or scheduler tick — there is no continued retry.
+
+### What Telemetry Does NOT Include
+
+- Glucose readings, insulin doses, carb entries, or any therapy data
+- Therapy settings (basal rates, ISF, carb ratio, glucose targets, max bolus, max basal)
+- Your Nightscout URL or API token
+- Your Tidepool email, password, or session token
+- Remote-command secrets or APNS keys
+- Time zone or location
+- App logs — log sharing remains a separate, user-initiated flow under Settings
+
 ### Debug Symbols (dSYMs)
 ### Debug Symbols (dSYMs)
 
 
 When we build the Trio app, we create special files called debug
 When we build the Trio app, we create special files called debug
@@ -77,12 +131,19 @@ and handle any data responsibly.
 
 
 ## Opting Out and Data Retention
 ## Opting Out and Data Retention
 
 
-You can opt out of crash reporting at any time through the Trio
-settings. If you opt out:
+You can opt out of crash reporting and/or anonymous usage telemetry
+at any time through Settings → App Diagnostics in Trio. The three
+options ("Enable Full Sharing", "Crash Reports Only", "Disable
+Sharing") apply to both data streams. If you opt out of crash
+reporting:
 
 
 - No new crash data will be collected or sent to us
 - No new crash data will be collected or sent to us
 - Previously collected crash data will still be retained for approximately 90 days
 - Previously collected crash data will still be retained for approximately 90 days
 
 
+If you opt out of anonymous usage telemetry, no new telemetry data
+will be collected or sent. Previously sent telemetry rows are retained
+on the Trio team's telemetry endpoint per its own retention policy.
+
 To avoid sending dSYMs to Crashlytics, you can delete the Trio target
 To avoid sending dSYMs to Crashlytics, you can delete the Trio target
 Build Phase script, titled "Copy dSYMs to Crashlytics".
 Build Phase script, titled "Copy dSYMs to Crashlytics".
 
 

+ 124 - 0
Trio.xcodeproj/project.pbxproj

@@ -190,6 +190,7 @@
 		38E98A3725F5509500C0CED0 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A3625F5509500C0CED0 /* String+Extensions.swift */; };
 		38E98A3725F5509500C0CED0 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A3625F5509500C0CED0 /* String+Extensions.swift */; };
 		38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */; };
 		38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */; };
 		38EA0600262091870064E39B /* BolusProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */; };
 		38EA0600262091870064E39B /* BolusProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */; };
+		DDB0BBA02026050100000001 /* BolusProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB0BBA02026050100000002 /* BolusProgressBar.swift */; };
 		38F37828261260DC009DB701 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F37827261260DC009DB701 /* Color+Extensions.swift */; };
 		38F37828261260DC009DB701 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F37827261260DC009DB701 /* Color+Extensions.swift */; };
 		38F3B2EF25ED8E2A005C48AA /* TempTargetsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */; };
 		38F3B2EF25ED8E2A005C48AA /* TempTargetsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */; };
 		38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3D525E8FDF40078B0D1 /* MD5.swift */; };
 		38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3D525E8FDF40078B0D1 /* MD5.swift */; };
@@ -418,6 +419,7 @@
 		BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */; };
 		BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */; };
 		BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */; };
 		BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */; };
 		BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */; };
 		BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */; };
+		BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */; };
 		BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */; };
 		BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */; };
 		BDA25EE62D260D5E00035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE52D260D5800035F34 /* WatchState.swift */; };
 		BDA25EE62D260D5E00035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE52D260D5800035F34 /* WatchState.swift */; };
 		BDA25EFD2D261C0000035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EFC2D261BF200035F34 /* WatchState.swift */; };
 		BDA25EFD2D261C0000035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EFC2D261BF200035F34 /* WatchState.swift */; };
@@ -619,6 +621,14 @@
 		DD73FA0F2D74F58E00D19D1E /* BackgroundTask+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */; };
 		DD73FA0F2D74F58E00D19D1E /* BackgroundTask+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */; };
 		DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */; };
 		DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */; };
 		DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */; };
 		DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */; };
+		DD7E1E300000000000000002 /* TelemetryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000001 /* TelemetryClient.swift */; };
+		DD7E1E300000000000000014 /* TelemetryAttestor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000013 /* TelemetryAttestor.swift */; };
+		DD7E1E300000000000000004 /* TelemetryPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000003 /* TelemetryPreviewView.swift */; };
+		DD7E1E300000000000000006 /* TelemetryPrivacyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */; };
+		DD7E1E300000000000000008 /* TelemetryMigrationSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */; };
+		DD7E1E30000000000000000E /* TelemetryDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E30000000000000000D /* TelemetryDataFlow.swift */; };
+		DD7E1E300000000000000010 /* TelemetryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E30000000000000000F /* TelemetryProvider.swift */; };
+		DD7E1E300000000000000012 /* TelemetryStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000011 /* TelemetryStateModel.swift */; };
 		DD868FD82E381A54005D3308 /* APNSJWTClaims.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */; };
 		DD868FD82E381A54005D3308 /* APNSJWTClaims.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */; };
 		DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88C8E12C50420800F2D558 /* DefinitionRow.swift */; };
 		DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88C8E12C50420800F2D558 /* DefinitionRow.swift */; };
 		DD906BF42EA6AA0100262772 /* NightscoutUploadPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */; };
 		DD906BF42EA6AA0100262772 /* NightscoutUploadPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */; };
@@ -745,6 +755,19 @@
 		FE41E4D629463EE20047FD55 /* NightscoutPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */; };
 		FE41E4D629463EE20047FD55 /* NightscoutPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */; };
 		FE66D16B291F74F8005D6F77 /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */; };
 		FE66D16B291F74F8005D6F77 /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */; };
 		FEFFA7A22929FE49007B8193 /* UIDevice+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */; };
 		FEFFA7A22929FE49007B8193 /* UIDevice+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */; };
+		BD175EBE0000100000000001 /* HistoryDataFlow+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE0000100000000001 /* HistoryDataFlow+Models.swift */; };
+		BD175EBE0000100000000002 /* HistoryDeletionTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE0000100000000002 /* HistoryDeletionTarget.swift */; };
+		BD175EBE0000100000000003 /* HistoryStateModel+Glucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE0000100000000003 /* HistoryStateModel+Glucose.swift */; };
+		BD175EBE0000100000000004 /* HistoryStateModel+Carbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE0000100000000004 /* HistoryStateModel+Carbs.swift */; };
+		BD175EBE0000100000000005 /* HistoryStateModel+Insulin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE0000100000000005 /* HistoryStateModel+Insulin.swift */; };
+		BD175EBE0000100000000006 /* HistoryStateModel+CarbEditing.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE0000100000000006 /* HistoryStateModel+CarbEditing.swift */; };
+		BD175EBE0000100000000007 /* HistoryRootView+Treatments.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE0000100000000007 /* HistoryRootView+Treatments.swift */; };
+		BD175EBE0000100000000008 /* HistoryRootView+Meals.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE0000100000000008 /* HistoryRootView+Meals.swift */; };
+		BD175EBE0000100000000009 /* HistoryRootView+Glucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE0000100000000009 /* HistoryRootView+Glucose.swift */; };
+		BD175EBE000010000000000A /* HistoryRootView+Adjustments.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE000010000000000A /* HistoryRootView+Adjustments.swift */; };
+		BD175EBE000010000000000B /* HistoryRootView+Filters.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE000010000000000B /* HistoryRootView+Filters.swift */; };
+		BD175EBE000010000000000C /* HistoryRootView+AddGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE000010000000000C /* HistoryRootView+AddGlucose.swift */; };
+		BD175EBE000010000000000D /* HistoryRootView+Confirmations.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD175EFE000010000000000D /* HistoryRootView+Confirmations.swift */; };
 /* End PBXBuildFile section */
 /* End PBXBuildFile section */
 
 
 /* Begin PBXContainerItemProxy section */
 /* Begin PBXContainerItemProxy section */
@@ -1052,6 +1075,7 @@
 		38E98A3625F5509500C0CED0 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
 		38E98A3625F5509500C0CED0 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
 		38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleLogReporter.swift; sourceTree = "<group>"; };
 		38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleLogReporter.swift; sourceTree = "<group>"; };
 		38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressViewStyle.swift; sourceTree = "<group>"; };
 		38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressViewStyle.swift; sourceTree = "<group>"; };
+		DDB0BBA02026050100000002 /* BolusProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressBar.swift; sourceTree = "<group>"; };
 		38F37827261260DC009DB701 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
 		38F37827261260DC009DB701 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
 		38F3783A2613555C009DB701 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
 		38F3783A2613555C009DB701 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
 		38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetsStorage.swift; sourceTree = "<group>"; };
 		38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetsStorage.swift; sourceTree = "<group>"; };
@@ -1255,6 +1279,7 @@
 		BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStorageTests.swift; sourceTree = "<group>"; };
+		BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolTherapySettingsTests.swift; sourceTree = "<group>"; };
 		BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleWatchManager.swift; sourceTree = "<group>"; };
 		BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleWatchManager.swift; sourceTree = "<group>"; };
 		BDA25EE52D260D5800035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = "<group>"; };
 		BDA25EE52D260D5800035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = "<group>"; };
 		BDA25EFC2D261BF200035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = "<group>"; };
 		BDA25EFC2D261BF200035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = "<group>"; };
@@ -1462,6 +1487,14 @@
 		DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BackgroundTask+Helper.swift"; sourceTree = "<group>"; };
 		DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BackgroundTask+Helper.swift"; sourceTree = "<group>"; };
 		DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusConfirmationView.swift; sourceTree = "<group>"; };
 		DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusConfirmationView.swift; sourceTree = "<group>"; };
 		DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyPersistentFlags.swift; sourceTree = "<group>"; };
 		DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyPersistentFlags.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000001 /* TelemetryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryClient.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000013 /* TelemetryAttestor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryAttestor.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000003 /* TelemetryPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryPreviewView.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryPrivacyView.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryMigrationSheetView.swift; sourceTree = "<group>"; };
+		DD7E1E30000000000000000D /* TelemetryDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryDataFlow.swift; sourceTree = "<group>"; };
+		DD7E1E30000000000000000F /* TelemetryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryProvider.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000011 /* TelemetryStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryStateModel.swift; sourceTree = "<group>"; };
 		DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTClaims.swift; sourceTree = "<group>"; };
 		DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTClaims.swift; sourceTree = "<group>"; };
 		DD88C8E12C50420800F2D558 /* DefinitionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinitionRow.swift; sourceTree = "<group>"; };
 		DD88C8E12C50420800F2D558 /* DefinitionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinitionRow.swift; sourceTree = "<group>"; };
 		DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadPipeline.swift; sourceTree = "<group>"; };
 		DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadPipeline.swift; sourceTree = "<group>"; };
@@ -1588,6 +1621,19 @@
 		FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutPreferences.swift; sourceTree = "<group>"; };
 		FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutPreferences.swift; sourceTree = "<group>"; };
 		FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = "<group>"; };
 		FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = "<group>"; };
 		FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = "<group>"; };
 		FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = "<group>"; };
+		BD175EFE0000100000000001 /* HistoryDataFlow+Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryDataFlow+Models.swift"; sourceTree = "<group>"; };
+		BD175EFE0000100000000002 /* HistoryDeletionTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryDeletionTarget.swift; sourceTree = "<group>"; };
+		BD175EFE0000100000000003 /* HistoryStateModel+Glucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryStateModel+Glucose.swift"; sourceTree = "<group>"; };
+		BD175EFE0000100000000004 /* HistoryStateModel+Carbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryStateModel+Carbs.swift"; sourceTree = "<group>"; };
+		BD175EFE0000100000000005 /* HistoryStateModel+Insulin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryStateModel+Insulin.swift"; sourceTree = "<group>"; };
+		BD175EFE0000100000000006 /* HistoryStateModel+CarbEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryStateModel+CarbEditing.swift"; sourceTree = "<group>"; };
+		BD175EFE0000100000000007 /* HistoryRootView+Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryRootView+Treatments.swift"; sourceTree = "<group>"; };
+		BD175EFE0000100000000008 /* HistoryRootView+Meals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryRootView+Meals.swift"; sourceTree = "<group>"; };
+		BD175EFE0000100000000009 /* HistoryRootView+Glucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryRootView+Glucose.swift"; sourceTree = "<group>"; };
+		BD175EFE000010000000000A /* HistoryRootView+Adjustments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryRootView+Adjustments.swift"; sourceTree = "<group>"; };
+		BD175EFE000010000000000B /* HistoryRootView+Filters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryRootView+Filters.swift"; sourceTree = "<group>"; };
+		BD175EFE000010000000000C /* HistoryRootView+AddGlucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryRootView+AddGlucose.swift"; sourceTree = "<group>"; };
+		BD175EFE000010000000000D /* HistoryRootView+Confirmations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HistoryRootView+Confirmations.swift"; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 /* End PBXFileReference section */
 
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -1719,6 +1765,13 @@
 			children = (
 			children = (
 				BDA7593D2D37CFC000E649A4 /* CarbEntryEditorView.swift */,
 				BDA7593D2D37CFC000E649A4 /* CarbEntryEditorView.swift */,
 				881E04BA5E0A003DE8E0A9C6 /* HistoryRootView.swift */,
 				881E04BA5E0A003DE8E0A9C6 /* HistoryRootView.swift */,
+				BD175EFE0000100000000007 /* HistoryRootView+Treatments.swift */,
+				BD175EFE0000100000000008 /* HistoryRootView+Meals.swift */,
+				BD175EFE0000100000000009 /* HistoryRootView+Glucose.swift */,
+				BD175EFE000010000000000A /* HistoryRootView+Adjustments.swift */,
+				BD175EFE000010000000000B /* HistoryRootView+Filters.swift */,
+				BD175EFE000010000000000C /* HistoryRootView+AddGlucose.swift */,
+				BD175EFE000010000000000D /* HistoryRootView+Confirmations.swift */,
 			);
 			);
 			path = View;
 			path = View;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
@@ -1918,6 +1971,7 @@
 				DDD163032C4C67B400CD525A /* Adjustments */,
 				DDD163032C4C67B400CD525A /* Adjustments */,
 				DD1745382C55BF8B00211FAC /* AlgorithmAdvancedSettings */,
 				DD1745382C55BF8B00211FAC /* AlgorithmAdvancedSettings */,
 				DDF690FE2DA2C9EE008BF16C /* AppDiagnostics */,
 				DDF690FE2DA2C9EE008BF16C /* AppDiagnostics */,
+				DD7E1E30000000000000000B /* Telemetry */,
 				DD1745422C55C5C400211FAC /* AutosensSettings */,
 				DD1745422C55C5C400211FAC /* AutosensSettings */,
 				A42F1FEDFFD0DDE00AAD54D3 /* BasalProfileEditor */,
 				A42F1FEDFFD0DDE00AAD54D3 /* BasalProfileEditor */,
 				3811DE0425C9D32E00A708ED /* Base */,
 				3811DE0425C9D32E00A708ED /* Base */,
@@ -2084,6 +2138,7 @@
 				DD9ECB662CA99EFE00AA7C45 /* RemoteControl */,
 				DD9ECB662CA99EFE00AA7C45 /* RemoteControl */,
 				38AEE75025F021F10013F05B /* SettingsManager */,
 				38AEE75025F021F10013F05B /* SettingsManager */,
 				3811DE9825C9D88300A708ED /* Storage */,
 				3811DE9825C9D88300A708ED /* Storage */,
+				DD7E1E30000000000000000A /* Telemetry */,
 				3811DEA525C9D88300A708ED /* UnlockManager */,
 				3811DEA525C9D88300A708ED /* UnlockManager */,
 				38E87406274F9AA500975559 /* UserNotifications */,
 				38E87406274F9AA500975559 /* UserNotifications */,
 				38E8754D275556E100975559 /* WatchManager */,
 				38E8754D275556E100975559 /* WatchManager */,
@@ -2091,6 +2146,36 @@
 			path = Services;
 			path = Services;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
 		};
 		};
+		DD7E1E30000000000000000A /* Telemetry */ = {
+			isa = PBXGroup;
+			children = (
+				DD7E1E300000000000000013 /* TelemetryAttestor.swift */,
+				DD7E1E300000000000000001 /* TelemetryClient.swift */,
+			);
+			path = Telemetry;
+			sourceTree = "<group>";
+		};
+		DD7E1E30000000000000000B /* Telemetry */ = {
+			isa = PBXGroup;
+			children = (
+				DD7E1E30000000000000000D /* TelemetryDataFlow.swift */,
+				DD7E1E30000000000000000F /* TelemetryProvider.swift */,
+				DD7E1E300000000000000011 /* TelemetryStateModel.swift */,
+				DD7E1E30000000000000000C /* View */,
+			);
+			path = Telemetry;
+			sourceTree = "<group>";
+		};
+		DD7E1E30000000000000000C /* View */ = {
+			isa = PBXGroup;
+			children = (
+				DD7E1E300000000000000003 /* TelemetryPreviewView.swift */,
+				DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */,
+				DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
 		3811DE9225C9D88200A708ED /* Appearance */ = {
 		3811DE9225C9D88200A708ED /* Appearance */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
@@ -2325,6 +2410,7 @@
 				383420D825FFEB3F002D46C1 /* Popup.swift */,
 				383420D825FFEB3F002D46C1 /* Popup.swift */,
 				389ECDFD2601061500D86C4F /* View+Snapshot.swift */,
 				389ECDFD2601061500D86C4F /* View+Snapshot.swift */,
 				38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */,
 				38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */,
+				DDB0BBA02026050100000002 /* BolusProgressBar.swift */,
 				38DF1785276A73D400B3528F /* TagCloudView.swift */,
 				38DF1785276A73D400B3528F /* TagCloudView.swift */,
 				DD88C8E12C50420800F2D558 /* DefinitionRow.swift */,
 				DD88C8E12C50420800F2D558 /* DefinitionRow.swift */,
 				DD1745282C55642100211FAC /* SettingInputSection.swift */,
 				DD1745282C55642100211FAC /* SettingInputSection.swift */,
@@ -2666,6 +2752,7 @@
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
 				B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */,
 				B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */,
 				BD8FC0532D66186000B95AED /* TestError.swift */,
 				BD8FC0532D66186000B95AED /* TestError.swift */,
+				BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */,
 			);
 			);
 			path = TrioTests;
 			path = TrioTests;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
@@ -2855,13 +2942,27 @@
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
 				A401509D21F7F35D4E109EDA /* HistoryDataFlow.swift */,
 				A401509D21F7F35D4E109EDA /* HistoryDataFlow.swift */,
+				BD175EFE0000100000000001 /* HistoryDataFlow+Models.swift */,
+				BD175EFE0000100000000002 /* HistoryDeletionTarget.swift */,
 				60744C3E9BB3652895C908CC /* HistoryProvider.swift */,
 				60744C3E9BB3652895C908CC /* HistoryProvider.swift */,
 				9455FA2D92E77A6C4AFED8A3 /* HistoryStateModel.swift */,
 				9455FA2D92E77A6C4AFED8A3 /* HistoryStateModel.swift */,
+				BD175EC00000100000000001 /* HistoryStateModel+Deletion */,
 				0EE66DD474AFFD4FD787D5B9 /* View */,
 				0EE66DD474AFFD4FD787D5B9 /* View */,
 			);
 			);
 			path = History;
 			path = History;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
 		};
 		};
+		BD175EC00000100000000001 /* HistoryStateModel+Deletion */ = {
+			isa = PBXGroup;
+			children = (
+				BD175EFE0000100000000003 /* HistoryStateModel+Glucose.swift */,
+				BD175EFE0000100000000004 /* HistoryStateModel+Carbs.swift */,
+				BD175EFE0000100000000005 /* HistoryStateModel+Insulin.swift */,
+				BD175EFE0000100000000006 /* HistoryStateModel+CarbEditing.swift */,
+			);
+			path = "HistoryStateModel+Deletion";
+			sourceTree = "<group>";
+		};
 		A42F1FEDFFD0DDE00AAD54D3 /* BasalProfileEditor */ = {
 		A42F1FEDFFD0DDE00AAD54D3 /* BasalProfileEditor */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
@@ -4320,6 +4421,14 @@
 				BD249D862D42FBEC00412DEB /* GlucoseMetricsView.swift in Sources */,
 				BD249D862D42FBEC00412DEB /* GlucoseMetricsView.swift in Sources */,
 				58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */,
 				58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */,
 				DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */,
 				DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */,
+				DD7E1E300000000000000002 /* TelemetryClient.swift in Sources */,
+				DD7E1E300000000000000014 /* TelemetryAttestor.swift in Sources */,
+				DD7E1E300000000000000004 /* TelemetryPreviewView.swift in Sources */,
+				DD7E1E300000000000000006 /* TelemetryPrivacyView.swift in Sources */,
+				DD7E1E300000000000000008 /* TelemetryMigrationSheetView.swift in Sources */,
+				DD7E1E30000000000000000E /* TelemetryDataFlow.swift in Sources */,
+				DD7E1E300000000000000010 /* TelemetryProvider.swift in Sources */,
+				DD7E1E300000000000000012 /* TelemetryStateModel.swift in Sources */,
 				388E5A5C25B6F0770019842D /* JSON.swift in Sources */,
 				388E5A5C25B6F0770019842D /* JSON.swift in Sources */,
 				C263D59F2E4267F400CBF08C /* NightscoutUploadGlucoseStepView.swift in Sources */,
 				C263D59F2E4267F400CBF08C /* NightscoutUploadGlucoseStepView.swift in Sources */,
 				3811DF0225CA9FEA00A708ED /* Credentials.swift in Sources */,
 				3811DF0225CA9FEA00A708ED /* Credentials.swift in Sources */,
@@ -4563,6 +4672,7 @@
 				582DF9792C8CE1E5001F516D /* MainChartHelper.swift in Sources */,
 				582DF9792C8CE1E5001F516D /* MainChartHelper.swift in Sources */,
 				E06B911A275B5EEA003C04B6 /* Array+Extension.swift in Sources */,
 				E06B911A275B5EEA003C04B6 /* Array+Extension.swift in Sources */,
 				38EA0600262091870064E39B /* BolusProgressViewStyle.swift in Sources */,
 				38EA0600262091870064E39B /* BolusProgressViewStyle.swift in Sources */,
+				DDB0BBA02026050100000001 /* BolusProgressBar.swift in Sources */,
 				389ECDFE2601061500D86C4F /* View+Snapshot.swift in Sources */,
 				389ECDFE2601061500D86C4F /* View+Snapshot.swift in Sources */,
 				38FEF3FE2738083E00574A46 /* CGMSettingsProvider.swift in Sources */,
 				38FEF3FE2738083E00574A46 /* CGMSettingsProvider.swift in Sources */,
 				38E98A3725F5509500C0CED0 /* String+Extensions.swift in Sources */,
 				38E98A3725F5509500C0CED0 /* String+Extensions.swift in Sources */,
@@ -4799,6 +4909,19 @@
 				8194B80890CDD6A3C13B0FEE /* SnoozeStateModel.swift in Sources */,
 				8194B80890CDD6A3C13B0FEE /* SnoozeStateModel.swift in Sources */,
 				BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */,
 				BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */,
 				0437CE46C12535A56504EC19 /* SnoozeRootView.swift in Sources */,
 				0437CE46C12535A56504EC19 /* SnoozeRootView.swift in Sources */,
+				BD175EBE0000100000000001 /* HistoryDataFlow+Models.swift in Sources */,
+				BD175EBE0000100000000002 /* HistoryDeletionTarget.swift in Sources */,
+				BD175EBE0000100000000003 /* HistoryStateModel+Glucose.swift in Sources */,
+				BD175EBE0000100000000004 /* HistoryStateModel+Carbs.swift in Sources */,
+				BD175EBE0000100000000005 /* HistoryStateModel+Insulin.swift in Sources */,
+				BD175EBE0000100000000006 /* HistoryStateModel+CarbEditing.swift in Sources */,
+				BD175EBE0000100000000007 /* HistoryRootView+Treatments.swift in Sources */,
+				BD175EBE0000100000000008 /* HistoryRootView+Meals.swift in Sources */,
+				BD175EBE0000100000000009 /* HistoryRootView+Glucose.swift in Sources */,
+				BD175EBE000010000000000A /* HistoryRootView+Adjustments.swift in Sources */,
+				BD175EBE000010000000000B /* HistoryRootView+Filters.swift in Sources */,
+				BD175EBE000010000000000C /* HistoryRootView+AddGlucose.swift in Sources */,
+				BD175EBE000010000000000D /* HistoryRootView+Confirmations.swift in Sources */,
 			);
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			runOnlyForDeploymentPostprocessing = 0;
 		};
 		};
@@ -4824,6 +4947,7 @@
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,
 				41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */,
 				41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */,
+				BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */,
 			);
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			runOnlyForDeploymentPostprocessing = 0;
 		};
 		};

+ 218 - 1
Trio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,6 +1,169 @@
 {
 {
+  "originHash" : "b271d5de8862534ec5ccf9ccfd7ce226afa7ed4c799b7066ccbe2281782402a1",
   "pins" : [
   "pins" : [
     {
     {
+      "identity" : "abseil-cpp-binary",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/abseil-cpp-binary.git",
+      "state" : {
+        "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
+        "version" : "1.2024072200.0"
+      }
+    },
+    {
+      "identity" : "app-check",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/app-check.git",
+      "state" : {
+        "revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
+        "version" : "11.2.0"
+      }
+    },
+    {
+      "identity" : "bluecryptor",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/BlueCryptor.git",
+      "state" : {
+        "revision" : "cec97c24b111351e70e448972a7d3fe68a756d6d",
+        "version" : "2.0.2"
+      }
+    },
+    {
+      "identity" : "blueecc",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/BlueECC.git",
+      "state" : {
+        "revision" : "1485268a54f8135435a825a855e733f026fa6cc8",
+        "version" : "1.2.201"
+      }
+    },
+    {
+      "identity" : "bluersa",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/BlueRSA.git",
+      "state" : {
+        "revision" : "f40325520344a966523b214394aa350132a6af68",
+        "version" : "1.0.203"
+      }
+    },
+    {
+      "identity" : "connectiq-companion-app-sdk-ios",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/garmin/connectiq-companion-app-sdk-ios",
+      "state" : {
+        "revision" : "f0d29ff691d700a132d86205ed9bb091e336c2f7",
+        "version" : "1.8.0"
+      }
+    },
+    {
+      "identity" : "cryptoswift",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/krzyzanowskim/CryptoSwift",
+      "state" : {
+        "revision" : "f2a627b84c1ff96f21ac2fcb623ab36142dd5512",
+        "version" : "1.10.0"
+      }
+    },
+    {
+      "identity" : "firebase-ios-sdk",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/firebase/firebase-ios-sdk.git",
+      "state" : {
+        "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5",
+        "version" : "11.15.0"
+      }
+    },
+    {
+      "identity" : "google-ads-on-device-conversion-ios-sdk",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
+      "state" : {
+        "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed",
+        "version" : "2.3.0"
+      }
+    },
+    {
+      "identity" : "googleappmeasurement",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/GoogleAppMeasurement.git",
+      "state" : {
+        "revision" : "45ce435e9406d3c674dd249a042b932bee006f60",
+        "version" : "11.15.0"
+      }
+    },
+    {
+      "identity" : "googledatatransport",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/GoogleDataTransport.git",
+      "state" : {
+        "revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
+        "version" : "10.1.0"
+      }
+    },
+    {
+      "identity" : "googleutilities",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/GoogleUtilities.git",
+      "state" : {
+        "revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
+        "version" : "8.1.0"
+      }
+    },
+    {
+      "identity" : "grpc-binary",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/grpc-binary.git",
+      "state" : {
+        "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
+        "version" : "1.69.1"
+      }
+    },
+    {
+      "identity" : "gtm-session-fetcher",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/gtm-session-fetcher.git",
+      "state" : {
+        "revision" : "c756a29784521063b6a1202907e2cc47f41b667c",
+        "version" : "4.5.0"
+      }
+    },
+    {
+      "identity" : "interop-ios-for-google-sdks",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/interop-ios-for-google-sdks.git",
+      "state" : {
+        "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
+        "version" : "101.0.0"
+      }
+    },
+    {
+      "identity" : "kituracontracts",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/KituraContracts.git",
+      "state" : {
+        "revision" : "6edf7ac3dd2b3a2c61284778d430bbad7d8a6f23",
+        "version" : "2.0.1"
+      }
+    },
+    {
+      "identity" : "leveldb",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/firebase/leveldb.git",
+      "state" : {
+        "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
+        "version" : "1.22.5"
+      }
+    },
+    {
+      "identity" : "loggerapi",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/LoggerAPI.git",
+      "state" : {
+        "revision" : "4e6b45e850ffa275e8e26a24c6454fd709d5b6ac",
+        "version" : "2.0.0"
+      }
+    },
+    {
       "identity" : "mkringprogressview",
       "identity" : "mkringprogressview",
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/maxkonovalov/MKRingProgressView.git",
       "location" : "https://github.com/maxkonovalov/MKRingProgressView.git",
@@ -10,6 +173,33 @@
       }
       }
     },
     },
     {
     {
+      "identity" : "nanopb",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/firebase/nanopb.git",
+      "state" : {
+        "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
+        "version" : "2.30910.0"
+      }
+    },
+    {
+      "identity" : "promises",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/promises.git",
+      "state" : {
+        "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
+        "version" : "2.4.0"
+      }
+    },
+    {
+      "identity" : "slidebutton",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/no-comment/SlideButton",
+      "state" : {
+        "branch" : "main",
+        "revision" : "5eacebba4d7deeb693592bc9a62ab2d2181e133b"
+      }
+    },
+    {
       "identity" : "swift-algorithms",
       "identity" : "swift-algorithms",
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-algorithms",
       "location" : "https://github.com/apple/swift-algorithms",
@@ -19,6 +209,24 @@
       }
       }
     },
     },
     {
     {
+      "identity" : "swift-jwt",
+      "kind" : "remoteSourceControl",
+      "location" : "http://github.com/Kitura/Swift-JWT.git",
+      "state" : {
+        "revision" : "f68ec28fbd90a651597e9e825ea7f315f8d52a1f",
+        "version" : "4.0.1"
+      }
+    },
+    {
+      "identity" : "swift-log",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-log.git",
+      "state" : {
+        "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256",
+        "version" : "1.12.0"
+      }
+    },
+    {
       "identity" : "swift-numerics",
       "identity" : "swift-numerics",
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-numerics",
       "location" : "https://github.com/apple/swift-numerics",
@@ -28,6 +236,15 @@
       }
       }
     },
     },
     {
     {
+      "identity" : "swift-protobuf",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-protobuf.git",
+      "state" : {
+        "revision" : "81558271e243f8f47dfe8e9fdd55f3c2b5413f68",
+        "version" : "1.37.0"
+      }
+    },
+    {
       "identity" : "swiftcharts",
       "identity" : "swiftcharts",
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/ivanschuetz/SwiftCharts.git",
       "location" : "https://github.com/ivanschuetz/SwiftCharts.git",
@@ -64,5 +281,5 @@
       }
       }
     }
     }
   ],
   ],
-  "version" : 2
+  "version" : 3
 }
 }

+ 1 - 1
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,5 +1,5 @@
 {
 {
-  "originHash" : "598841ae6fe892058ca678f5672f34299df2d62843330367c207648003263ccd",
+  "originHash" : "1e72c1cdf8ea5ec9fe527ebfab01ea55fca9e8651fe3252338fd3d4ea2cb327a",
   "pins" : [
   "pins" : [
     {
     {
       "identity" : "abseil-cpp-binary",
       "identity" : "abseil-cpp-binary",

Plik diff jest za duży
+ 1006 - 382
Trio/Resources/InfoPlist.xcstrings


+ 1 - 1
Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift

@@ -217,7 +217,7 @@ class OscillatingGenerator: BloodGlucoseGenerator {
 
 
             // Create BloodGlucose with the correct constructor
             // Create BloodGlucose with the correct constructor
             let bloodGlucose = BloodGlucose(
             let bloodGlucose = BloodGlucose(
-                _id: UUID().uuidString,
+                id: UUID().uuidString,
                 sgv: glucose,
                 sgv: glucose,
                 direction: direction,
                 direction: direction,
                 date: Decimal(Int(currentDate.timeIntervalSince1970) * 1000),
                 date: Decimal(Int(currentDate.timeIntervalSince1970) * 1000),

+ 1 - 1
Trio/Sources/APS/CGM/PluginSource.swift

@@ -236,7 +236,7 @@ extension PluginSource: CGMManagerDelegate {
 
 
                 let value = Int(quantity.doubleValue(for: .milligramsPerDeciliter))
                 let value = Int(quantity.doubleValue(for: .milligramsPerDeciliter))
                 return BloodGlucose(
                 return BloodGlucose(
-                    _id: UUID().uuidString,
+                    id: UUID().uuidString,
                     sgv: value,
                     sgv: value,
                     direction: .init(trendType: newGlucoseSample.trend),
                     direction: .init(trendType: newGlucoseSample.trend),
                     date: Decimal(Int(newGlucoseSample.date.timeIntervalSince1970 * 1000)),
                     date: Decimal(Int(newGlucoseSample.date.timeIntervalSince1970 * 1000)),

+ 1 - 1
Trio/Sources/APS/DeviceDataManager.swift

@@ -357,7 +357,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                         let results = glucose.enumerated().map { index, sample -> BloodGlucose in
                         let results = glucose.enumerated().map { index, sample -> BloodGlucose in
                             let value = Int(sample.quantity.doubleValue(for: .milligramsPerDeciliter))
                             let value = Int(sample.quantity.doubleValue(for: .milligramsPerDeciliter))
                             return BloodGlucose(
                             return BloodGlucose(
-                                _id: sample.syncIdentifier,
+                                id: sample.syncIdentifier,
                                 sgv: value,
                                 sgv: value,
                                 direction: directions[index],
                                 direction: directions[index],
                                 date: Decimal(Int(sample.date.timeIntervalSince1970 * 1000)),
                                 date: Decimal(Int(sample.date.timeIntervalSince1970 * 1000)),

+ 4 - 2
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -381,9 +381,11 @@ extension BaseFetchGlucoseManager {
             // Predicate must cover at least the full glucose horizon used by downstream algorithm consumers.
             // Predicate must cover at least the full glucose horizon used by downstream algorithm consumers.
             // If autosens / oref / smoothing logic ever starts looking back further (e.g. 36h),
             // If autosens / oref / smoothing logic ever starts looking back further (e.g. 36h),
             // this fetch window must be expanded accordingly.
             // this fetch window must be expanded accordingly.
+            // Fetch descending (newest first) so the limit always keeps the most recent 350 readings.
+            // Reversed before return so callers receive oldest-first (chronological) order.
             predicate: compoundPredicate,
             predicate: compoundPredicate,
             key: "date",
             key: "date",
-            ascending: true, // the first element is the oldest
+            ascending: false,
             fetchLimit: 350
             fetchLimit: 350
         )
         )
 
 
@@ -391,7 +393,7 @@ extension BaseFetchGlucoseManager {
             throw CoreDataError.fetchError(function: #function, file: #file)
             throw CoreDataError.fetchError(function: #function, file: #file)
         }
         }
 
 
-        return glucoseArray.map(\.objectID)
+        return Array(glucoseArray.map(\.objectID).reversed())
     }
     }
 
 
     /// CoreData-friendly AAPS exponential smoothing + storage.
     /// CoreData-friendly AAPS exponential smoothing + storage.

+ 26 - 63
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -19,7 +19,6 @@ protocol GlucoseStorage {
     func isGlucoseFresh() -> Bool
     func isGlucoseFresh() -> Bool
     func getGlucoseNotYetUploadedToNightscout() async throws -> [BloodGlucose]
     func getGlucoseNotYetUploadedToNightscout() async throws -> [BloodGlucose]
     func getCGMStateNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
     func getCGMStateNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
-    func getManualGlucoseNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
     func getGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose]
     func getGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose]
     func getManualGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose]
     func getManualGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose]
     func getGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample]
     func getGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample]
@@ -411,64 +410,28 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
             }
             }
 
 
             return fetchedResults.map { result in
             return fetchedResults.map { result in
-                BloodGlucose(
-                    _id: result.id?.uuidString ?? UUID().uuidString,
-                    sgv: Int(result.glucose),
-                    direction: BloodGlucose.Direction(from: result.direction ?? ""),
-                    date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
-                    dateString: result.date ?? Date(),
-                    unfiltered: Decimal(result.glucose),
-                    filtered: Decimal(result.glucose),
-                    noise: nil,
-                    glucose: Int(result.glucose),
-                    type: "sgv"
-                )
-            }
-        }
-    }
-
-    // Fetch manual glucose that is not uploaded to Nightscout yet
-    /// - Returns: Array of NightscoutTreatment to ensure the correct format for the NS Upload
-    func getManualGlucoseNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: GlucoseStored.self,
-            onContext: context,
-            predicate: NSPredicate.manualGlucoseNotYetUploadedToNightscout,
-            key: "date",
-            ascending: false
-        )
-
-        return try await context.perform {
-            guard let fetchedResults = results as? [GlucoseStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-
-            return fetchedResults.map { result in
-                NightscoutTreatment(
-                    duration: nil,
-                    rawDuration: nil,
-                    rawRate: nil,
-                    absolute: nil,
-                    rate: nil,
-                    eventType: .capillaryGlucose,
-                    createdAt: result.date,
-                    enteredBy: CarbsEntry.local,
-                    bolus: nil,
-                    insulin: nil,
-                    notes: "Trio User",
-                    carbs: nil,
-                    fat: nil,
-                    protein: nil,
-                    foodType: nil,
-                    targetTop: nil,
-                    targetBottom: nil,
-                    glucoseType: "Manual",
-                    glucose: self.settingsManager.settings
-                        .units == .mgdL ? (self.glucoseFormatter.string(from: Int(result.glucose) as NSNumber) ?? "")
-                        : (self.glucoseFormatter.string(from: Decimal(result.glucose).asMmolL as NSNumber) ?? ""),
-                    units: self.settingsManager.settings.units == .mmolL ? "mmol" : "mg/dl",
-                    id: result.id?.uuidString
-                )
+                if result.isManual {
+                    BloodGlucose(
+                        id: result.id?.uuidString ?? UUID().uuidString,
+                        mbg: Int(result.glucose),
+                        date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
+                        dateString: result.date ?? Date(),
+                        type: "mbg"
+                    )
+                } else {
+                    BloodGlucose(
+                        id: result.id?.uuidString ?? UUID().uuidString,
+                        sgv: Int(result.glucose),
+                        direction: BloodGlucose.Direction(from: result.direction ?? ""),
+                        date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
+                        dateString: result.date ?? Date(),
+                        unfiltered: Decimal(result.glucose),
+                        filtered: Decimal(result.glucose),
+                        noise: nil,
+                        glucose: Int(result.glucose),
+                        type: "sgv"
+                    )
+                }
             }
             }
         }
         }
     }
     }
@@ -501,7 +464,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
 
             return fetchedResults.map { result in
             return fetchedResults.map { result in
                 BloodGlucose(
                 BloodGlucose(
-                    _id: result.id?.uuidString ?? UUID().uuidString,
+                    id: result.id?.uuidString ?? UUID().uuidString,
                     sgv: Int(result.glucose),
                     sgv: Int(result.glucose),
                     direction: BloodGlucose.Direction(from: result.direction ?? ""),
                     direction: BloodGlucose.Direction(from: result.direction ?? ""),
                     date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
                     date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
@@ -533,7 +496,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
 
             return fetchedResults.map { result in
             return fetchedResults.map { result in
                 BloodGlucose(
                 BloodGlucose(
-                    _id: result.id?.uuidString ?? UUID().uuidString,
+                    id: result.id?.uuidString ?? UUID().uuidString,
                     sgv: Int(result.glucose),
                     sgv: Int(result.glucose),
                     direction: BloodGlucose.Direction(from: result.direction ?? ""),
                     direction: BloodGlucose.Direction(from: result.direction ?? ""),
                     date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
                     date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
@@ -565,7 +528,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
 
             return fetchedResults.map { result in
             return fetchedResults.map { result in
                 BloodGlucose(
                 BloodGlucose(
-                    _id: result.id?.uuidString ?? UUID().uuidString,
+                    id: result.id?.uuidString ?? UUID().uuidString,
                     sgv: Int(result.glucose),
                     sgv: Int(result.glucose),
                     direction: BloodGlucose.Direction(from: result.direction ?? ""),
                     direction: BloodGlucose.Direction(from: result.direction ?? ""),
                     date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
                     date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
@@ -598,7 +561,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
 
             return fetchedResults.map { result in
             return fetchedResults.map { result in
                 BloodGlucose(
                 BloodGlucose(
-                    _id: result.id?.uuidString ?? UUID().uuidString,
+                    id: result.id?.uuidString ?? UUID().uuidString,
                     sgv: Int(result.glucose),
                     sgv: Int(result.glucose),
                     direction: BloodGlucose.Direction(from: result.direction ?? ""),
                     direction: BloodGlucose.Direction(from: result.direction ?? ""),
                     date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
                     date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,

+ 16 - 1
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -205,6 +205,21 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                     newPumpEvent.isUploadedToTidepool = false
                     newPumpEvent.isUploadedToTidepool = false
                     newPumpEvent.note = event.title
                     newPumpEvent.note = event.title
 
 
+                case .replaceComponent(componentType: .infusionSet),
+                     .replaceComponent(componentType: .pump):
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
+                        continue
+                    }
+                    let newPumpEvent = PumpEventStored(context: self.context)
+                    newPumpEvent.id = UUID().uuidString
+                    newPumpEvent.timestamp = event.date
+                    newPumpEvent.type = PumpEvent.siteChange.rawValue
+                    newPumpEvent.isUploadedToNS = false
+                    newPumpEvent.isUploadedToHealth = false
+                    newPumpEvent.isUploadedToTidepool = false
+
                 default:
                 default:
                     continue
                     continue
                 }
                 }
@@ -418,7 +433,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         targetTop: nil,
                         targetTop: nil,
                         targetBottom: nil
                         targetBottom: nil
                     )
                     )
-                case PumpEvent.prime.rawValue:
+                case PumpEvent.siteChange.rawValue:
                     return NightscoutTreatment(
                     return NightscoutTreatment(
                         duration: nil,
                         duration: nil,
                         rawDuration: nil,
                         rawDuration: nil,

+ 3 - 2
Trio/Sources/APS/Storage/TempTargetsStorage.swift

@@ -136,6 +136,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
             newTempTarget.name = tempTarget.name
             newTempTarget.name = tempTarget.name
             newTempTarget.target = NSDecimalNumber(decimal: tempTarget.targetTop ?? 0)
             newTempTarget.target = NSDecimalNumber(decimal: tempTarget.targetTop ?? 0)
             newTempTarget.isPreset = tempTarget.isPreset ?? false
             newTempTarget.isPreset = tempTarget.isPreset ?? false
+            newTempTarget.enteredBy = tempTarget.enteredBy
 
 
             // Nullify half basal target to ensure the latest HBT is used via OpenAPS Manager when sending TT data to oref
             // Nullify half basal target to ensure the latest HBT is used via OpenAPS Manager when sending TT data to oref
             newTempTarget.halfBasalTarget = nil
             newTempTarget.halfBasalTarget = nil
@@ -293,7 +294,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
                     rate: nil,
                     rate: nil,
                     eventType: .nsTempTarget,
                     eventType: .nsTempTarget,
                     createdAt: tempTarget.date ?? Date(),
                     createdAt: tempTarget.date ?? Date(),
-                    enteredBy: TempTarget.local,
+                    enteredBy: tempTarget.enteredBy ?? TempTarget.local,
                     bolus: nil,
                     bolus: nil,
                     insulin: nil,
                     insulin: nil,
                     notes: tempTarget.name ?? TempTarget.custom,
                     notes: tempTarget.name ?? TempTarget.custom,
@@ -336,7 +337,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
                     rate: nil,
                     rate: nil,
                     eventType: .nsTempTarget,
                     eventType: .nsTempTarget,
                     createdAt: (tempTargetRun.startDate ?? tempTargetRun.tempTarget?.date) ?? Date(),
                     createdAt: (tempTargetRun.startDate ?? tempTargetRun.tempTarget?.date) ?? Date(),
-                    enteredBy: TempTarget.local,
+                    enteredBy: tempTargetRun.tempTarget?.enteredBy ?? TempTarget.local,
                     bolus: nil,
                     bolus: nil,
                     insulin: nil,
                     insulin: nil,
                     notes: tempTargetRun.tempTarget?.name ?? TempTarget.custom,
                     notes: tempTargetRun.tempTarget?.name ?? TempTarget.custom,

+ 12 - 0
Trio/Sources/Application/AppDelegate.swift

@@ -20,6 +20,18 @@ class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNoti
         Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(crashReportingEnabled)
         Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(crashReportingEnabled)
         Crashlytics.crashlytics().setCustomValue(Bundle.main.appDevVersion ?? "unknown", forKey: "app_dev_version")
         Crashlytics.crashlytics().setCustomValue(Bundle.main.appDevVersion ?? "unknown", forKey: "app_dev_version")
 
 
+        // Telemetry: record this cold launch into the sliding 7-day window. If
+        // consent is set and the build SHA changed since the last successful
+        // send, fire an immediate ping — the 24h scheduler can't notice a
+        // build update on its own. Then arm the recurring 24h timer.
+        TelemetryClient.shared.recordColdLaunch()
+        Task.detached {
+            if TelemetryClient.shared.buildShaChangedSinceLastSend() {
+                await TelemetryClient.shared.maybeSend()
+            }
+            TelemetryClient.shared.scheduleRecurring()
+        }
+
         return true
         return true
     }
     }
 
 

+ 30 - 0
Trio/Sources/Application/TrioApp.swift

@@ -40,6 +40,11 @@ extension Notification.Name {
     @State private var showOnboardingCompletedSplash = false
     @State private var showOnboardingCompletedSplash = false
     @State private var showMigrationError: Bool = false
     @State private var showMigrationError: Bool = false
 
 
+    // Telemetry: one-shot guard so the consent migration sheet is presented
+    // at most once per process even if scene activates repeatedly.
+    @State private var showTelemetryMigrationSheet = false
+    @State private var hasCheckedTelemetryMigration = false
+
     // Dependencies Assembler
     // Dependencies Assembler
     // contain all dependencies Assemblies
     // contain all dependencies Assemblies
     // TODO: Remove static key after update "Use Dependencies" logic
     // TODO: Remove static key after update "Use Dependencies" logic
@@ -340,6 +345,10 @@ extension Notification.Name {
                     self.showOnboardingCompletedSplash = true
                     self.showOnboardingCompletedSplash = true
                 }
                 }
             }
             }
+            .sheet(isPresented: $showTelemetryMigrationSheet) {
+                TelemetryMigrationSheetView()
+                    .interactiveDismissDisabled(true)
+            }
         }
         }
         .onChange(of: scenePhase) { _, newScenePhase in
         .onChange(of: scenePhase) { _, newScenePhase in
             debug(.default, "APPLICATION PHASE: \(newScenePhase)")
             debug(.default, "APPLICATION PHASE: \(newScenePhase)")
@@ -358,10 +367,31 @@ extension Notification.Name {
                 if initState.complete {
                 if initState.complete {
                     performCleanupIfNecessary()
                     performCleanupIfNecessary()
                 }
                 }
+                presentTelemetryMigrationSheetIfNeeded()
             }
             }
         }
         }
     }
     }
 
 
+    /// Presents the one-time telemetry consent sheet for users who completed
+    /// onboarding before telemetry existed. The condition (`onboardingCompleted
+    /// == true` and no telemetry decision yet) is checked once per process —
+    /// the in-app dismiss handler sets `telemetryConsentDecisionMade`, so a
+    /// re-foreground after the user picks will no longer match.
+    private func presentTelemetryMigrationSheetIfNeeded() {
+        guard !hasCheckedTelemetryMigration else { return }
+        hasCheckedTelemetryMigration = true
+
+        let onboarded = PropertyPersistentFlags.shared.onboardingCompleted == true
+        let telemetryDecided = PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true
+        guard onboarded, !telemetryDecided else { return }
+
+        // Defer one runloop so SwiftUI has finished settling on whatever root
+        // view was just shown (loading screen, splash, main view).
+        DispatchQueue.main.async {
+            showTelemetryMigrationSheet = true
+        }
+    }
+
     func configureTabBarAppearance() {
     func configureTabBarAppearance() {
         let appearance = UITabBarAppearance()
         let appearance = UITabBarAppearance()
         appearance.configureWithDefaultBackground()
         appearance.configureWithDefaultBackground()

+ 26 - 0
Trio/Sources/Helpers/PropertyPersistentFlags.swift

@@ -26,4 +26,30 @@ final class PropertyPersistentFlags {
 
 
     // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup.
     // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup.
     @PersistedProperty(key: "hasSeenFatProteinOrderChange") var hasSeenFatProteinOrderChange: Bool?
     @PersistedProperty(key: "hasSeenFatProteinOrderChange") var hasSeenFatProteinOrderChange: Bool?
+
+    // MARK: - Telemetry
+
+    //
+    // See Trio/Sources/Services/Telemetry/TelemetryClient.swift.
+    // `telemetryEnabled` gates the anonymous-usage POST. `diagnosticsSharingEnabled`
+    // remains the Crashlytics gate. Both flags `nil` means the user has not yet
+    // chosen — used to surface the one-time migration sheet to existing users.
+    @PersistedProperty(key: "telemetryEnabled") var telemetryEnabled: Bool?
+    @PersistedProperty(key: "telemetryConsentDecisionMade") var telemetryConsentDecisionMade: Bool?
+    @PersistedProperty(key: "telemetryLastSentAt") var telemetryLastSentAt: Date?
+    @PersistedProperty(key: "telemetryLastSentSha") var telemetryLastSentSha: String?
+    // Sliding 7-day window of cold-launch timestamps; count is sent as `coldLaunches7d`.
+    @PersistedProperty(key: "telemetryColdLaunchTimes") var telemetryColdLaunchTimes: [Date]?
+    // Stable per-install UUID. IDFV resets when the user removes all Trio-team apps;
+    // this survives independently and is wiped only by deleting Trio itself.
+    @PersistedProperty(key: "telemetryInstallId") var telemetryInstallId: String?
+
+    // App Attest "give up" signal — set on a 403 from /api/attest/register, meaning
+    // the server has rejected this app_id and there's no point retrying.
+    @PersistedProperty(key: "telemetryAttestForbidden") var telemetryAttestForbidden: Bool?
+
+    // Debug override for the telemetry server base URL. Empty/unset → use the
+    // production constant in TelemetryClient. Surfaced as a hidden field in
+    // App Diagnostics for local testing against a dev server.
+    @PersistedProperty(key: "telemetryDebugServerURL") var telemetryDebugServerURL: String?
 }
 }

+ 9 - 1
Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift

@@ -123,7 +123,15 @@ enum FileProtectionFixer {
             "onboardingCompleted.plist",
             "onboardingCompleted.plist",
             "diagnosticsSharing.plist",
             "diagnosticsSharing.plist",
             "lastCleanupDate.plist",
             "lastCleanupDate.plist",
-            "hasSeenFatProteinOrderChange.plist"
+            "hasSeenFatProteinOrderChange.plist",
+            "telemetryEnabled.plist",
+            "telemetryConsentDecisionMade.plist",
+            "telemetryLastSentAt.plist",
+            "telemetryLastSentSha.plist",
+            "telemetryColdLaunchTimes.plist",
+            "telemetryInstallId.plist",
+            "telemetryAttestForbidden.plist",
+            "telemetryDebugServerURL.plist"
         ]
         ]
 
 
         let fileManager = FileManager.default
         let fileManager = FileManager.default

Plik diff jest za duży
+ 24444 - 10131
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 4 - 0
Trio/Sources/Logger/Logger.swift

@@ -140,6 +140,7 @@ final class Logger {
     static let watchManager = Logger(category: .watchManager, reporter: baseReporter)
     static let watchManager = Logger(category: .watchManager, reporter: baseReporter)
     static let coreData = Logger(category: .coreData, reporter: baseReporter)
     static let coreData = Logger(category: .coreData, reporter: baseReporter)
     static let storage = Logger(category: .storage, reporter: baseReporter)
     static let storage = Logger(category: .storage, reporter: baseReporter)
+    static let telemetry = Logger(category: .telemetry, reporter: baseReporter)
 
 
     enum Category: String {
     enum Category: String {
         case `default`
         case `default`
@@ -154,6 +155,7 @@ final class Logger {
         case watchManager
         case watchManager
         case coreData
         case coreData
         case storage
         case storage
+        case telemetry
 
 
         var name: String {
         var name: String {
             rawValue.capitalizingFirstLetter()
             rawValue.capitalizingFirstLetter()
@@ -173,6 +175,7 @@ final class Logger {
             case .watchManager: return .watchManager
             case .watchManager: return .watchManager
             case .coreData: return .coreData
             case .coreData: return .coreData
             case .storage: return .storage
             case .storage: return .storage
+            case .telemetry: return .telemetry
             }
             }
         }
         }
 
 
@@ -190,6 +193,7 @@ final class Logger {
                  .remoteControl,
                  .remoteControl,
                  .service,
                  .service,
                  .storage,
                  .storage,
+                 .telemetry,
                  .watchManager:
                  .watchManager:
                 return OSLog(subsystem: subsystem, category: name)
                 return OSLog(subsystem: subsystem, category: name)
             }
             }

+ 1 - 1
Trio/Sources/Models/AlgorithmGlucose.swift

@@ -68,7 +68,7 @@ struct AlgorithmGlucose: Codable {
 
 
         try container.encode(dateFormatter.string(from: date ?? Date()), forKey: .dateString)
         try container.encode(dateFormatter.string(from: date ?? Date()), forKey: .dateString)
 
 
-        let dateAsUnixTimestamp = String(format: "%.0f", (date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000)
+        let dateAsUnixTimestamp = Int64((date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000)
         try container.encode(dateAsUnixTimestamp, forKey: .date)
         try container.encode(dateAsUnixTimestamp, forKey: .date)
 
 
         try container.encode(direction, forKey: .direction)
         try container.encode(direction, forKey: .direction)

+ 26 - 9
Trio/Sources/Models/BloodGlucose.swift

@@ -60,8 +60,10 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
     }
     }
 
 
     enum CodingKeys: String, CodingKey {
     enum CodingKeys: String, CodingKey {
-        case _id
+        case legacyId = "_id"
+        case id
         case sgv
         case sgv
+        case mbg
         case direction
         case direction
         case date
         case date
         case dateString
         case dateString
@@ -77,7 +79,12 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
 
 
     init(from decoder: Decoder) throws {
     init(from decoder: Decoder) throws {
         let container = try decoder.container(keyedBy: CodingKeys.self)
         let container = try decoder.container(keyedBy: CodingKeys.self)
-        _id = try container.decode(String.self, forKey: ._id)
+
+        let legacyId = try container.decodeIfPresent(String.self, forKey: .legacyId)
+        let explicitId = try container.decodeIfPresent(String.self, forKey: .id)
+
+        self.legacyId = legacyId
+        id = explicitId ?? legacyId ?? UUID().uuidString
 
 
         sgv = try? container.decodeIfPresent(Int.self, forKey: .sgv)
         sgv = try? container.decodeIfPresent(Int.self, forKey: .sgv)
         if sgv == nil {
         if sgv == nil {
@@ -87,6 +94,14 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
             }
             }
             // If both attempts fail, sgv remains nil
             // If both attempts fail, sgv remains nil
         }
         }
+        mbg = try? container.decodeIfPresent(Int.self, forKey: .mbg)
+        if mbg == nil {
+            // The nightscout API might return a double instead of an int, or the key might be missing
+            if let doubleValue = try? container.decodeIfPresent(Double.self, forKey: .mbg) {
+                mbg = Int(doubleValue)
+            }
+            // If both attempts fail, sgv remains nil
+        }
 
 
         direction = try container.decodeIfPresent(Direction.self, forKey: .direction)
         direction = try container.decodeIfPresent(Direction.self, forKey: .direction)
         date = try container.decode(Decimal.self, forKey: .date)
         date = try container.decode(Decimal.self, forKey: .date)
@@ -102,8 +117,10 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
     }
     }
 
 
     init(
     init(
-        _id: String = UUID().uuidString,
+        id: String = UUID().uuidString,
+        legacyId: String? = nil,
         sgv: Int? = nil,
         sgv: Int? = nil,
+        mbg: Int? = nil,
         direction: Direction? = nil,
         direction: Direction? = nil,
         date: Decimal,
         date: Decimal,
         dateString: Date,
         dateString: Date,
@@ -116,8 +133,10 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
         sessionStartDate: Date? = nil,
         sessionStartDate: Date? = nil,
         transmitterID: String? = nil
         transmitterID: String? = nil
     ) {
     ) {
-        self._id = _id
+        self.id = id
+        self.legacyId = legacyId
         self.sgv = sgv
         self.sgv = sgv
+        self.mbg = mbg
         self.direction = direction
         self.direction = direction
         self.date = date
         self.date = date
         self.dateString = dateString
         self.dateString = dateString
@@ -131,12 +150,10 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
         self.transmitterID = transmitterID
         self.transmitterID = transmitterID
     }
     }
 
 
-    var _id: String?
-    var id: String {
-        _id ?? UUID().uuidString
-    }
-
+    let legacyId: String?
+    var id: String
     var sgv: Int?
     var sgv: Int?
+    var mbg: Int?
     var direction: Direction?
     var direction: Direction?
     let date: Decimal
     let date: Decimal
     let dateString: Date
     let dateString: Date

+ 4 - 0
Trio/Sources/Models/CarbRatios.swift

@@ -1,5 +1,9 @@
 import Foundation
 import Foundation
 
 
+protocol CarbRatiosObserver {
+    func carbRatiosDidChange(_ carbRatios: CarbRatios)
+}
+
 struct CarbRatios: JSON {
 struct CarbRatios: JSON {
     let units: CarbUnit
     let units: CarbUnit
     let schedule: [CarbRatioEntry]
     let schedule: [CarbRatioEntry]

+ 4 - 0
Trio/Sources/Models/InsulinSensitivities.swift

@@ -1,5 +1,9 @@
 import Foundation
 import Foundation
 
 
+protocol InsulinSensitivitiesObserver {
+    func insulinSensitivitiesDidChange(_ sensitivities: InsulinSensitivities)
+}
+
 struct InsulinSensitivities: JSON {
 struct InsulinSensitivities: JSON {
     var units: GlucoseUnits
     var units: GlucoseUnits
     var userPreferredUnits: GlucoseUnits
     var userPreferredUnits: GlucoseUnits

+ 5 - 0
Trio/Sources/Models/TrioSettings.swift

@@ -75,6 +75,7 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var smartStackView: LockScreenView = .simple
     var smartStackView: LockScreenView = .simple
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
     var timeInRangeType: TimeInRangeType = .timeInTightRange
+    var requireAdjustmentsConfirmation: Bool = false
 
 
     /// Selected Garmin watchface (Trio or SwissAlpine)
     /// Selected Garmin watchface (Trio or SwissAlpine)
     var garminWatchface: GarminWatchface = .trio
     var garminWatchface: GarminWatchface = .trio
@@ -358,6 +359,10 @@ extension TrioSettings: Decodable {
             settings.timeInRangeType = timeInRangeType
             settings.timeInRangeType = timeInRangeType
         }
         }
 
 
+        if let requireAdjustmentsConfirmation = try? container.decode(Bool.self, forKey: .requireAdjustmentsConfirmation) {
+            settings.requireAdjustmentsConfirmation = requireAdjustmentsConfirmation
+        }
+
         if let garminWatchface = try? container.decode(GarminWatchface.self, forKey: .garminWatchface) {
         if let garminWatchface = try? container.decode(GarminWatchface.self, forKey: .garminWatchface) {
             settings.garminWatchface = garminWatchface
             settings.garminWatchface = garminWatchface
         }
         }

+ 5 - 0
Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift

@@ -13,6 +13,9 @@ extension Adjustments {
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
         @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
         @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
 
 
+        var requireAdjustmentsConfirmation: Bool = false
+        var shouldDisplayPresetStartConfirmDialog: Bool = false
+
         // MARK: - Override and Temp Target Properties
         // MARK: - Override and Temp Target Properties
 
 
         var overridePercentage: Double = 100
         var overridePercentage: Double = 100
@@ -162,6 +165,7 @@ extension Adjustments {
                 target: tempTargetTarget,
                 target: tempTargetTarget,
                 autosensMax: autosensMax
                 autosensMax: autosensMax
             )
             )
+            requireAdjustmentsConfirmation = settingsManager.settings.requireAdjustmentsConfirmation
             Task {
             Task {
                 await getCurrentGlucoseTarget()
                 await getCurrentGlucoseTarget()
             }
             }
@@ -256,6 +260,7 @@ extension Adjustments.StateModel: SettingsObserver, PreferencesObserver {
     /// Updates settings when they change.
     /// Updates settings when they change.
     func settingsDidChange(_: TrioSettings) {
     func settingsDidChange(_: TrioSettings) {
         units = settingsManager.settings.units
         units = settingsManager.settings.units
+        requireAdjustmentsConfirmation = settingsManager.settings.requireAdjustmentsConfirmation
         Task {
         Task {
             await getCurrentGlucoseTarget()
             await getCurrentGlucoseTarget()
         }
         }

+ 110 - 0
Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift

@@ -23,6 +23,7 @@ extension Adjustments {
         @State var isEditingTT = false
         @State var isEditingTT = false
         @State var showCancelOverrideConfirmDialog = false
         @State var showCancelOverrideConfirmDialog = false
         @State var showCancelTempTargetConfirmDialog = false
         @State var showCancelTempTargetConfirmDialog = false
+        @State var pendingPresetActivation: PendingPresetActivation?
 
 
         private var shouldDisplayStickyOverrideStopButton: Bool {
         private var shouldDisplayStickyOverrideStopButton: Bool {
             state.isOverrideEnabled && state.activeOverrideName.isNotEmpty
             state.isOverrideEnabled && state.activeOverrideName.isNotEmpty
@@ -171,6 +172,25 @@ extension Adjustments {
                 } message: {
                 } message: {
                     Text("Stop the Temp Target \"\(state.currentActiveTempTarget?.name ?? "")\"?")
                     Text("Stop the Temp Target \"\(state.currentActiveTempTarget?.name ?? "")\"?")
                 }
                 }
+                .confirmationDialog(
+                    "Activate Preset",
+                    isPresented: presetActivationConfirmationBinding
+                ) {
+                    Button("Activate") {
+                        if let activation = pendingPresetActivation {
+                            activatePreset(activation)
+                        }
+                    }
+
+                    Button("Cancel", role: .cancel) {
+                        state.shouldDisplayPresetStartConfirmDialog = false
+                        pendingPresetActivation = nil
+                    }
+                } message: {
+                    if let activation = pendingPresetActivation {
+                        Text(activation.confirmationMessage)
+                    }
+                }
             }).background(appState.trioBackgroundColor(for: colorScheme))
             }).background(appState.trioBackgroundColor(for: colorScheme))
         }
         }
 
 
@@ -291,3 +311,93 @@ extension Adjustments {
         }
         }
     }
     }
 }
 }
+
+// MARK: Preset Activation Handling
+
+extension Adjustments.RootView: View {
+    enum PendingPresetActivation {
+        case override(objectID: NSManagedObjectID, presetID: String?, name: String)
+        case tempTarget(objectID: NSManagedObjectID, presetID: String?, name: String)
+
+        var name: String {
+            switch self {
+            case let .override(_, _, name),
+                 let .tempTarget(_, _, name):
+                return name
+            }
+        }
+
+        var adjustmentType: String {
+            switch self {
+            case .override:
+                return String(localized: "Override")
+            case .tempTarget:
+                return String(localized: "Temp Target")
+            }
+        }
+
+        var confirmationMessage: String {
+            String(localized: "Start the \(adjustmentType) \"\(name)\"?", comment: "Confirmation message for starting a preset")
+        }
+    }
+
+    private var presetActivationConfirmationBinding: Binding<Bool> {
+        Binding(
+            get: {
+                state.requireAdjustmentsConfirmation &&
+                    state.shouldDisplayPresetStartConfirmDialog &&
+                    pendingPresetActivation != nil
+            },
+            set: { isPresented in
+                if !isPresented {
+                    state.shouldDisplayPresetStartConfirmDialog = false
+                    pendingPresetActivation = nil
+                }
+            }
+        )
+    }
+
+    func requestPresetActivation(_ activation: PendingPresetActivation) {
+        if state.requireAdjustmentsConfirmation {
+            pendingPresetActivation = activation
+            state.shouldDisplayPresetStartConfirmDialog = true
+        } else {
+            activatePreset(activation)
+        }
+    }
+
+    func activatePreset(_ activation: PendingPresetActivation) {
+        Task {
+            switch activation {
+            case let .override(objectID, presetID, _):
+                await state.enactOverridePreset(withID: objectID)
+
+                await MainActor.run {
+                    state.hideModal()
+                    selectedOverridePresetID = presetID
+                    showOverrideCheckmark = true
+                    state.shouldDisplayPresetStartConfirmDialog = false
+                    pendingPresetActivation = nil
+                }
+
+                DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+                    showOverrideCheckmark = false
+                }
+
+            case let .tempTarget(objectID, presetID, _):
+                await state.enactTempTargetPreset(withID: objectID)
+
+                await MainActor.run {
+                    selectedTempTargetPresetID = presetID
+                    showTempTargetCheckmark = true
+                    state.shouldDisplayPresetStartConfirmDialog = false
+                    pendingPresetActivation = nil
+                }
+
+                DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+                    showTempTargetCheckmark = false
+                }
+            }
+        }
+    }
+}

+ 15 - 17
Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift

@@ -17,10 +17,13 @@ extension Adjustments.RootView {
         Section {
         Section {
             ForEach(state.overridePresets) { preset in
             ForEach(state.overridePresets) { preset in
                 overridesView(for: preset, showCheckMark: showOverrideCheckmark) {
                 overridesView(for: preset, showCheckMark: showOverrideCheckmark) {
-                    enactOverridePreset(preset)
+                    requestOverridePresetActivation(preset)
+                }
+                .contextMenu {
+                    actionButtonsForOverrides(for: preset)
                 }
                 }
                 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
-                    swipeActionsForOverrides(for: preset)
+                    actionButtonsForOverrides(for: preset)
                 }
                 }
             }
             }
             .onMove(perform: state.reorderOverride)
             .onMove(perform: state.reorderOverride)
@@ -73,24 +76,19 @@ extension Adjustments.RootView {
         }
         }
     }
     }
 
 
-    func enactOverridePreset(_ preset: OverrideStored) {
-        Task {
-            let objectID = preset.objectID
-            await state.enactOverridePreset(withID: objectID)
-            state.hideModal()
-            selectedOverridePresetID = preset.id
-            showOverrideCheckmark = true
-
-            // Deactivate checkmark after 3 seconds
-            DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
-                showOverrideCheckmark = false
-            }
-        }
+    private func requestOverridePresetActivation(_ preset: OverrideStored) {
+        let activation = PendingPresetActivation.override(
+            objectID: preset.objectID,
+            presetID: preset.id,
+            name: preset.name ?? ""
+        )
+
+        requestPresetActivation(activation)
     }
     }
 
 
-    func swipeActionsForOverrides(for preset: OverrideStored) -> some View {
+    func actionButtonsForOverrides(for preset: OverrideStored) -> some View {
         Group {
         Group {
-            Button(role: .none) {
+            Button(role: .destructive) {
                 selectedOverride = preset
                 selectedOverride = preset
                 isConfirmDeletePresented = true
                 isConfirmDeletePresented = true
             } label: {
             } label: {

+ 15 - 15
Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift

@@ -21,7 +21,7 @@ extension Adjustments.RootView {
             ForEach(state.scheduledTempTargets) { tempTarget in
             ForEach(state.scheduledTempTargets) { tempTarget in
                 tempTargetView(for: tempTarget)
                 tempTargetView(for: tempTarget)
                     .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                     .swipeActions(edge: .trailing, allowsFullSwipe: true) {
-                        swipeActionsForTempTargets(for: tempTarget)
+                        actionButtonsForTempTargets(for: tempTarget)
                     }
                     }
             }
             }
             .listRowBackground(Color.chart)
             .listRowBackground(Color.chart)
@@ -34,10 +34,13 @@ extension Adjustments.RootView {
         Section {
         Section {
             ForEach(state.tempTargetPresets) { preset in
             ForEach(state.tempTargetPresets) { preset in
                 tempTargetView(for: preset, showCheckmark: showTempTargetCheckmark) {
                 tempTargetView(for: preset, showCheckmark: showTempTargetCheckmark) {
-                    enactTempTargetPreset(preset)
+                    requestTempTargetPresetActivation(preset)
+                }
+                .contextMenu {
+                    actionButtonsForTempTargets(for: preset)
                 }
                 }
                 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
-                    swipeActionsForTempTargets(for: preset)
+                    actionButtonsForTempTargets(for: preset)
                 }
                 }
             }
             }
             .onMove(perform: state.reorderTempTargets)
             .onMove(perform: state.reorderTempTargets)
@@ -61,22 +64,19 @@ extension Adjustments.RootView {
         }
         }
     }
     }
 
 
-    private func enactTempTargetPreset(_ preset: TempTargetStored) {
-        Task {
-            let objectID = preset.objectID
-            await state.enactTempTargetPreset(withID: objectID)
-            selectedTempTargetPresetID = preset.id?.uuidString
-            showTempTargetCheckmark = true
+    private func requestTempTargetPresetActivation(_ preset: TempTargetStored) {
+        let activation = PendingPresetActivation.tempTarget(
+            objectID: preset.objectID,
+            presetID: preset.id?.uuidString,
+            name: preset.name ?? ""
+        )
 
 
-            DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
-                showTempTargetCheckmark = false
-            }
-        }
+        requestPresetActivation(activation)
     }
     }
 
 
-    private func swipeActionsForTempTargets(for tempTarget: TempTargetStored) -> some View {
+    private func actionButtonsForTempTargets(for tempTarget: TempTargetStored) -> some View {
         Group {
         Group {
-            Button {
+            Button(role: .destructive) {
                 Task {
                 Task {
                     selectedTempTarget = tempTarget
                     selectedTempTarget = tempTarget
                     isConfirmDeletePresented = true
                     isConfirmDeletePresented = true

+ 5 - 0
Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift

@@ -7,6 +7,7 @@ extension AlgorithmAdvancedSettings {
         @Injected() var settings: SettingsManager!
         @Injected() var settings: SettingsManager!
         @Injected() var storage: FileStorage!
         @Injected() var storage: FileStorage!
         @Injected() var nightscout: NightscoutManager!
         @Injected() var nightscout: NightscoutManager!
+        @Injected() private var tidepoolManager: TidepoolManager!
 
 
         var units: GlucoseUnits = .mgdL
         var units: GlucoseUnits = .mgdL
 
 
@@ -78,6 +79,10 @@ extension AlgorithmAdvancedSettings {
                                 )
                                 )
                             }
                             }
                         }
                         }
+
+                        Task.detached(priority: .low) {
+                            await self.tidepoolManager.uploadSettings()
+                        }
                     } receiveValue: {}
                     } receiveValue: {}
                     .store(in: &lifetime)
                     .store(in: &lifetime)
             }
             }

+ 30 - 11
Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift

@@ -6,26 +6,45 @@ extension AppDiagnostics {
     @Observable final class StateModel: BaseStateModel<Provider> {
     @Observable final class StateModel: BaseStateModel<Provider> {
         // MARK: - Diagnostics Sharing Option
         // MARK: - Diagnostics Sharing Option
 
 
-        var diagnosticsSharingOption: DiagnosticsSharingOption = .enabled
+        var diagnosticsSharingOption: DiagnosticsSharingOption = .full
 
 
         override func subscribe() {
         override func subscribe() {
             loadDiagnostics()
             loadDiagnostics()
         }
         }
 
 
-        /// Loads the diagnostics sharing option from UserDefaults as a boolean.
+        /// Derives the 3-state option from the two underlying flags. Defaults
+        /// to `.full` for fresh installs (opt-out). For pre-telemetry users
+        /// who have Crashlytics on but haven't seen the migration sheet, we
+        /// surface `.crashOnly` until they pick — never auto-upgrade to
+        /// `.full` without an explicit decision.
         func loadDiagnostics() {
         func loadDiagnostics() {
-            if let storedDiagnosticsSharingOption = PropertyPersistentFlags.shared.diagnosticsSharingEnabled {
-                diagnosticsSharingOption = storedDiagnosticsSharingOption ? .enabled : .disabled
-            } else {
-                diagnosticsSharingOption = .enabled
-            }
+            let crashlytics = PropertyPersistentFlags.shared.diagnosticsSharingEnabled ?? true
+            let telemetryDecided = PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true
+            let telemetry = telemetryDecided
+                ? (PropertyPersistentFlags.shared.telemetryEnabled ?? false)
+                : false
+            diagnosticsSharingOption = DiagnosticsSharingOption(
+                crashlyticsEnabled: crashlytics,
+                telemetryEnabled: telemetry
+            )
         }
         }
 
 
-        /// Persists the current diagnostics sharing option to UserDefaults as a boolean.
+        /// Persists the current diagnostics sharing option to both underlying flags
+        /// and applies it to Crashlytics + the telemetry sender.
         func applyDiagnostics() {
         func applyDiagnostics() {
-            let booleanValue: Bool = diagnosticsSharingOption == .enabled
-            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = booleanValue
-            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(booleanValue)
+            let wasTelemetryOn = PropertyPersistentFlags.shared.telemetryEnabled == true
+
+            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = diagnosticsSharingOption.crashlyticsEnabled
+            PropertyPersistentFlags.shared.telemetryEnabled = diagnosticsSharingOption.telemetryEnabled
+            PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
+            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(diagnosticsSharingOption.crashlyticsEnabled)
+
+            // Fire an inaugural send on a fresh opt-in so the first data point
+            // arrives at the moment of consent rather than 24h later.
+            if diagnosticsSharingOption.telemetryEnabled, !wasTelemetryOn {
+                TelemetryClient.shared.scheduleRecurring()
+                Task.detached { await TelemetryClient.shared.maybeSend() }
+            }
         }
         }
     }
     }
 }
 }

+ 19 - 8
Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift

@@ -21,7 +21,7 @@ extension AppDiagnostics {
                                 Button(action: {
                                 Button(action: {
                                     state.diagnosticsSharingOption = option
                                     state.diagnosticsSharingOption = option
                                 }) {
                                 }) {
-                                    HStack {
+                                    HStack(alignment: .top, spacing: 12) {
                                         Image(
                                         Image(
                                             systemName: state
                                             systemName: state
                                                 .diagnosticsSharingOption == option ? "largecircle.fill.circle" : "circle"
                                                 .diagnosticsSharingOption == option ? "largecircle.fill.circle" : "circle"
@@ -29,8 +29,14 @@ extension AppDiagnostics {
                                         .foregroundColor(state.diagnosticsSharingOption == option ? .accentColor : .secondary)
                                         .foregroundColor(state.diagnosticsSharingOption == option ? .accentColor : .secondary)
                                         .imageScale(.large)
                                         .imageScale(.large)
 
 
-                                        Text(option.displayName)
-                                            .foregroundColor(.primary)
+                                        VStack(alignment: .leading, spacing: 4) {
+                                            Text(option.displayName)
+                                                .foregroundColor(.primary)
+                                                .bold()
+                                            Text(option.caption)
+                                                .font(.footnote)
+                                                .foregroundColor(.secondary)
+                                        }
 
 
                                         Spacer()
                                         Spacer()
                                     }
                                     }
@@ -48,32 +54,37 @@ extension AppDiagnostics {
                 ).listRowBackground(Color.chart)
                 ).listRowBackground(Color.chart)
 
 
                 Section {
                 Section {
+                    NavigationLink("What's sent") { TelemetryPreviewView() }
+                    NavigationLink("Privacy details") { TelemetryPrivacyView() }
+                }.listRowBackground(Color.chart)
+
+                Section {
                     VStack(alignment: .leading, spacing: 8) {
                     VStack(alignment: .leading, spacing: 8) {
                         Text("Why does Trio collect this data?").bold()
                         Text("Why does Trio collect this data?").bold()
                         VStack(alignment: .leading, spacing: 4) {
                         VStack(alignment: .leading, spacing: 4) {
                             BulletPoint(
                             BulletPoint(
                                 String(
                                 String(
-                                    localized: "App diagnostic insights help us enhance app stability, ensure safety for all users, and enable us to quickly identify and resolve critical issues."
+                                    localized: "App diagnostic insights — based on crash reports only — help us enhance app stability, ensure safety for all users, and quickly identify and resolve critical issues."
                                 )
                                 )
                             )
                             )
                             BulletPoint(
                             BulletPoint(
                                 String(
                                 String(
-                                    localized: "Trio collects the app's state on crash, device, iOS and general system info, and a stack trace."
+                                    localized: "Crash reports include the app's state on crash, device, iOS and general system info, and a stack trace. They are sent to a Google Firebase Crashlytics project maintained by the Trio team."
                                 )
                                 )
                             )
                             )
                             BulletPoint(
                             BulletPoint(
                                 String(
                                 String(
-                                    localized: "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, setting values, or similar."
+                                    localized: "Anonymous usage statistics include the app version and build, device and iOS version, which pump and CGM you have paired, and whether Nightscout, Tidepool, and Apple Health are configured (yes/no — no URLs or credentials). They are sent to a self-hosted Trio telemetry endpoint."
                                 )
                                 )
                             )
                             )
                             BulletPoint(
                             BulletPoint(
                                 String(
                                 String(
-                                    localized: "Trio does not track any usage metrics or any other personal data about users other than the used iPhone model and iOS version."
+                                    localized: "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, therapy setting values, or similar."
                                 )
                                 )
                             )
                             )
                         }
                         }
                         Text(
                         Text(
-                            "Diagnostics are sent to a Google Firebase Crashlytics project, which is securely maintained and accessed only by the Trio team."
+                            "Use \"What's sent\" above to inspect the exact JSON payload before deciding."
                         )
                         )
                     }
                     }
                     .font(.footnote)
                     .font(.footnote)

+ 5 - 0
Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift

@@ -4,6 +4,7 @@ import SwiftUI
 extension BasalProfileEditor {
 extension BasalProfileEditor {
     @Observable final class StateModel: BaseStateModel<Provider> {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Injected() private var nightscout: NightscoutManager!
         @ObservationIgnored @Injected() private var nightscout: NightscoutManager!
+        @ObservationIgnored @Injected() private var tidepoolManager: TidepoolManager!
         @ObservationIgnored @Injected() private var broadcaster: Broadcaster!
         @ObservationIgnored @Injected() private var broadcaster: Broadcaster!
 
 
         var syncInProgress: Bool = false
         var syncInProgress: Bool = false
@@ -127,6 +128,10 @@ extension BasalProfileEditor {
                                 debug(.default, "Failed to upload basal rates to Nightscout: \(error)")
                                 debug(.default, "Failed to upload basal rates to Nightscout: \(error)")
                             }
                             }
                         }
                         }
+
+                        Task.detached(priority: .low) {
+                            await self.tidepoolManager.uploadSettings()
+                        }
                     case .failure:
                     case .failure:
                         // Handle the error, show error message
                         // Handle the error, show error message
                         self.showAlert = true
                         self.showAlert = true

+ 13 - 0
Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift

@@ -3,6 +3,8 @@ import SwiftUI
 extension CarbRatioEditor {
 extension CarbRatioEditor {
     final class StateModel: BaseStateModel<Provider> {
     final class StateModel: BaseStateModel<Provider> {
         @Injected() private var nightscout: NightscoutManager!
         @Injected() private var nightscout: NightscoutManager!
+        @Injected() private var tidepoolManager: TidepoolManager!
+        @Injected() private var broadcaster: Broadcaster!
         @Published var items: [Item] = []
         @Published var items: [Item] = []
         @Published var initialItems: [Item] = []
         @Published var initialItems: [Item] = []
         @Published var therapyItems: [TherapySettingItem] = []
         @Published var therapyItems: [TherapySettingItem] = []
@@ -89,6 +91,13 @@ extension CarbRatioEditor {
             let profile = CarbRatios(units: .grams, schedule: schedule)
             let profile = CarbRatios(units: .grams, schedule: schedule)
             provider.saveProfile(profile)
             provider.saveProfile(profile)
             initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
             initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
+
+            DispatchQueue.main.async {
+                self.broadcaster.notify(CarbRatiosObserver.self, on: .main) {
+                    $0.carbRatiosDidChange(profile)
+                }
+            }
+
             Task.detached(priority: .low) {
             Task.detached(priority: .low) {
                 do {
                 do {
                     debug(.nightscout, "Attempting to upload CRs to Nightscout")
                     debug(.nightscout, "Attempting to upload CRs to Nightscout")
@@ -97,6 +106,10 @@ extension CarbRatioEditor {
                     debug(.default, "Failed to upload CRs to Nightscout: \(error)")
                     debug(.default, "Failed to upload CRs to Nightscout: \(error)")
                 }
                 }
             }
             }
+
+            Task.detached(priority: .low) {
+                await self.tidepoolManager.uploadSettings()
+            }
         }
         }
 
 
         func validate() {
         func validate() {

+ 49 - 43
Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift

@@ -5,13 +5,43 @@ extension DynamicSettings {
     struct RootView: BaseView {
     struct RootView: BaseView {
         let resolver: Resolver
         let resolver: Resolver
         @StateObject var state = StateModel()
         @StateObject var state = StateModel()
-        @State private var shouldDisplayHint: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var hintDetent = PresentationDetent.large
-        @State var selectedVerboseHint: AnyView?
-        @State var hintLabel: String?
+        @State private var hintPayload: HintPayload?
         @State private var decimalPlaceholder: Decimal = 0.0
         @State private var decimalPlaceholder: Decimal = 0.0
         @State private var booleanPlaceholder: Bool = false
         @State private var booleanPlaceholder: Bool = false
 
 
+        private struct HintPayload: Identifiable {
+            let id = UUID()
+            let label: String
+            let content: AnyView
+        }
+
+        private var shouldDisplayHintBinding: Binding<Bool> {
+            Binding(
+                get: { hintPayload != nil },
+                set: { newValue in
+                    if !newValue {
+                        hintPayload = nil
+                    } else if hintPayload == nil {
+                        hintPayload = HintPayload(label: "", content: AnyView(EmptyView()))
+                    }
+                }
+            )
+        }
+
+        private func verboseHintBinding(label: String) -> Binding<(any View)?> {
+            Binding(
+                get: { hintPayload?.content },
+                set: { newView in
+                    if let view = newView {
+                        hintPayload = HintPayload(label: label, content: AnyView(view))
+                    } else {
+                        hintPayload = nil
+                    }
+                }
+            )
+        }
+
         private var conversionFormatter: NumberFormatter {
         private var conversionFormatter: NumberFormatter {
             let formatter = NumberFormatter()
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
             formatter.numberStyle = .decimal
@@ -76,9 +106,9 @@ extension DynamicSettings {
                                 Spacer()
                                 Spacer()
                                 Button(
                                 Button(
                                     action: {
                                     action: {
-                                        hintLabel = String(localized: "Time in Range Chart Style")
-                                        selectedVerboseHint =
-                                            AnyView(
+                                        hintPayload = HintPayload(
+                                            label: String(localized: "Dynamic Insulin Sensitivity"),
+                                            content: AnyView(
                                                 VStack(alignment: .leading, spacing: 10) {
                                                 VStack(alignment: .leading, spacing: 10) {
                                                     Text("Default: Disabled").bold()
                                                     Text("Default: Disabled").bold()
                                                     Text(
                                                     Text(
@@ -124,7 +154,7 @@ extension DynamicSettings {
                                                     }
                                                     }
                                                 }
                                                 }
                                             )
                                             )
-                                        shouldDisplayHint.toggle()
+                                        )
                                     },
                                     },
                                     label: {
                                     label: {
                                         HStack {
                                         HStack {
@@ -142,14 +172,8 @@ extension DynamicSettings {
                         SettingInputSection(
                         SettingInputSection(
                             decimalValue: $state.adjustmentFactor,
                             decimalValue: $state.adjustmentFactor,
                             booleanValue: $booleanPlaceholder,
                             booleanValue: $booleanPlaceholder,
-                            shouldDisplayHint: $shouldDisplayHint,
-                            selectedVerboseHint: Binding(
-                                get: { selectedVerboseHint },
-                                set: {
-                                    selectedVerboseHint = $0.map { AnyView($0) }
-                                    hintLabel = String(localized: "Adjustment Factor (AF)")
-                                }
-                            ),
+                            shouldDisplayHint: shouldDisplayHintBinding,
+                            selectedVerboseHint: verboseHintBinding(label: String(localized: "Adjustment Factor (AF)")),
                             // TODO?: include conditional links to Desmos logarithmic graphs based on which .glucose setting is used
                             // TODO?: include conditional links to Desmos logarithmic graphs based on which .glucose setting is used
                             units: state.units,
                             units: state.units,
                             type: .decimal("adjustmentFactor"),
                             type: .decimal("adjustmentFactor"),
@@ -173,14 +197,8 @@ extension DynamicSettings {
                         SettingInputSection(
                         SettingInputSection(
                             decimalValue: $state.adjustmentFactorSigmoid,
                             decimalValue: $state.adjustmentFactorSigmoid,
                             booleanValue: $booleanPlaceholder,
                             booleanValue: $booleanPlaceholder,
-                            shouldDisplayHint: $shouldDisplayHint,
-                            selectedVerboseHint: Binding(
-                                get: { selectedVerboseHint },
-                                set: {
-                                    selectedVerboseHint = $0.map { AnyView($0) }
-                                    hintLabel = String(localized: "Sigmoid Adjustment Factor")
-                                }
-                            ),
+                            shouldDisplayHint: shouldDisplayHintBinding,
+                            selectedVerboseHint: verboseHintBinding(label: String(localized: "Sigmoid Adjustment Factor")),
                             units: state.units,
                             units: state.units,
                             type: .decimal("adjustmentFactorSigmoid"),
                             type: .decimal("adjustmentFactorSigmoid"),
                             label: String(localized: "Sigmoid Adjustment Factor"),
                             label: String(localized: "Sigmoid Adjustment Factor"),
@@ -207,14 +225,8 @@ extension DynamicSettings {
                     SettingInputSection(
                     SettingInputSection(
                         decimalValue: $state.weightPercentage,
                         decimalValue: $state.weightPercentage,
                         booleanValue: $booleanPlaceholder,
                         booleanValue: $booleanPlaceholder,
-                        shouldDisplayHint: $shouldDisplayHint,
-                        selectedVerboseHint: Binding(
-                            get: { selectedVerboseHint },
-                            set: {
-                                selectedVerboseHint = $0.map { AnyView($0) }
-                                hintLabel = String(localized: "Weighted Average of TDD")
-                            }
-                        ),
+                        shouldDisplayHint: shouldDisplayHintBinding,
+                        selectedVerboseHint: verboseHintBinding(label: String(localized: "Weighted Average of TDD")),
                         units: state.units,
                         units: state.units,
                         type: .decimal("weightPercentage"),
                         type: .decimal("weightPercentage"),
                         label: String(localized: "Weighted Average of TDD"),
                         label: String(localized: "Weighted Average of TDD"),
@@ -236,14 +248,8 @@ extension DynamicSettings {
                     SettingInputSection(
                     SettingInputSection(
                         decimalValue: $decimalPlaceholder,
                         decimalValue: $decimalPlaceholder,
                         booleanValue: $state.tddAdjBasal,
                         booleanValue: $state.tddAdjBasal,
-                        shouldDisplayHint: $shouldDisplayHint,
-                        selectedVerboseHint: Binding(
-                            get: { selectedVerboseHint },
-                            set: {
-                                selectedVerboseHint = $0.map { AnyView($0) }
-                                hintLabel = String(localized: "Adjust Basal")
-                            }
-                        ),
+                        shouldDisplayHint: shouldDisplayHintBinding,
+                        selectedVerboseHint: verboseHintBinding(label: String(localized: "Adjust Basal")),
                         units: state.units,
                         units: state.units,
                         type: .boolean,
                         type: .boolean,
                         label: String(localized: "Adjust Basal"),
                         label: String(localized: "Adjust Basal"),
@@ -264,12 +270,12 @@ extension DynamicSettings {
                 }
                 }
             }
             }
             .listSectionSpacing(sectionSpacing)
             .listSectionSpacing(sectionSpacing)
-            .sheet(isPresented: $shouldDisplayHint) {
+            .sheet(item: $hintPayload) { payload in
                 SettingInputHintView(
                 SettingInputHintView(
                     hintDetent: $hintDetent,
                     hintDetent: $hintDetent,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    hintLabel: hintLabel ?? "",
-                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    shouldDisplayHint: shouldDisplayHintBinding,
+                    hintLabel: payload.label,
+                    hintText: payload.content,
                     sheetTitle: String(localized: "Help", comment: "Help sheet title")
                     sheetTitle: String(localized: "Help", comment: "Help sheet title")
                 )
                 )
             }
             }

+ 5 - 0
Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsStateModel.swift

@@ -5,6 +5,7 @@ extension UnitsLimitsSettings {
     final class StateModel: BaseStateModel<Provider> {
     final class StateModel: BaseStateModel<Provider> {
         @Injected() var settings: SettingsManager!
         @Injected() var settings: SettingsManager!
         @Injected() var storage: FileStorage!
         @Injected() var storage: FileStorage!
+        @Injected() private var tidepoolManager: TidepoolManager!
 
 
         @Published var units: GlucoseUnits = .mgdL
         @Published var units: GlucoseUnits = .mgdL
         @Published var unitsIndex = 0 // index 0 is mg/dl
         @Published var unitsIndex = 0 // index 0 is mg/dl
@@ -56,6 +57,10 @@ extension UnitsLimitsSettings {
                         let settings = self.provider.settings()
                         let settings = self.provider.settings()
                         self.maxBasal = settings.maxBasal
                         self.maxBasal = settings.maxBasal
                         self.maxBolus = settings.maxBolus
                         self.maxBolus = settings.maxBolus
+
+                        Task.detached(priority: .low) {
+                            await self.tidepoolManager.uploadSettings()
+                        }
                     } receiveValue: {}
                     } receiveValue: {}
                     .store(in: &lifetime)
                     .store(in: &lifetime)
             }
             }

+ 161 - 0
Trio/Sources/Modules/History/HistoryDataFlow+Models.swift

@@ -0,0 +1,161 @@
+import Foundation
+import SwiftUI
+
+extension History {
+    class Treatment: Identifiable, Hashable, Equatable {
+        let id: String
+        let idPumpEvent: String?
+        let units: GlucoseUnits
+        let type: DataType
+        let date: Date
+        let amount: Decimal?
+        let secondAmount: Decimal?
+        let duration: Decimal?
+        let isFPU: Bool?
+        let fpuID: String?
+        let note: String?
+        let isSMB: Bool?
+        let isExternal: Bool?
+
+        private var numberFormatter: NumberFormatter {
+            let formatter = NumberFormatter()
+            formatter.numberStyle = .decimal
+            formatter.maximumFractionDigits = 2
+            return formatter
+        }
+
+        private var tempTargetFormater: NumberFormatter {
+            let formatter = NumberFormatter()
+            formatter.numberStyle = .decimal
+            formatter.maximumFractionDigits = 1
+            return formatter
+        }
+
+        init(
+            units: GlucoseUnits,
+            type: DataType,
+            date: Date,
+            amount: Decimal? = nil,
+            secondAmount: Decimal? = nil,
+            duration: Decimal? = nil,
+            id: String? = nil,
+            idPumpEvent: String? = nil,
+            isFPU: Bool? = nil,
+            fpuID: String? = nil,
+            note: String? = nil,
+            isSMB: Bool? = nil,
+            isExternal: Bool? = nil
+        ) {
+            self.units = units
+            self.type = type
+            self.date = date
+            self.amount = amount
+            self.secondAmount = secondAmount
+            self.duration = duration
+            self.id = id ?? UUID().uuidString
+            self.idPumpEvent = idPumpEvent
+            self.isFPU = isFPU
+            self.fpuID = fpuID
+            self.note = note
+            self.isSMB = isSMB
+            self.isExternal = isExternal
+        }
+
+        static func == (lhs: Treatment, rhs: Treatment) -> Bool {
+            lhs.id == rhs.id
+        }
+
+        func hash(into hasher: inout Hasher) {
+            hasher.combine(id)
+        }
+
+        var amountText: String {
+            guard let amount = amount else {
+                return ""
+            }
+
+            if amount == 0, duration == 0 {
+                return "Cancel temp"
+            }
+
+            switch type {
+            case .carbs:
+                return numberFormatter
+                    .string(from: amount as NSNumber)! + String(localized: " g", comment: "gram of carbs")
+            case .fpus:
+                return numberFormatter
+                    .string(from: amount as NSNumber)! + String(localized: " g", comment: "gram of carb equilvalents")
+            case .bolus:
+                var bolusText = " "
+                if isSMB ?? false {}
+                else if isExternal ?? false {
+                    bolusText += String(localized: "External", comment: "External Insulin")
+                } else {
+                    bolusText += String(localized: "Manual", comment: "Manual Bolus")
+                }
+
+                return numberFormatter
+                    .string(from: amount as NSNumber)! + String(localized: " U", comment: "Insulin unit") + bolusText
+            case .tempBasal:
+                return numberFormatter
+                    .string(from: amount as NSNumber)! + String(localized: " U/hr", comment: "Unit insulin per hour")
+            case .tempTarget:
+                var converted = amount
+                if units == .mmolL {
+                    converted = converted.asMmolL
+                }
+
+                guard var secondAmount = secondAmount else {
+                    return numberFormatter.string(from: converted as NSNumber)! + " \(units.rawValue)"
+                }
+                if units == .mmolL {
+                    secondAmount = secondAmount.asMmolL
+                }
+
+                return tempTargetFormater.string(from: converted as NSNumber)! + " - " + tempTargetFormater
+                    .string(from: secondAmount as NSNumber)! + " \(units.rawValue)"
+            case .resume,
+                 .suspend:
+                return type.name
+            }
+        }
+
+        var color: Color {
+            switch type {
+            case .carbs:
+                return .loopYellow
+            case .fpus:
+                return .orange.opacity(0.5)
+            case .bolus:
+                return Color.insulin
+            case .tempBasal:
+                return Color.insulin.opacity(0.4)
+            case .resume,
+                 .suspend,
+                 .tempTarget:
+                return .loopGray
+            }
+        }
+
+        var durationText: String? {
+            guard let duration = duration, duration > 0 else {
+                return nil
+            }
+            return numberFormatter.string(from: duration as NSNumber)! + " min"
+        }
+    }
+
+    class Glucose: Identifiable, Hashable, Equatable {
+        static func == (lhs: History.Glucose, rhs: History.Glucose) -> Bool {
+            lhs.glucose == rhs.glucose
+        }
+
+        let glucose: BloodGlucose
+
+        init(glucose: BloodGlucose) {
+            self.glucose = glucose
+        }
+
+        var id: String { glucose.id }
+    }
+}

+ 3 - 158
Trio/Sources/Modules/History/HistoryDataFlow.swift

@@ -82,170 +82,15 @@ enum History {
             }
             }
         }
         }
     }
     }
-
-    class Treatment: Identifiable, Hashable, Equatable {
-        let id: String
-        let idPumpEvent: String?
-        let units: GlucoseUnits
-        let type: DataType
-        let date: Date
-        let amount: Decimal?
-        let secondAmount: Decimal?
-        let duration: Decimal?
-        let isFPU: Bool?
-        let fpuID: String?
-        let note: String?
-        let isSMB: Bool?
-        let isExternal: Bool?
-
-        private var numberFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 2
-            return formatter
-        }
-
-        private var tempTargetFormater: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 1
-            return formatter
-        }
-
-        init(
-            units: GlucoseUnits,
-            type: DataType,
-            date: Date,
-            amount: Decimal? = nil,
-            secondAmount: Decimal? = nil,
-            duration: Decimal? = nil,
-            id: String? = nil,
-            idPumpEvent: String? = nil,
-            isFPU: Bool? = nil,
-            fpuID: String? = nil,
-            note: String? = nil,
-            isSMB: Bool? = nil,
-            isExternal: Bool? = nil
-        ) {
-            self.units = units
-            self.type = type
-            self.date = date
-            self.amount = amount
-            self.secondAmount = secondAmount
-            self.duration = duration
-            self.id = id ?? UUID().uuidString
-            self.idPumpEvent = idPumpEvent
-            self.isFPU = isFPU
-            self.fpuID = fpuID
-            self.note = note
-            self.isSMB = isSMB
-            self.isExternal = isExternal
-        }
-
-        static func == (lhs: Treatment, rhs: Treatment) -> Bool {
-            lhs.id == rhs.id
-        }
-
-        func hash(into hasher: inout Hasher) {
-            hasher.combine(id)
-        }
-
-        var amountText: String {
-            guard let amount = amount else {
-                return ""
-            }
-
-            if amount == 0, duration == 0 {
-                return "Cancel temp"
-            }
-
-            switch type {
-            case .carbs:
-                return numberFormatter
-                    .string(from: amount as NSNumber)! + String(localized: " g", comment: "gram of carbs")
-            case .fpus:
-                return numberFormatter
-                    .string(from: amount as NSNumber)! + String(localized: " g", comment: "gram of carb equilvalents")
-            case .bolus:
-                var bolusText = " "
-                if isSMB ?? false {}
-                else if isExternal ?? false {
-                    bolusText += String(localized: "External", comment: "External Insulin")
-                } else {
-                    bolusText += String(localized: "Manual", comment: "Manual Bolus")
-                }
-
-                return numberFormatter
-                    .string(from: amount as NSNumber)! + String(localized: " U", comment: "Insulin unit") + bolusText
-            case .tempBasal:
-                return numberFormatter
-                    .string(from: amount as NSNumber)! + String(localized: " U/hr", comment: "Unit insulin per hour")
-            case .tempTarget:
-                var converted = amount
-                if units == .mmolL {
-                    converted = converted.asMmolL
-                }
-
-                guard var secondAmount = secondAmount else {
-                    return numberFormatter.string(from: converted as NSNumber)! + " \(units.rawValue)"
-                }
-                if units == .mmolL {
-                    secondAmount = secondAmount.asMmolL
-                }
-
-                return tempTargetFormater.string(from: converted as NSNumber)! + " - " + tempTargetFormater
-                    .string(from: secondAmount as NSNumber)! + " \(units.rawValue)"
-            case .resume,
-                 .suspend:
-                return type.name
-            }
-        }
-
-        var color: Color {
-            switch type {
-            case .carbs:
-                return .loopYellow
-            case .fpus:
-                return .orange.opacity(0.5)
-            case .bolus:
-                return Color.insulin
-            case .tempBasal:
-                return Color.insulin.opacity(0.4)
-            case .resume,
-                 .suspend,
-                 .tempTarget:
-                return .loopGray
-            }
-        }
-
-        var durationText: String? {
-            guard let duration = duration, duration > 0 else {
-                return nil
-            }
-            return numberFormatter.string(from: duration as NSNumber)! + " min"
-        }
-    }
-
-    class Glucose: Identifiable, Hashable, Equatable {
-        static func == (lhs: History.Glucose, rhs: History.Glucose) -> Bool {
-            lhs.glucose == rhs.glucose
-        }
-
-        let glucose: BloodGlucose
-
-        init(glucose: BloodGlucose) {
-            self.glucose = glucose
-        }
-
-        var id: String { glucose.id }
-    }
 }
 }
 
 
 protocol HistoryProvider: Provider {
 protocol HistoryProvider: Provider {
     func deleteCarbsFromNightscout(withID id: String)
     func deleteCarbsFromNightscout(withID id: String)
     func deleteInsulinFromNightscout(withID id: String)
     func deleteInsulinFromNightscout(withID id: String)
-    func deleteManualGlucoseFromNightscout(withID id: String)
+    func deleteGlucoseFromNightscout(withID id: String, withDate date: Date)
     func deleteGlucoseFromHealth(withSyncID id: String)
     func deleteGlucoseFromHealth(withSyncID id: String)
     func deleteMealDataFromHealth(byID id: String, sampleType: HKSampleType)
     func deleteMealDataFromHealth(byID id: String, sampleType: HKSampleType)
     func deleteInsulinFromHealth(withSyncID id: String)
     func deleteInsulinFromHealth(withSyncID id: String)
+    func deleteInsulinFromTidepool(withSyncId id: String, amount: Decimal, at: Date)
+    func deleteCarbsFromTidepool(withSyncId id: UUID, carbs: Decimal, at: Date, enteredBy: String)
 }
 }

+ 65 - 0
Trio/Sources/Modules/History/HistoryDeletionTarget.swift

@@ -0,0 +1,65 @@
+import CoreData
+import Foundation
+
+extension History {
+    enum DeletionTarget: Identifiable {
+        case glucose(GlucoseStored)
+        case insulin(PumpEventStored)
+        case carbs(CarbEntryStored)
+
+        var id: NSManagedObjectID {
+            switch self {
+            case let .glucose(glucose): return glucose.objectID
+            case let .insulin(pumpEvent): return pumpEvent.objectID
+            case let .carbs(carbEntry): return carbEntry.objectID
+            }
+        }
+
+        func title(units _: GlucoseUnits) -> String {
+            switch self {
+            case .glucose:
+                return String(localized: "Delete Glucose?", comment: "Alert title for deleting glucose")
+            case .insulin:
+                return String(localized: "Delete Insulin?", comment: "Alert title for deleting insulin")
+            case let .carbs(carbEntry):
+                if carbEntry.fpuID == nil {
+                    return String(localized: "Delete Carbs?", comment: "Alert title for deleting carbs")
+                }
+                return carbEntry.isFPU
+                    ? String(localized: "Delete Carbs Equivalents?", comment: "Alert title for deleting carb equivalents")
+                    : String(localized: "Delete Carbs?", comment: "Alert title for deleting carbs")
+            }
+        }
+
+        func message(units: GlucoseUnits) -> String? {
+            switch self {
+            case let .glucose(glucose):
+                let glucoseToDisplay = units == .mgdL
+                    ? glucose.glucose.description
+                    : Int(glucose.glucose).formattedAsMmolL
+                return Formatter.dateFormatter.string(from: glucose.date ?? Date())
+                    + ", " + glucoseToDisplay + " " + units.rawValue
+            case let .insulin(pumpEvent):
+                var text = Formatter.dateFormatter.string(from: pumpEvent.timestamp ?? Date())
+                    + ", "
+                    + (Formatter.decimalFormatterWithThreeFractionDigits.string(from: pumpEvent.bolus?.amount ?? 0) ?? "0")
+                    + String(localized: " U", comment: "Insulin unit")
+                if let bolus = pumpEvent.bolus, bolus.isSMB {
+                    text += String(localized: " SMB", comment: "Super Micro Bolus indicator in delete alert")
+                }
+                return text
+            case let .carbs(carbEntry):
+                if carbEntry.fpuID == nil {
+                    return Formatter.dateFormatter.string(from: carbEntry.date ?? Date())
+                        + ", "
+                        + (Formatter.decimalFormatterWithTwoFractionDigits.string(for: carbEntry.carbs) ?? "0")
+                        + String(localized: " g", comment: "gram of carbs")
+                }
+                return String(
+                    localized: "All FPUs and the carbs of the meal will be deleted.",
+                    comment: "Alert message for meal deletion"
+                )
+            }
+        }
+    }
+}

+ 2 - 2
Trio/Sources/Modules/History/HistoryProvider.swift

@@ -35,10 +35,10 @@ extension History {
             }
             }
         }
         }
 
 
-        func deleteManualGlucoseFromNightscout(withID id: String) {
+        func deleteGlucoseFromNightscout(withID id: String, withDate date: Date) {
             Task.detached { [weak self] in
             Task.detached { [weak self] in
                 guard let self = self else { return }
                 guard let self = self else { return }
-                await self.nightscoutManager.deleteManualGlucose(withID: id)
+                await self.nightscoutManager.deleteGlucose(withID: id, withDate: date)
             }
             }
         }
         }
 
 

+ 291 - 0
Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift

@@ -0,0 +1,291 @@
+import CoreData
+import Foundation
+
+extension History.StateModel {
+    // MARK: - Entry Management
+
+    /// Updates a carb/FPU entry with new values and handles the necessary cleanup and recreation of FPU entries
+    /// - Parameters:
+    ///   - treatmentObjectID: The ID of the entry to update
+    ///   - newCarbs: The new carbs value
+    ///   - newFat: The new fat value
+    ///   - newProtein: The new protein value
+    ///   - newNote: The new note text
+    ///   - newDate: The new date for the entry
+    func updateEntry(
+        _ treatmentObjectID: NSManagedObjectID,
+        newCarbs: Decimal,
+        newFat: Decimal,
+        newProtein: Decimal,
+        newNote: String,
+        newDate: Date
+    ) {
+        Task {
+            do {
+                // Get original date from entry to re-create the entry later with the updated values and the same date
+                guard let originalEntry = await getOriginalEntryValues(treatmentObjectID) else { return }
+
+                // Deletion logic for carb and FPU entries
+                try await deleteOldEntries(
+                    treatmentObjectID,
+                    originalEntry: originalEntry,
+                    newCarbs: newCarbs,
+                    newFat: newFat,
+                    newProtein: newProtein,
+                    newNote: newNote
+                )
+
+                try await createNewEntries(
+                    originalDate: newDate,
+                    newCarbs: newCarbs,
+                    newFat: newFat,
+                    newProtein: newProtein,
+                    newNote: newNote
+                )
+
+                await syncWithServices()
+
+                // Perform a determine basal sync to update cob
+                try await apsManager.determineBasalSync()
+
+            } catch {
+                debug(.default, "\(DebuggingIdentifiers.failed) failed to update entry: \(error)")
+            }
+        }
+    }
+
+    private func createNewEntries(
+        originalDate: Date,
+        newCarbs: Decimal,
+        newFat: Decimal,
+        newProtein: Decimal,
+        newNote: String
+    ) async throws {
+        let newEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: Date(),
+            actualDate: originalDate,
+            carbs: newCarbs,
+            fat: newFat,
+            protein: newProtein,
+            note: newNote,
+            enteredBy: CarbsEntry.local,
+            isFPU: false,
+            fpuID: newFat > 0 || newProtein > 0 ? UUID().uuidString : nil
+        )
+
+        // Handles internally whether to create fake carbs or not based on whether fat > 0 or protein > 0
+        try await carbsStorage.storeCarbs([newEntry], areFetchedFromRemote: false)
+    }
+
+    /// Deletes the old carb/ FPU entries and creates new ones with updated values
+    /// - Parameters:
+    ///   - treatmentObjectID: The ID of the entry to delete
+    ///   - originalDate: The original date to preserve
+    ///   - newCarbs: The new carbs value
+    ///   - newFat: The new fat value
+    ///   - newProtein: The new protein value
+    ///   - newNote: The new note text
+    private func deleteOldEntries(
+        _ treatmentObjectID: NSManagedObjectID,
+        originalEntry: (
+            entryValues: (date: Date, carbs: Double, fat: Double, protein: Double)?,
+            entryId: NSManagedObjectID
+        ),
+        newCarbs _: Decimal,
+        newFat _: Decimal,
+        newProtein _: Decimal,
+        newNote _: String
+    ) async throws {
+        if ((originalEntry.entryValues?.carbs ?? 0) == 0 && (originalEntry.entryValues?.fat ?? 0) > 0) ||
+            ((originalEntry.entryValues?.carbs ?? 0) == 0 && (originalEntry.entryValues?.protein ?? 0) > 0)
+        {
+            // Delete the zero-carb-entry and all its carb equivalents connected by the same fpuID from remote services and Core Data
+            // Use fpuID
+            try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: true)
+        } else if ((originalEntry.entryValues?.carbs ?? 0) > 0 && (originalEntry.entryValues?.fat ?? 0) > 0) ||
+            ((originalEntry.entryValues?.carbs ?? 0) > 0 && (originalEntry.entryValues?.protein ?? 0) > 0)
+        {
+            // Delete carb entry and carb equivalents that are all connected by the same fpuID from remote services and Core Data
+            // Use fpuID
+            try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: true)
+
+        } else {
+            // Delete just the carb entry since there are no carb equivalents
+            // Use NSManagedObjectID
+            try await deleteCarbs(treatmentObjectID)
+        }
+    }
+
+    /// Retrieves the original entry values
+    /// - Parameter objectID: The ID of the entry
+    /// - Returns: A tuple of the old entry values and its original date and the objectID or nil
+    private func getOriginalEntryValues(_ objectID: NSManagedObjectID) async
+        -> (entryValues: (date: Date, carbs: Double, fat: Double, protein: Double)?, entryId: NSManagedObjectID)?
+    {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateContext"
+        context.transactionAuthor = "updateEntry"
+
+        return await context.perform {
+            do {
+                guard let entry = try context.existingObject(with: objectID) as? CarbEntryStored, let entryDate = entry.date
+                else { return nil }
+
+                return (
+                    entryValues: (date: entryDate, carbs: entry.carbs, fat: entry.fat, protein: entry.protein),
+                    entryId: entry.objectID
+                )
+            } catch let error as NSError {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to get original date with error: \(error.userInfo)")
+                return nil
+            }
+        }
+    }
+
+    /// Synchronizes the FPU/ Carb entry with all remote services in parallel
+    private func syncWithServices() async {
+        async let nightscoutUpload: () = provider.nightscoutManager.uploadCarbs()
+        async let healthKitUpload: () = provider.healthkitManager.uploadCarbs()
+        async let tidepoolUpload: () = provider.tidepoolManager.uploadCarbs()
+
+        _ = await [nightscoutUpload, healthKitUpload, tidepoolUpload]
+    }
+
+    // MARK: - Entry Loading
+
+    /// Loads the values of a carb or FPU entry from Core Data
+    /// - Parameter objectID: The ID of the entry to load
+    /// - Returns: A tuple containing the entry's values, or nil if not found
+    func loadEntryValues(from objectID: NSManagedObjectID) async
+        -> (carbs: Decimal, fat: Decimal, protein: Decimal, note: String, date: Date)?
+    {
+        let context = CoreDataStack.shared.persistentContainer.viewContext
+
+        return await context.perform {
+            do {
+                guard let entry = try context.existingObject(with: objectID) as? CarbEntryStored,
+                      let entryDate = entry.date
+                else { return nil }
+
+                return (
+                    carbs: Decimal(entry.carbs),
+                    fat: Decimal(entry.fat),
+                    protein: Decimal(entry.protein),
+                    note: entry.note ?? "",
+                    date: entryDate
+                )
+            } catch {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to load entry: \(error)")
+                return nil
+            }
+        }
+    }
+
+    // MARK: - FPU Entry Handling
+
+    /// Handles the loading of FPU entries based on their type
+    /// If the user taps on an FPU entry in the DataTable list, there are two cases:
+    /// - the User has entered this FPU entry WITH carbs
+    /// - the User has entered this FPU entry WITHOUT carbs
+    /// In the first case, we simply need to load the corresponding carb entry. For this case THIS is the entry we want to edit.
+    /// In the second case, we need to load the zero-carb entry that actually holds the FPU values (and the carbs). For this case THIS is the entry we want to edit.
+    /// - Parameter objectID: The ID of the FPU entry
+    /// - Returns: A tuple containing the entry values and ID, or nil if not found
+    func handleFPUEntry(_ objectID: NSManagedObjectID) async
+        -> (
+            entryValues: (carbs: Decimal, fat: Decimal, protein: Decimal, note: String, date: Date)?,
+            entryID: NSManagedObjectID?
+        )?
+    {
+        // Case 1: FPU entry WITH carbs
+        if let correspondingCarbEntryID = await getCorrespondingCarbEntry(objectID) {
+            if let values = await loadEntryValues(from: correspondingCarbEntryID) {
+                return (values, correspondingCarbEntryID)
+            }
+        }
+        // Case 2: FPU entry WITHOUT carbs
+        else if let originalEntryID = await getZeroCarbNonFPUEntry(objectID) {
+            if let values = await loadEntryValues(from: originalEntryID) {
+                return (values, originalEntryID)
+            }
+        }
+        return nil
+    }
+
+    /// Retrieves the original zero-carb non-FPU entry for a given FPU entry.
+    /// This is used when the user has entered a FPU entry WITHOUT carbs.
+    /// - Parameter treatmentObjectID: The ID of the FPU entry
+    /// - Returns: The ID of the original entry, or nil if not found
+    func getZeroCarbNonFPUEntry(_ treatmentObjectID: NSManagedObjectID) async -> NSManagedObjectID? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fpuContext"
+
+        return await context.perform {
+            do {
+                // Get the fpuID from the selected entry
+                guard let selectedEntry = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored,
+                      let fpuID = selectedEntry.fpuID
+                else { return nil }
+
+                // Fetch the original zero-carb entry (non-FPU) with the same fpuID
+                let last24Hours = Date().addingTimeInterval(-60 * 60 * 24)
+                let request = CarbEntryStored.fetchRequest()
+                request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
+                    NSPredicate(format: "date >= %@", last24Hours as NSDate),
+                    NSPredicate(format: "fpuID == %@", fpuID as CVarArg),
+                    NSPredicate(format: "isFPU == NO"),
+                    NSPredicate(format: "carbs == 0")
+                ])
+                request.fetchLimit = 1
+
+                let originalEntry = try context.fetch(request).first
+                debugPrint("FPU fetch result: \(originalEntry != nil ? "Entry found" : "No entry found")")
+                return originalEntry?.objectID
+
+            } catch let error as NSError {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch original FPU entry: \(error.userInfo)")
+                return nil
+            }
+        }
+    }
+
+    /// Retrieves the corresponding carb entry for a given FPU entry.
+    /// This is used when the user has entered a carb entry WITH FPUs all at once.
+    /// - Parameter treatmentObjectID: The ID of the FPU entry
+    /// - Returns: The ID of the corresponding carb entry, or nil if not found
+    func getCorrespondingCarbEntry(_ treatmentObjectID: NSManagedObjectID) async -> NSManagedObjectID? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "carbContext"
+
+        return await context.perform {
+            do {
+                // Get the fpuID from the selected entry
+                guard let selectedEntry = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored,
+                      let fpuID = selectedEntry.fpuID
+                else { return nil }
+
+                // Fetch the corresponding carb entry with the same fpuID
+                let last24Hours = Date().addingTimeInterval(-24.hours.timeInterval)
+                let request = CarbEntryStored.fetchRequest()
+                request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
+                    NSPredicate(format: "date >= %@", last24Hours as NSDate),
+                    NSPredicate(format: "fpuID == %@", fpuID as CVarArg),
+                    NSPredicate(format: "isFPU == NO"),
+                    NSPredicate(format: "(carbs > 0) OR (fat > 0) OR (protein > 0)")
+                ])
+                request.fetchLimit = 1
+
+                let correspondingCarbEntry = try context.fetch(request).first
+                debugPrint(
+                    "Corresponding carb entry fetch result: \(correspondingCarbEntry != nil ? "Entry found" : "No entry found")"
+                )
+                return correspondingCarbEntry?.objectID
+
+            } catch let error as NSError {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch corresponding carb entry: \(error.userInfo)")
+                return nil
+            }
+        }
+    }
+}

+ 119 - 0
Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Carbs.swift

@@ -0,0 +1,119 @@
+import CoreData
+import Foundation
+import HealthKit
+
+extension History.StateModel {
+    // Carb and FPU deletion from history
+    /// - **Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
+    func invokeCarbDeletionTask(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) {
+        Task {
+            do {
+                /// Set the variables that control the CustomProgressView BEFORE the actual deletion
+                /// otherwise the determineBasalSync gets executed first, sets waitForSuggestion to false and afterwards waitForSuggestion is set in this function to true, leading to an endless animation
+                await MainActor.run {
+                    carbEntryDeleted = true
+                    waitForSuggestion = true
+                }
+
+                try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: isFpuOrComplexMeal)
+
+            } catch {
+                debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete carbs: \(error)")
+                await MainActor.run {
+                    carbEntryDeleted = false
+                    waitForSuggestion = false
+                }
+            }
+        }
+    }
+
+    func deleteCarbs(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) async throws {
+        // Delete from Nightscout/Apple Health/Tidepool
+        await deleteFromServices(treatmentObjectID, isFPUDeletion: isFpuOrComplexMeal)
+
+        // Delete carbs from Core Data
+        await carbsStorage.deleteCarbsEntryStored(treatmentObjectID)
+
+        // Perform a determine basal sync to update cob
+        try await apsManager.determineBasalSync()
+    }
+
+    /// Deletes carb and FPU entries from all connected services (Nightscout, HealthKit, Tidepool)
+    /// - Parameters:
+    ///   - treatmentObjectID: The Core Data object ID of the entry to delete
+    ///   - isFPUDeletion: Flag indicating if this is a FPU deletion that requires special handling
+    ///     - If true: Will first fetch the corresponding carb entry and then delete both FPU and carb entries
+    ///     - If false: Will delete the entry directly as a standard carb deletion
+    /// - Note: This function handles three scenarios:
+    ///   1. Standard carb deletion (isFPUDeletion = false)
+    ///   2. FPU-only deletion (isFPUDeletion = true)
+    ///   3. Combined carb+FPU deletion (isFPUDeletion = true)
+    func deleteFromServices(_ treatmentObjectID: NSManagedObjectID, isFPUDeletion: Bool = false) async {
+        let taskContext = CoreDataStack.shared.newTaskContext()
+        taskContext.name = "deleteContext"
+        taskContext.transactionAuthor = "deleteCarbsFromServices"
+
+        var carbEntry: CarbEntryStored?
+        var objectIDToDelete = treatmentObjectID
+
+        // For FPU deletions, first get the corresponding carb entry
+        if isFPUDeletion {
+            guard let correspondingEntry: (
+                entryValues: (carbs: Decimal, fat: Decimal, protein: Decimal, note: String, date: Date)?,
+                entryID: NSManagedObjectID?
+            ) = await handleFPUEntry(treatmentObjectID),
+                let nsManagedObjectID = correspondingEntry.entryID
+            else { return }
+
+            objectIDToDelete = nsManagedObjectID
+        }
+
+        // Delete entries from all services
+        await taskContext.perform {
+            do {
+                carbEntry = try taskContext.existingObject(with: objectIDToDelete) as? CarbEntryStored
+                guard let carbEntry = carbEntry else {
+                    debugPrint("Carb entry for deletion not found. \(DebuggingIdentifiers.failed)")
+                    return
+                }
+
+                // Delete FPU related entries if they exist
+                if let fpuID = carbEntry.fpuID {
+                    // Delete Fat and Protein entries from Nightscout
+                    self.provider.deleteCarbsFromNightscout(withID: fpuID.uuidString)
+
+                    // Delete Fat and Protein entries from Apple Health
+                    let healthObjectsToDelete: [HKSampleType?] = [
+                        AppleHealthConfig.healthFatObject,
+                        AppleHealthConfig.healthProteinObject
+                    ]
+
+                    for sampleType in healthObjectsToDelete {
+                        if let validSampleType = sampleType {
+                            self.provider.deleteMealDataFromHealth(byID: fpuID.uuidString, sampleType: validSampleType)
+                        }
+                    }
+                }
+
+                // Delete carb entries if they exist
+                if let id = carbEntry.id, let entryDate = carbEntry.date {
+                    self.provider.deleteCarbsFromNightscout(withID: id.uuidString)
+
+                    // Delete carbs from Apple Health
+                    if let sampleType = AppleHealthConfig.healthCarbObject {
+                        self.provider.deleteMealDataFromHealth(byID: id.uuidString, sampleType: sampleType)
+                    }
+
+                    self.provider.deleteCarbsFromTidepool(
+                        withSyncId: id,
+                        carbs: Decimal(carbEntry.carbs),
+                        at: entryDate,
+                        enteredBy: CarbsEntry.local
+                    )
+                }
+            } catch {
+                debugPrint("\(DebuggingIdentifiers.failed) Error deleting entries: \(error)")
+            }
+        }
+    }
+}

+ 55 - 0
Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Glucose.swift

@@ -0,0 +1,55 @@
+import CoreData
+import Foundation
+
+extension History.StateModel {
+    /// Initiates the glucose deletion process asynchronously
+    /// - Parameter treatmentObjectID: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
+    func invokeGlucoseDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
+        Task {
+            await deleteGlucose(treatmentObjectID)
+        }
+    }
+
+    func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
+        // Delete from Apple Health/Tidepool
+        await deleteGlucoseFromServices(treatmentObjectID)
+
+        // Delete from Core Data
+        await glucoseStorage.deleteGlucose(treatmentObjectID)
+    }
+
+    func deleteGlucoseFromServices(_ treatmentObjectID: NSManagedObjectID) async {
+        let taskContext = CoreDataStack.shared.newTaskContext()
+        taskContext.name = "deleteContext"
+        taskContext.transactionAuthor = "deleteGlucoseFromServices"
+
+        await taskContext.perform {
+            do {
+                let result = try taskContext.existingObject(with: treatmentObjectID) as? GlucoseStored
+
+                guard let glucoseToDelete = result else {
+                    debugPrint("Data Table State: \(#function) \(DebuggingIdentifiers.failed) glucose not found in core data")
+                    return
+                }
+
+                // Delete from Nightscout
+                if let id = glucoseToDelete.id?.uuidString, let date = glucoseToDelete.date {
+                    self.provider.deleteGlucoseFromNightscout(withID: id, withDate: date)
+                }
+
+                // Delete from Apple Health
+                if let id = glucoseToDelete.id?.uuidString {
+                    self.provider.deleteGlucoseFromHealth(withSyncID: id)
+                }
+
+                debugPrint(
+                    "\(#file) \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from remote service(s) (Nightscout, Apple Health, Tidepool)"
+                )
+            } catch {
+                debugPrint(
+                    "\(#file) \(#function) \(DebuggingIdentifiers.failed) error while deleting glucose remote service(s) (Nightscout, Apple Health, Tidepool) with error: \(error)"
+                )
+            }
+        }
+    }
+}

+ 85 - 0
Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Insulin.swift

@@ -0,0 +1,85 @@
+import CoreData
+import Foundation
+
+extension History.StateModel {
+    // Insulin deletion from history
+    /// - **Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
+    func invokeInsulinDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
+        Task {
+            do {
+                try await invokeInsulinDeletion(treatmentObjectID)
+            } catch {
+                debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete insulin entry: \(error)")
+            }
+        }
+    }
+
+    func invokeInsulinDeletion(_ treatmentObjectID: NSManagedObjectID) async throws {
+        do {
+            let authenticated = try await unlockmanager.unlock()
+
+            guard authenticated else {
+                debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Authentication Error")
+                return
+            }
+
+            /// Set variables that control the CustomProgressView to true AFTER the authentication and BEFORE the actual determineBasalSync
+            /// We definitely need to set the variables BEFORE the actual sync
+            /// otherwise the determineBasalSync gets executed first, sets waitForSuggestion to false and afterwards waitForSuggestion is set in this function to true, leading to an endless animation
+            /// But we also want it AFTER the authentication
+            /// otherwise the animation would pop up even before the authentication prompt appears to the user
+            await MainActor.run {
+                insulinEntryDeleted = true
+                waitForSuggestion = true
+            }
+
+            // Delete from remote service(s) (i.e. Nightscout, Apple Health, Tidepool)
+            await deleteInsulinFromServices(with: treatmentObjectID)
+
+            // Delete from Core Data
+            await CoreDataStack.shared.deleteObject(identifiedBy: treatmentObjectID)
+
+            // Perform a determine basal sync to update iob
+            try await apsManager.determineBasalSync()
+        } catch {
+            debugPrint(
+                "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error while Insulin Deletion Task: \(error)"
+            )
+            await MainActor.run {
+                insulinEntryDeleted = false
+                waitForSuggestion = false
+            }
+        }
+    }
+
+    func deleteInsulinFromServices(with treatmentObjectID: NSManagedObjectID) async {
+        let taskContext = CoreDataStack.shared.newTaskContext()
+        taskContext.name = "deleteContext"
+        taskContext.transactionAuthor = "deleteInsulinFromServices"
+
+        await taskContext.perform {
+            do {
+                guard let treatmentToDelete = try taskContext.existingObject(with: treatmentObjectID) as? PumpEventStored
+                else {
+                    debug(.default, "Could not cast the object to PumpEventStored")
+                    return
+                }
+
+                if let id = treatmentToDelete.id, let timestamp = treatmentToDelete.timestamp,
+                   let bolus = treatmentToDelete.bolus, let bolusAmount = bolus.amount
+                {
+                    self.provider.deleteInsulinFromNightscout(withID: id)
+                    self.provider.deleteInsulinFromHealth(withSyncID: id)
+                    self.provider.deleteInsulinFromTidepool(withSyncId: id, amount: bolusAmount as Decimal, at: timestamp)
+                }
+
+                taskContext.delete(treatmentToDelete)
+                try taskContext.save()
+
+                debug(.default, "Successfully deleted the treatment object.")
+            } catch {
+                debug(.default, "Failed to delete the treatment object: \(error)")
+            }
+        }
+    }
+}

+ 0 - 533
Trio/Sources/Modules/History/HistoryStateModel.swift

@@ -42,57 +42,6 @@ extension History {
             glucoseStorage.isGlucoseDataFresh(glucoseDate)
             glucoseStorage.isGlucoseDataFresh(glucoseDate)
         }
         }
 
 
-        /// Initiates the glucose deletion process asynchronously
-        /// - Parameter treatmentObjectID: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
-        func invokeGlucoseDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
-            Task {
-                await deleteGlucose(treatmentObjectID)
-            }
-        }
-
-        func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
-            // Delete from Apple Health/Tidepool
-            await deleteGlucoseFromServices(treatmentObjectID)
-
-            // Delete from Core Data
-            await glucoseStorage.deleteGlucose(treatmentObjectID)
-        }
-
-        func deleteGlucoseFromServices(_ treatmentObjectID: NSManagedObjectID) async {
-            let taskContext = CoreDataStack.shared.newTaskContext()
-            taskContext.name = "deleteContext"
-            taskContext.transactionAuthor = "deleteGlucoseFromServices"
-
-            await taskContext.perform {
-                do {
-                    let result = try taskContext.existingObject(with: treatmentObjectID) as? GlucoseStored
-
-                    guard let glucoseToDelete = result else {
-                        debugPrint("Data Table State: \(#function) \(DebuggingIdentifiers.failed) glucose not found in core data")
-                        return
-                    }
-
-                    // Delete from Nightscout
-                    if let id = glucoseToDelete.id?.uuidString {
-                        self.provider.deleteManualGlucoseFromNightscout(withID: id)
-                    }
-
-                    // Delete from Apple Health
-                    if let id = glucoseToDelete.id?.uuidString {
-                        self.provider.deleteGlucoseFromHealth(withSyncID: id)
-                    }
-
-                    debugPrint(
-                        "\(#file) \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from remote service(s) (Nightscout, Apple Health, Tidepool)"
-                    )
-                } catch {
-                    debugPrint(
-                        "\(#file) \(#function) \(DebuggingIdentifiers.failed) error while deleting glucose remote service(s) (Nightscout, Apple Health, Tidepool) with error: \(error)"
-                    )
-                }
-            }
-        }
-
         func addManualGlucose() {
         func addManualGlucose() {
             // Always save value in mg/dL
             // Always save value in mg/dL
             let glucose = units == .mmolL ? manualGlucose.asMgdL : manualGlucose
             let glucose = units == .mmolL ? manualGlucose.asMgdL : manualGlucose
@@ -100,488 +49,6 @@ extension History {
 
 
             glucoseStorage.addManualGlucose(glucose: glucoseAsInt)
             glucoseStorage.addManualGlucose(glucose: glucoseAsInt)
         }
         }
-
-        // Carb and FPU deletion from history
-        /// - **Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
-        func invokeCarbDeletionTask(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) {
-            Task {
-                do {
-                    /// Set the variables that control the CustomProgressView BEFORE the actual deletion
-                    /// otherwise the determineBasalSync gets executed first, sets waitForSuggestion to false and afterwards waitForSuggestion is set in this function to true, leading to an endless animation
-                    await MainActor.run {
-                        carbEntryDeleted = true
-                        waitForSuggestion = true
-                    }
-
-                    try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: isFpuOrComplexMeal)
-
-                } catch {
-                    debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete carbs: \(error)")
-                    await MainActor.run {
-                        carbEntryDeleted = false
-                        waitForSuggestion = false
-                    }
-                }
-            }
-        }
-
-        func deleteCarbs(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) async throws {
-            // Delete from Nightscout/Apple Health/Tidepool
-            await deleteFromServices(treatmentObjectID, isFPUDeletion: isFpuOrComplexMeal)
-
-            // Delete carbs from Core Data
-            await carbsStorage.deleteCarbsEntryStored(treatmentObjectID)
-
-            // Perform a determine basal sync to update cob
-            try await apsManager.determineBasalSync()
-        }
-
-        /// Deletes carb and FPU entries from all connected services (Nightscout, HealthKit, Tidepool)
-        /// - Parameters:
-        ///   - treatmentObjectID: The Core Data object ID of the entry to delete
-        ///   - isFPUDeletion: Flag indicating if this is a FPU deletion that requires special handling
-        ///     - If true: Will first fetch the corresponding carb entry and then delete both FPU and carb entries
-        ///     - If false: Will delete the entry directly as a standard carb deletion
-        /// - Note: This function handles three scenarios:
-        ///   1. Standard carb deletion (isFPUDeletion = false)
-        ///   2. FPU-only deletion (isFPUDeletion = true)
-        ///   3. Combined carb+FPU deletion (isFPUDeletion = true)
-        func deleteFromServices(_ treatmentObjectID: NSManagedObjectID, isFPUDeletion: Bool = false) async {
-            let taskContext = CoreDataStack.shared.newTaskContext()
-            taskContext.name = "deleteContext"
-            taskContext.transactionAuthor = "deleteCarbsFromServices"
-
-            var carbEntry: CarbEntryStored?
-            var objectIDToDelete = treatmentObjectID
-
-            // For FPU deletions, first get the corresponding carb entry
-            if isFPUDeletion {
-                guard let correspondingEntry: (
-                    entryValues: (carbs: Decimal, fat: Decimal, protein: Decimal, note: String, date: Date)?,
-                    entryID: NSManagedObjectID?
-                ) = await handleFPUEntry(treatmentObjectID),
-                    let nsManagedObjectID = correspondingEntry.entryID
-                else { return }
-
-                objectIDToDelete = nsManagedObjectID
-            }
-
-            // Delete entries from all services
-            await taskContext.perform {
-                do {
-                    carbEntry = try taskContext.existingObject(with: objectIDToDelete) as? CarbEntryStored
-                    guard let carbEntry = carbEntry else {
-                        debugPrint("Carb entry for deletion not found. \(DebuggingIdentifiers.failed)")
-                        return
-                    }
-
-                    // Delete FPU related entries if they exist
-                    if let fpuID = carbEntry.fpuID {
-                        // Delete Fat and Protein entries from Nightscout
-                        self.provider.deleteCarbsFromNightscout(withID: fpuID.uuidString)
-
-                        // Delete Fat and Protein entries from Apple Health
-                        let healthObjectsToDelete: [HKSampleType?] = [
-                            AppleHealthConfig.healthFatObject,
-                            AppleHealthConfig.healthProteinObject
-                        ]
-
-                        for sampleType in healthObjectsToDelete {
-                            if let validSampleType = sampleType {
-                                self.provider.deleteMealDataFromHealth(byID: fpuID.uuidString, sampleType: validSampleType)
-                            }
-                        }
-                    }
-
-                    // Delete carb entries if they exist
-                    if let id = carbEntry.id, let entryDate = carbEntry.date {
-                        self.provider.deleteCarbsFromNightscout(withID: id.uuidString)
-
-                        // Delete carbs from Apple Health
-                        if let sampleType = AppleHealthConfig.healthCarbObject {
-                            self.provider.deleteMealDataFromHealth(byID: id.uuidString, sampleType: sampleType)
-                        }
-
-                        self.provider.deleteCarbsFromTidepool(
-                            withSyncId: id,
-                            carbs: Decimal(carbEntry.carbs),
-                            at: entryDate,
-                            enteredBy: CarbsEntry.local
-                        )
-                    }
-                } catch {
-                    debugPrint("\(DebuggingIdentifiers.failed) Error deleting entries: \(error)")
-                }
-            }
-        }
-
-        // Insulin deletion from history
-        /// - **Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
-        func invokeInsulinDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
-            Task {
-                do {
-                    try await invokeInsulinDeletion(treatmentObjectID)
-                } catch {
-                    debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete insulin entry: \(error)")
-                }
-            }
-        }
-
-        func invokeInsulinDeletion(_ treatmentObjectID: NSManagedObjectID) async throws {
-            do {
-                let authenticated = try await unlockmanager.unlock()
-
-                guard authenticated else {
-                    debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Authentication Error")
-                    return
-                }
-
-                /// Set variables that control the CustomProgressView to true AFTER the authentication and BEFORE the actual determineBasalSync
-                /// We definitely need to set the variables BEFORE the actual sync
-                /// otherwise the determineBasalSync gets executed first, sets waitForSuggestion to false and afterwards waitForSuggestion is set in this function to true, leading to an endless animation
-                /// But we also want it AFTER the authentication
-                /// otherwise the animation would pop up even before the authentication prompt appears to the user
-                await MainActor.run {
-                    insulinEntryDeleted = true
-                    waitForSuggestion = true
-                }
-
-                // Delete from remote service(s) (i.e. Nightscout, Apple Health, Tidepool)
-                await deleteInsulinFromServices(with: treatmentObjectID)
-
-                // Delete from Core Data
-                await CoreDataStack.shared.deleteObject(identifiedBy: treatmentObjectID)
-
-                // Perform a determine basal sync to update iob
-                try await apsManager.determineBasalSync()
-            } catch {
-                debugPrint(
-                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error while Insulin Deletion Task: \(error)"
-                )
-                await MainActor.run {
-                    insulinEntryDeleted = false
-                    waitForSuggestion = false
-                }
-            }
-        }
-
-        func deleteInsulinFromServices(with treatmentObjectID: NSManagedObjectID) async {
-            let taskContext = CoreDataStack.shared.newTaskContext()
-            taskContext.name = "deleteContext"
-            taskContext.transactionAuthor = "deleteInsulinFromServices"
-
-            await taskContext.perform {
-                do {
-                    guard let treatmentToDelete = try taskContext.existingObject(with: treatmentObjectID) as? PumpEventStored
-                    else {
-                        debug(.default, "Could not cast the object to PumpEventStored")
-                        return
-                    }
-
-                    if let id = treatmentToDelete.id, let timestamp = treatmentToDelete.timestamp,
-                       let bolus = treatmentToDelete.bolus, let bolusAmount = bolus.amount
-                    {
-                        self.provider.deleteInsulinFromNightscout(withID: id)
-                        self.provider.deleteInsulinFromHealth(withSyncID: id)
-                        self.provider.deleteInsulinFromTidepool(withSyncId: id, amount: bolusAmount as Decimal, at: timestamp)
-                    }
-
-                    taskContext.delete(treatmentToDelete)
-                    try taskContext.save()
-
-                    debug(.default, "Successfully deleted the treatment object.")
-                } catch {
-                    debug(.default, "Failed to delete the treatment object: \(error)")
-                }
-            }
-        }
-
-        // MARK: - Entry Management
-
-        /// Updates a carb/FPU entry with new values and handles the necessary cleanup and recreation of FPU entries
-        /// - Parameters:
-        ///   - treatmentObjectID: The ID of the entry to update
-        ///   - newCarbs: The new carbs value
-        ///   - newFat: The new fat value
-        ///   - newProtein: The new protein value
-        ///   - newNote: The new note text
-        ///   - newDate: The new date for the entry
-        func updateEntry(
-            _ treatmentObjectID: NSManagedObjectID,
-            newCarbs: Decimal,
-            newFat: Decimal,
-            newProtein: Decimal,
-            newNote: String,
-            newDate: Date
-        ) {
-            Task {
-                do {
-                    // Get original date from entry to re-create the entry later with the updated values and the same date
-                    guard let originalEntry = await getOriginalEntryValues(treatmentObjectID) else { return }
-
-                    // Deletion logic for carb and FPU entries
-                    try await deleteOldEntries(
-                        treatmentObjectID,
-                        originalEntry: originalEntry,
-                        newCarbs: newCarbs,
-                        newFat: newFat,
-                        newProtein: newProtein,
-                        newNote: newNote
-                    )
-
-                    try await createNewEntries(
-                        originalDate: newDate,
-                        newCarbs: newCarbs,
-                        newFat: newFat,
-                        newProtein: newProtein,
-                        newNote: newNote
-                    )
-
-                    await syncWithServices()
-
-                    // Perform a determine basal sync to update cob
-                    try await apsManager.determineBasalSync()
-
-                } catch {
-                    debug(.default, "\(DebuggingIdentifiers.failed) failed to update entry: \(error)")
-                }
-            }
-        }
-
-        private func createNewEntries(
-            originalDate: Date,
-            newCarbs: Decimal,
-            newFat: Decimal,
-            newProtein: Decimal,
-            newNote: String
-        ) async throws {
-            let newEntry = CarbsEntry(
-                id: UUID().uuidString,
-                createdAt: Date(),
-                actualDate: originalDate,
-                carbs: newCarbs,
-                fat: newFat,
-                protein: newProtein,
-                note: newNote,
-                enteredBy: CarbsEntry.local,
-                isFPU: false,
-                fpuID: newFat > 0 || newProtein > 0 ? UUID().uuidString : nil
-            )
-
-            // Handles internally whether to create fake carbs or not based on whether fat > 0 or protein > 0
-            try await carbsStorage.storeCarbs([newEntry], areFetchedFromRemote: false)
-        }
-
-        /// Deletes the old carb/ FPU entries and creates new ones with updated values
-        /// - Parameters:
-        ///   - treatmentObjectID: The ID of the entry to delete
-        ///   - originalDate: The original date to preserve
-        ///   - newCarbs: The new carbs value
-        ///   - newFat: The new fat value
-        ///   - newProtein: The new protein value
-        ///   - newNote: The new note text
-        private func deleteOldEntries(
-            _ treatmentObjectID: NSManagedObjectID,
-            originalEntry: (
-                entryValues: (date: Date, carbs: Double, fat: Double, protein: Double)?,
-                entryId: NSManagedObjectID
-            ),
-            newCarbs _: Decimal,
-            newFat _: Decimal,
-            newProtein _: Decimal,
-            newNote _: String
-        ) async throws {
-            if ((originalEntry.entryValues?.carbs ?? 0) == 0 && (originalEntry.entryValues?.fat ?? 0) > 0) ||
-                ((originalEntry.entryValues?.carbs ?? 0) == 0 && (originalEntry.entryValues?.protein ?? 0) > 0)
-            {
-                // Delete the zero-carb-entry and all its carb equivalents connected by the same fpuID from remote services and Core Data
-                // Use fpuID
-                try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: true)
-            } else if ((originalEntry.entryValues?.carbs ?? 0) > 0 && (originalEntry.entryValues?.fat ?? 0) > 0) ||
-                ((originalEntry.entryValues?.carbs ?? 0) > 0 && (originalEntry.entryValues?.protein ?? 0) > 0)
-            {
-                // Delete carb entry and carb equivalents that are all connected by the same fpuID from remote services and Core Data
-                // Use fpuID
-                try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: true)
-
-            } else {
-                // Delete just the carb entry since there are no carb equivalents
-                // Use NSManagedObjectID
-                try await deleteCarbs(treatmentObjectID)
-            }
-        }
-
-        /// Retrieves the original entry values
-        /// - Parameter objectID: The ID of the entry
-        /// - Returns: A tuple of the old entry values and its original date and the objectID or nil
-        private func getOriginalEntryValues(_ objectID: NSManagedObjectID) async
-            -> (entryValues: (date: Date, carbs: Double, fat: Double, protein: Double)?, entryId: NSManagedObjectID)?
-        {
-            let context = CoreDataStack.shared.newTaskContext()
-            context.name = "updateContext"
-            context.transactionAuthor = "updateEntry"
-
-            return await context.perform {
-                do {
-                    guard let entry = try context.existingObject(with: objectID) as? CarbEntryStored, let entryDate = entry.date
-                    else { return nil }
-
-                    return (
-                        entryValues: (date: entryDate, carbs: entry.carbs, fat: entry.fat, protein: entry.protein),
-                        entryId: entry.objectID
-                    )
-                } catch let error as NSError {
-                    debugPrint("\(DebuggingIdentifiers.failed) Failed to get original date with error: \(error.userInfo)")
-                    return nil
-                }
-            }
-        }
-
-        /// Synchronizes the FPU/ Carb entry with all remote services in parallel
-        private func syncWithServices() async {
-            async let nightscoutUpload: () = provider.nightscoutManager.uploadCarbs()
-            async let healthKitUpload: () = provider.healthkitManager.uploadCarbs()
-            async let tidepoolUpload: () = provider.tidepoolManager.uploadCarbs()
-
-            _ = await [nightscoutUpload, healthKitUpload, tidepoolUpload]
-        }
-
-        // MARK: - Entry Loading
-
-        /// Loads the values of a carb or FPU entry from Core Data
-        /// - Parameter objectID: The ID of the entry to load
-        /// - Returns: A tuple containing the entry's values, or nil if not found
-        func loadEntryValues(from objectID: NSManagedObjectID) async
-            -> (carbs: Decimal, fat: Decimal, protein: Decimal, note: String, date: Date)?
-        {
-            let context = CoreDataStack.shared.persistentContainer.viewContext
-
-            return await context.perform {
-                do {
-                    guard let entry = try context.existingObject(with: objectID) as? CarbEntryStored,
-                          let entryDate = entry.date
-                    else { return nil }
-
-                    return (
-                        carbs: Decimal(entry.carbs),
-                        fat: Decimal(entry.fat),
-                        protein: Decimal(entry.protein),
-                        note: entry.note ?? "",
-                        date: entryDate
-                    )
-                } catch {
-                    debugPrint("\(DebuggingIdentifiers.failed) Failed to load entry: \(error)")
-                    return nil
-                }
-            }
-        }
-
-        // MARK: - FPU Entry Handling
-
-        /// Handles the loading of FPU entries based on their type
-        /// If the user taps on an FPU entry in the DataTable list, there are two cases:
-        /// - the User has entered this FPU entry WITH carbs
-        /// - the User has entered this FPU entry WITHOUT carbs
-        /// In the first case, we simply need to load the corresponding carb entry. For this case THIS is the entry we want to edit.
-        /// In the second case, we need to load the zero-carb entry that actually holds the FPU values (and the carbs). For this case THIS is the entry we want to edit.
-        /// - Parameter objectID: The ID of the FPU entry
-        /// - Returns: A tuple containing the entry values and ID, or nil if not found
-        func handleFPUEntry(_ objectID: NSManagedObjectID) async
-            -> (
-                entryValues: (carbs: Decimal, fat: Decimal, protein: Decimal, note: String, date: Date)?,
-                entryID: NSManagedObjectID?
-            )?
-        {
-            // Case 1: FPU entry WITH carbs
-            if let correspondingCarbEntryID = await getCorrespondingCarbEntry(objectID) {
-                if let values = await loadEntryValues(from: correspondingCarbEntryID) {
-                    return (values, correspondingCarbEntryID)
-                }
-            }
-            // Case 2: FPU entry WITHOUT carbs
-            else if let originalEntryID = await getZeroCarbNonFPUEntry(objectID) {
-                if let values = await loadEntryValues(from: originalEntryID) {
-                    return (values, originalEntryID)
-                }
-            }
-            return nil
-        }
-
-        /// Retrieves the original zero-carb non-FPU entry for a given FPU entry.
-        /// This is used when the user has entered a FPU entry WITHOUT carbs.
-        /// - Parameter treatmentObjectID: The ID of the FPU entry
-        /// - Returns: The ID of the original entry, or nil if not found
-        func getZeroCarbNonFPUEntry(_ treatmentObjectID: NSManagedObjectID) async -> NSManagedObjectID? {
-            let context = CoreDataStack.shared.newTaskContext()
-            context.name = "fpuContext"
-
-            return await context.perform {
-                do {
-                    // Get the fpuID from the selected entry
-                    guard let selectedEntry = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored,
-                          let fpuID = selectedEntry.fpuID
-                    else { return nil }
-
-                    // Fetch the original zero-carb entry (non-FPU) with the same fpuID
-                    let last24Hours = Date().addingTimeInterval(-60 * 60 * 24)
-                    let request = CarbEntryStored.fetchRequest()
-                    request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
-                        NSPredicate(format: "date >= %@", last24Hours as NSDate),
-                        NSPredicate(format: "fpuID == %@", fpuID as CVarArg),
-                        NSPredicate(format: "isFPU == NO"),
-                        NSPredicate(format: "carbs == 0")
-                    ])
-                    request.fetchLimit = 1
-
-                    let originalEntry = try context.fetch(request).first
-                    debugPrint("FPU fetch result: \(originalEntry != nil ? "Entry found" : "No entry found")")
-                    return originalEntry?.objectID
-
-                } catch let error as NSError {
-                    debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch original FPU entry: \(error.userInfo)")
-                    return nil
-                }
-            }
-        }
-
-        /// Retrieves the corresponding carb entry for a given FPU entry.
-        /// This is used when the user has entered a carb entry WITH FPUs all at once.
-        /// - Parameter treatmentObjectID: The ID of the FPU entry
-        /// - Returns: The ID of the corresponding carb entry, or nil if not found
-        func getCorrespondingCarbEntry(_ treatmentObjectID: NSManagedObjectID) async -> NSManagedObjectID? {
-            let context = CoreDataStack.shared.newTaskContext()
-            context.name = "carbContext"
-
-            return await context.perform {
-                do {
-                    // Get the fpuID from the selected entry
-                    guard let selectedEntry = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored,
-                          let fpuID = selectedEntry.fpuID
-                    else { return nil }
-
-                    // Fetch the corresponding carb entry with the same fpuID
-                    let last24Hours = Date().addingTimeInterval(-24.hours.timeInterval)
-                    let request = CarbEntryStored.fetchRequest()
-                    request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
-                        NSPredicate(format: "date >= %@", last24Hours as NSDate),
-                        NSPredicate(format: "fpuID == %@", fpuID as CVarArg),
-                        NSPredicate(format: "isFPU == NO"),
-                        NSPredicate(format: "(carbs > 0) OR (fat > 0) OR (protein > 0)")
-                    ])
-                    request.fetchLimit = 1
-
-                    let correspondingCarbEntry = try context.fetch(request).first
-                    debugPrint(
-                        "Corresponding carb entry fetch result: \(correspondingCarbEntry != nil ? "Entry found" : "No entry found")"
-                    )
-                    return correspondingCarbEntry?.objectID
-
-                } catch let error as NSError {
-                    debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch corresponding carb entry: \(error.userInfo)")
-                    return nil
-                }
-            }
-        }
     }
     }
 }
 }
 
 

+ 72 - 0
Trio/Sources/Modules/History/View/HistoryRootView+AddGlucose.swift

@@ -0,0 +1,72 @@
+import SwiftUI
+
+extension History.RootView {
+    var manualGlucoseFormatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        if state.units == .mgdL {
+            formatter.maximumIntegerDigits = 3
+            formatter.maximumFractionDigits = 0
+        } else {
+            formatter.maximumIntegerDigits = 2
+            formatter.minimumFractionDigits = 0
+            formatter.maximumFractionDigits = 1
+        }
+        formatter.roundingMode = .halfUp
+        return formatter
+    }
+
+    @ViewBuilder func addGlucoseView() -> some View {
+        let limitLow: Decimal = state.units == .mgdL ? Decimal(14) : 14.asMmolL
+        let limitHigh: Decimal = state.units == .mgdL ? Decimal(720) : 720.asMmolL
+
+        NavigationView {
+            VStack {
+                Form {
+                    Section {
+                        HStack {
+                            Text("New Glucose")
+                            TextFieldWithToolBar(
+                                text: $state.manualGlucose,
+                                placeholder: " ... ",
+                                keyboardType: state.units == .mgdL ? .numberPad : .decimalPad,
+                                numberFormatter: manualGlucoseFormatter,
+                                initialFocus: true,
+                                unitsText: state.units.rawValue
+                            )
+                        }
+                    }.listRowBackground(Color.chart)
+
+                    Section {
+                        HStack {
+                            Button {
+                                state.addManualGlucose()
+                                isAmountUnconfirmed = false
+                                showManualGlucose = false
+                                state.mode = .glucose
+                            }
+                            label: { Text("Save") }
+                                .frame(maxWidth: .infinity, alignment: .center)
+                                .disabled(state.manualGlucose < limitLow || state.manualGlucose > limitHigh)
+                        }
+                    }
+                    .listRowBackground(
+                        state.manualGlucose < limitLow || state
+                            .manualGlucose > limitHigh ? Color(.systemGray4) : Color(.systemBlue)
+                    )
+                    .tint(.white)
+                }.scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            }
+            .onAppear(perform: configureView)
+            .navigationTitle("Add Glucose")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .topBarLeading) {
+                    Button("Close") {
+                        showManualGlucose = false
+                    }
+                }
+            }
+        }
+    }
+}

+ 133 - 0
Trio/Sources/Modules/History/View/HistoryRootView+Adjustments.swift

@@ -0,0 +1,133 @@
+import CoreData
+import SwiftUI
+
+extension History.RootView {
+    var adjustmentsList: some View {
+        List {
+            HStack {
+                Text("Adjustment").foregroundStyle(.secondary)
+                Spacer()
+            }
+            if !combinedAdjustments.isEmpty {
+                ForEach(combinedAdjustments) { item in
+                    adjustmentView(for: item)
+                }
+            } else {
+                ContentUnavailableView(
+                    String(localized: "No data."),
+                    systemImage: "clock.arrow.2.circlepath"
+                )
+            }
+        }
+        .listRowBackground(Color.chart)
+    }
+
+    fileprivate var combinedAdjustments: [AdjustmentItem] {
+        let overrides = overrideRunStored.map { override -> AdjustmentItem in
+            AdjustmentItem(
+                id: override.objectID,
+                name: override.name ?? String(localized: "Override"),
+                startDate: override.startDate ?? Date(),
+                endDate: override.endDate ?? Date(),
+                target: override.target?.decimalValue,
+                type: .override
+            )
+        }
+
+        let tempTargets = tempTargetRunStored.map { tempTarget -> AdjustmentItem in
+            AdjustmentItem(
+                id: tempTarget.objectID,
+                name: tempTarget.name ?? String(localized: "Temp Target"),
+                startDate: tempTarget.startDate ?? Date(),
+                endDate: tempTarget.endDate ?? Date(),
+                target: tempTarget.target?.decimalValue,
+                type: .tempTarget
+            )
+        }
+
+        let combined = overrides + tempTargets
+        return combined.sorted {
+            if $0.startDate == $1.startDate {
+                return $0.endDate > $1.endDate
+            }
+            return $0.startDate > $1.startDate
+        }
+    }
+
+    fileprivate struct AdjustmentItem: Identifiable {
+        let id: NSManagedObjectID
+        let name: String
+        let startDate: Date
+        let endDate: Date
+        let target: Decimal?
+        let type: AdjustmentType
+    }
+
+    fileprivate enum AdjustmentType {
+        case override
+        case tempTarget
+
+        var symbolName: String {
+            switch self {
+            case .override:
+                return "clock.arrow.2.circlepath"
+            case .tempTarget:
+                return "target"
+            }
+        }
+
+        var symbolColor: Color {
+            switch self {
+            case .override:
+                return .orange
+            case .tempTarget:
+                return .blue
+            }
+        }
+    }
+
+    @ViewBuilder fileprivate func adjustmentView(for item: AdjustmentItem) -> some View {
+        let formattedDates =
+            "\(Formatter.dateFormatter.string(from: item.startDate)) - \(Formatter.dateFormatter.string(from: item.endDate))"
+
+        let targetDescription: String = {
+            guard let target = item.target, target != 0 else {
+                return ""
+            }
+            return "\(state.units == .mgdL ? target : target.asMmolL) \(state.units.rawValue)"
+        }()
+
+        let labels: [String] = [
+            targetDescription,
+            formattedDates
+        ].filter { !$0.isEmpty }
+
+        ZStack(alignment: .trailing) {
+            HStack {
+                VStack(alignment: .leading) {
+                    HStack {
+                        Image(systemName: item.type.symbolName)
+                            .foregroundStyle(item.type == .override ? Color.purple : Color.green)
+                        Text(item.name)
+                            .font(.headline)
+                        Spacer()
+                    }
+                    HStack(spacing: 5) {
+                        ForEach(labels, id: \.self) { label in
+                            Text(label)
+                            if label != labels.last {
+                                Divider()
+                            }
+                        }
+                        Spacer()
+                    }
+                    .padding(.top, 2)
+                    .foregroundColor(.secondary)
+                    .font(.caption)
+                }
+                .contentShape(Rectangle())
+            }
+        }
+        .padding(.vertical, 8)
+    }
+}

+ 44 - 0
Trio/Sources/Modules/History/View/HistoryRootView+Confirmations.swift

@@ -0,0 +1,44 @@
+import SwiftUI
+
+extension History.RootView {
+    func requestDelete(_ target: History.DeletionTarget) {
+        deletionTarget = target
+    }
+
+    @ViewBuilder func historyConfirmations(_ content: some View) -> some View {
+        content
+            .confirmationDialog(
+                deletionTarget?.title(units: state.units) ?? "",
+                isPresented: Binding(
+                    get: { deletionTarget != nil },
+                    set: { if !$0 { deletionTarget = nil } }
+                ),
+                titleVisibility: .visible,
+                presenting: deletionTarget
+            ) { target in
+                Button("Delete", role: .destructive) {
+                    switch target {
+                    case let .glucose(glucose):
+                        state.invokeGlucoseDeletionTask(glucose.objectID)
+                    case let .insulin(pumpEvent):
+                        state.invokeInsulinDeletionTask(pumpEvent.objectID)
+                    case let .carbs(carbEntry):
+                        state.invokeCarbDeletionTask(
+                            carbEntry.objectID,
+                            isFpuOrComplexMeal: carbEntry.isFPU || carbEntry.fat > 0 || carbEntry.protein > 0
+                        )
+                    }
+                }
+                Button("Cancel", role: .cancel) {}
+            } message: { target in
+                if let message = target.message(units: state.units) {
+                    Text(message)
+                }
+            }
+            .alert("Error", isPresented: $showErrorAlert) {
+                Button("OK", role: .cancel) {}
+            } message: {
+                Text(errorMessage)
+            }
+    }
+}

+ 103 - 0
Trio/Sources/Modules/History/View/HistoryRootView+Filters.swift

@@ -0,0 +1,103 @@
+import SwiftUI
+
+extension History.RootView {
+    var filterTreatmentsButton: some View {
+        Button(action: {
+            showTreatmentTypeFilter.toggle()
+        }) {
+            HStack {
+                Text("Filter")
+                Image(
+                    systemName: selectedTreatmentTypes.count == History.TreatmentType.allCases.count
+                        ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill"
+                )
+                if selectedTreatmentTypes.count < History.TreatmentType.allCases.count {
+                    Text(verbatim: "(\(selectedTreatmentTypes.count)/\(History.TreatmentType.allCases.count))")
+                }
+            }.foregroundColor(Color.accentColor)
+        }
+        .popover(isPresented: $showTreatmentTypeFilter, arrowEdge: .top) {
+            VStack(alignment: .leading, spacing: 20) {
+                Button(action: {
+                    if selectedTreatmentTypes.count == History.TreatmentType.allCases.count {
+                        // Deselect all - keep at least one selected
+                        selectedTreatmentTypes = []
+                    } else {
+                        // Select all
+                        selectedTreatmentTypes = Set(History.TreatmentType.allCases)
+                    }
+                }) {
+                    HStack(spacing: 20) {
+                        Image(
+                            systemName: selectedTreatmentTypes.count == History.TreatmentType.allCases.count
+                                ? "checkmark.square.fill" : "square"
+                        )
+                        .frame(width: 20)
+                        .foregroundColor(Color.accentColor)
+                        Text(
+                            selectedTreatmentTypes.count == History.TreatmentType.allCases
+                                .count ? String(localized: "Deselect All") : String(localized: "Select All")
+                        )
+                        .foregroundColor(Color.primary)
+                    }.padding(4)
+                }
+                .buttonStyle(.borderless)
+
+                Divider()
+
+                ForEach(History.TreatmentType.allCases, id: \.rawValue) { treatmentType in
+                    Button(action: {
+                        toggleTreatmentType(treatmentType)
+                    }) {
+                        HStack(spacing: 20) {
+                            Image(
+                                systemName: selectedTreatmentTypes
+                                    .contains(treatmentType) ? "checkmark.square.fill" : "square"
+                            )
+                            .frame(width: 20)
+                            .foregroundColor(Color.accentColor)
+                            Text(treatmentType.displayName)
+                                .foregroundColor(Color.primary)
+                        }.padding(4)
+                    }
+                    .buttonStyle(.borderless)
+                }
+
+                Divider()
+
+                Button("Done") {
+                    showTreatmentTypeFilter = false
+                }
+                .frame(maxWidth: .infinity)
+                .buttonStyle(.borderless)
+            }
+            .padding()
+            .presentationCompactAdaptation(.popover)
+            .background(Color.chart)
+        }
+    }
+
+    var filterFutureEntriesButton: some View {
+        Button(
+            action: {
+                showFutureEntries.toggle()
+            },
+            label: {
+                HStack {
+                    Text(showFutureEntries ? String(localized: "Hide Future") : String(localized: "Show Future"))
+                        .foregroundColor(Color.accentColor)
+                    Image(systemName: showFutureEntries ? "eye.slash" : "eye")
+                        .foregroundColor(Color.accentColor)
+                }
+            }
+        ).buttonStyle(.borderless)
+    }
+
+    func toggleTreatmentType(_ type: History.TreatmentType) {
+        if selectedTreatmentTypes.contains(type) {
+            selectedTreatmentTypes.remove(type)
+        } else {
+            selectedTreatmentTypes.insert(type)
+        }
+    }
+}

+ 79 - 0
Trio/Sources/Modules/History/View/HistoryRootView+Glucose.swift

@@ -0,0 +1,79 @@
+import CoreData
+import SwiftUI
+
+extension History.RootView {
+    var glucoseList: some View {
+        List {
+            HStack {
+                Text("Values")
+                Spacer()
+                Text("Time")
+            }.foregroundStyle(.secondary)
+
+            if !glucoseStored.isEmpty {
+                ForEach(glucoseStored) { glucose in
+                    HStack {
+                        Text(formatGlucose(Decimal(glucose.glucose), isManual: glucose.isManual))
+
+                        /// check for manual glucose
+                        if glucose.isManual {
+                            Image(systemName: "drop.fill").symbolRenderingMode(.monochrome).foregroundStyle(.red)
+                        } else {
+                            Text("\(glucose.directionEnum?.symbol ?? "--")")
+                        }
+
+                        if state.settingsManager.settings.smoothGlucose, !glucose.isManual,
+                           let smoothedGlucose = glucose.smoothedGlucose, smoothedGlucose != 0
+                        {
+                            let smoothedGlucoseForDisplay = state.units == .mgdL ? smoothedGlucose
+                                .description : smoothedGlucose.decimalValue
+                                .formattedAsMmolL
+
+                            (
+                                Text("(") +
+                                    Text(Image(systemName: "sparkles")) +
+                                    Text(" ") +
+                                    Text("\(smoothedGlucoseForDisplay)") +
+                                    Text(")")
+                            ).foregroundStyle(.secondary)
+                                .padding(.leading, 10)
+                        }
+
+                        Spacer()
+
+                        Text(Formatter.dateFormatter.string(from: glucose.date ?? Date()))
+                    }
+                    .contextMenu {
+                        Button(
+                            "Delete",
+                            systemImage: "trash.fill",
+                            role: .destructive,
+                            action: { requestDelete(.glucose(glucose)) }
+                        ).tint(.red)
+                    }
+                    .swipeActions {
+                        Button(
+                            "Delete",
+                            systemImage: "trash.fill",
+                            role: .none,
+                            action: { requestDelete(.glucose(glucose)) }
+                        ).tint(.red)
+                    }
+                }
+            } else {
+                ContentUnavailableView(
+                    String(localized: "No data."),
+                    systemImage: "drop.fill"
+                )
+            }
+        }.listRowBackground(Color.chart)
+    }
+
+    func formatGlucose(_ value: Decimal, isManual: Bool) -> String {
+        let formatter = isManual ? manualGlucoseFormatter : Formatter.glucoseFormatter(for: state.units)
+        let glucoseValue = state.units == .mmolL ? value.asMmolL : value
+        let formattedValue = formatter.string(from: glucoseValue as NSNumber) ?? "--"
+
+        return formattedValue
+    }
+}

+ 98 - 0
Trio/Sources/Modules/History/View/HistoryRootView+Meals.swift

@@ -0,0 +1,98 @@
+import CoreData
+import SwiftUI
+
+extension History.RootView {
+    var mealsList: some View {
+        List {
+            HStack {
+                Text("Type").foregroundStyle(.secondary)
+                Spacer()
+                filterFutureEntriesButton
+            }
+            if !carbEntryStored.isEmpty {
+                ForEach(carbEntryStored.filter({ !showFutureEntries ? $0.date ?? Date() <= Date() : true })) { item in
+                    mealView(item)
+                }
+            } else {
+                ContentUnavailableView(
+                    String(localized: "No data."),
+                    systemImage: "fork.knife"
+                )
+            }
+        }.listRowBackground(Color.chart)
+    }
+
+    @ViewBuilder func mealView(_ meal: CarbEntryStored) -> some View {
+        VStack {
+            HStack {
+                if meal.isFPU {
+                    Image(systemName: "circle.fill").foregroundColor(Color.orange.opacity(0.5))
+                    Text("Fat / Protein")
+                    Text(
+                        (Formatter.decimalFormatterWithTwoFractionDigits.string(for: meal.carbs) ?? "0") +
+                            String(localized: " g", comment: "gram of carbs")
+                    )
+                } else {
+                    Image(systemName: "circle.fill").foregroundColor(Color.loopYellow)
+                    Text("Carbs")
+                    Text(
+                        (Formatter.decimalFormatterWithTwoFractionDigits.string(for: meal.carbs) ?? "0") +
+                            String(localized: " g", comment: "gram of carb equilvalents")
+                    )
+                }
+
+                Spacer()
+
+                Text(Formatter.dateFormatter.string(from: meal.date ?? Date()))
+                    .moveDisabled(true)
+            }
+            if let note = meal.note, note != "" {
+                HStack {
+                    Image(systemName: "square.and.pencil")
+                    Text(note)
+                    Spacer()
+                }.padding(.top, 5).foregroundColor(.secondary)
+            }
+        }
+        .contextMenu {
+            Button(
+                "Delete",
+                systemImage: "trash.fill",
+                role: .destructive,
+                action: { requestDelete(.carbs(meal)) }
+            ).tint(.red)
+
+            Button(
+                "Edit",
+                systemImage: "pencil",
+                role: .none,
+                action: {
+                    state.carbEntryToEdit = meal
+                    state.showCarbEntryEditor = true
+                }
+            )
+            .tint(!state.settingsManager.settings.useFPUconversion && meal.isFPU ? Color(.systemGray4) : Color.blue)
+            .disabled(!state.settingsManager.settings.useFPUconversion && meal.isFPU)
+        }
+        .swipeActions {
+            Button(
+                "Delete",
+                systemImage: "trash.fill",
+                role: .none,
+                action: { requestDelete(.carbs(meal)) }
+            ).tint(.red)
+
+            Button(
+                "Edit",
+                systemImage: "pencil",
+                role: .none,
+                action: {
+                    state.carbEntryToEdit = meal
+                    state.showCarbEntryEditor = true
+                }
+            )
+            .tint(!state.settingsManager.settings.useFPUconversion && meal.isFPU ? Color(.systemGray4) : Color.blue)
+            .disabled(!state.settingsManager.settings.useFPUconversion && meal.isFPU)
+        }
+    }
+}

+ 103 - 0
Trio/Sources/Modules/History/View/HistoryRootView+Treatments.swift

@@ -0,0 +1,103 @@
+import CoreData
+import SwiftUI
+
+extension History.RootView {
+    var treatmentsList: some View {
+        List {
+            HStack {
+                filterTreatmentsButton
+                Spacer()
+                Text("Time").foregroundStyle(.secondary)
+            }
+            if !filteredPumpEvents.isEmpty {
+                ForEach(filteredPumpEvents) { item in
+                    treatmentView(item)
+                }
+            } else {
+                ContentUnavailableView(
+                    String(localized: "No data."),
+                    systemImage: "syringe"
+                )
+            }
+        }.listRowBackground(Color.chart)
+    }
+
+    var filteredPumpEvents: [PumpEventStored] {
+        pumpEventStored.filter { item in
+            // First filter by date
+            let passesDateFilter = !showFutureEntries ? item.timestamp ?? Date() <= Date() : true
+
+            guard passesDateFilter else { return false }
+
+            // Then filter by treatment type
+            if let bolus = item.bolus {
+                if bolus.isSMB {
+                    return selectedTreatmentTypes.contains(.smb)
+                } else if bolus.isExternal {
+                    return selectedTreatmentTypes.contains(.externalBolus)
+                } else {
+                    return selectedTreatmentTypes.contains(.bolus)
+                }
+            } else if item.tempBasal != nil {
+                return selectedTreatmentTypes.contains(.tempBasal)
+            } else if item.type == "PumpSuspend" {
+                return selectedTreatmentTypes.contains(.suspend)
+            } else {
+                return selectedTreatmentTypes.contains(.other)
+            }
+        }
+    }
+
+    @ViewBuilder func treatmentView(_ item: PumpEventStored) -> some View {
+        HStack {
+            if let bolus = item.bolus, let amount = bolus.amount {
+                Image(systemName: "circle.fill").foregroundColor(Color.insulin)
+                Text(bolus.isSMB ? "SMB" : item.type ?? "Bolus")
+                Text(
+                    (Formatter.decimalFormatterWithThreeFractionDigits.string(from: amount) ?? "0") +
+                        String(localized: " U", comment: "Insulin unit")
+                )
+                .foregroundColor(.secondary)
+                if bolus.isExternal {
+                    Text(String(localized: "External", comment: "External Insulin")).foregroundColor(.secondary)
+                }
+            } else if let tempBasal = item.tempBasal, let rate = tempBasal.rate {
+                Image(systemName: "circle.fill").foregroundColor(Color.insulin.opacity(0.4))
+                Text("Temp Basal")
+                Text(
+                    (Formatter.decimalFormatterWithThreeFractionDigits.string(from: rate) ?? "0") +
+                        String(localized: " U/hr", comment: "Unit insulin per hour")
+                )
+                .foregroundColor(.secondary)
+                if tempBasal.duration > 0 {
+                    Text("\(tempBasal.duration.string) min").foregroundColor(.secondary)
+                }
+            } else {
+                Image(systemName: "circle.fill").foregroundColor(Color.loopGray)
+                Text(item.type ?? "Pump Event")
+            }
+            Spacer()
+            Text(Formatter.dateFormatter.string(from: item.timestamp ?? Date())).moveDisabled(true)
+        }
+        .contextMenu {
+            if item.bolus != nil {
+                Button(
+                    "Delete",
+                    systemImage: "trash.fill",
+                    role: .destructive,
+                    action: { requestDelete(.insulin(item)) }
+                ).tint(.red)
+            }
+        }
+        .swipeActions {
+            if item.bolus != nil {
+                Button(
+                    "Delete",
+                    systemImage: "trash.fill",
+                    role: .none,
+                    action: { requestDelete(.insulin(item)) }
+                ).tint(.red)
+            }
+        }
+    }
+}

+ 61 - 745
Trio/Sources/Modules/History/View/HistoryRootView.swift

@@ -8,19 +8,14 @@ extension History {
 
 
         @State var state = StateModel()
         @State var state = StateModel()
 
 
-        @State private var isRemoveHistoryItemAlertPresented: Bool = false
-        @State private var alertTitle: String = ""
-        @State private var alertMessage: String = ""
-        @State private var alertTreatmentToDelete: PumpEventStored?
-        @State private var alertCarbEntryToDelete: CarbEntryStored?
-        @State private var alertGlucoseToDelete: GlucoseStored?
-        @State private var showAlert = false
-        @State private var showFutureEntries: Bool = false // default to hide future entries
-        @State private var showManualGlucose: Bool = false
-        @State private var isAmountUnconfirmed: Bool = true
-        @State private var showTreatmentTypeFilter = false
-        @State private var selectedTreatmentTypes: Set<TreatmentType> = Set(TreatmentType.allCases)
-        @State private var filterPopoverAnchor: CGRect = .zero
+        @State var deletionTarget: History.DeletionTarget?
+        @State var showErrorAlert: Bool = false
+        @State var errorMessage: String = ""
+        @State var showFutureEntries: Bool = false // default to hide future entries
+        @State var showManualGlucose: Bool = false
+        @State var isAmountUnconfirmed: Bool = true
+        @State var showTreatmentTypeFilter = false
+        @State var selectedTreatmentTypes: Set<TreatmentType> = Set(TreatmentType.allCases)
 
 
         @Environment(\.colorScheme) var colorScheme
         @Environment(\.colorScheme) var colorScheme
         @Environment(\.managedObjectContext) var context
         @Environment(\.managedObjectContext) var context
@@ -61,76 +56,63 @@ extension History {
             animation: .bouncy
             animation: .bouncy
         ) var tempTargetRunStored: FetchedResults<TempTargetRunStored>
         ) var tempTargetRunStored: FetchedResults<TempTargetRunStored>
 
 
-        private var manualGlucoseFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            if state.units == .mgdL {
-                formatter.maximumIntegerDigits = 3
-                formatter.maximumFractionDigits = 0
-            } else {
-                formatter.maximumIntegerDigits = 2
-                formatter.minimumFractionDigits = 0
-                formatter.maximumFractionDigits = 1
-            }
-            formatter.roundingMode = .halfUp
-            return formatter
-        }
-
         var body: some View {
         var body: some View {
-            ZStack(alignment: .center, content: {
-                VStack {
-                    Picker("Mode", selection: $state.mode) {
-                        ForEach(
-                            Mode.allCases.indexed(),
-                            id: \.1
-                        ) { index, item in
-                            Text(item.name).tag(index)
+            historyConfirmations(
+                ZStack(alignment: .center, content: {
+                    VStack {
+                        Picker("Mode", selection: $state.mode) {
+                            ForEach(
+                                Mode.allCases.indexed(),
+                                id: \.1
+                            ) { index, item in
+                                Text(item.name).tag(index)
+                            }
                         }
                         }
+                        .pickerStyle(SegmentedPickerStyle())
+                        .padding(.horizontal)
+
+                        Form {
+                            switch state.mode {
+                            case .treatments: treatmentsList
+                            case .glucose: glucoseList
+                            case .meals: mealsList
+                            case .adjustments: adjustmentsList
+                            }
+                        }.scrollContentBackground(.hidden)
+                            .background(appState.trioBackgroundColor(for: colorScheme))
+                    }.blur(radius: state.waitForSuggestion ? 8 : 0)
+
+                    // Show custom progress view
+                    /// don't show it if glucose is stale as it will block the UI
+                    if state.waitForSuggestion && state.isGlucoseDataFresh(glucoseStored.first?.date) {
+                        CustomProgressView(text: progressText.displayName)
+                    }
+                })
+                    .background(appState.trioBackgroundColor(for: colorScheme))
+                    .onAppear(perform: configureView)
+                    .onDisappear {
+                        state.carbEntryDeleted = false
+                        state.insulinEntryDeleted = false
+                    }
+                    .navigationTitle("History")
+                    .navigationBarTitleDisplayMode(.large)
+                    .toolbar {
+                        ToolbarItem(placement: .topBarTrailing, content: {
+                            addButton({
+                                showManualGlucose = true
+                                state.manualGlucose = 0
+                            })
+                        })
                     }
                     }
-                    .pickerStyle(SegmentedPickerStyle())
-                    .padding(.horizontal)
-
-                    Form {
-                        switch state.mode {
-                        case .treatments: treatmentsList
-                        case .glucose: glucoseList
-                        case .meals: mealsList
-                        case .adjustments: adjustmentsList
+                    .sheet(isPresented: $showManualGlucose) {
+                        addGlucoseView()
+                    }
+                    .sheet(isPresented: $state.showCarbEntryEditor) {
+                        if let carbEntry = state.carbEntryToEdit {
+                            CarbEntryEditorView(state: state, carbEntry: carbEntry)
                         }
                         }
-                    }.scrollContentBackground(.hidden)
-                        .background(appState.trioBackgroundColor(for: colorScheme))
-                }.blur(radius: state.waitForSuggestion ? 8 : 0)
-
-                // Show custom progress view
-                /// don't show it if glucose is stale as it will block the UI
-                if state.waitForSuggestion && state.isGlucoseDataFresh(glucoseStored.first?.date) {
-                    CustomProgressView(text: progressText.displayName)
-                }
-            })
-                .background(appState.trioBackgroundColor(for: colorScheme))
-                .onAppear(perform: configureView)
-                .onDisappear {
-                    state.carbEntryDeleted = false
-                    state.insulinEntryDeleted = false
-                }
-                .navigationTitle("History")
-                .navigationBarTitleDisplayMode(.large)
-                .toolbar {
-                    ToolbarItem(placement: .topBarTrailing, content: {
-                        addButton({
-                            showManualGlucose = true
-                            state.manualGlucose = 0
-                        })
-                    })
-                }
-                .sheet(isPresented: $showManualGlucose) {
-                    addGlucoseView()
-                }
-                .sheet(isPresented: $state.showCarbEntryEditor) {
-                    if let carbEntry = state.carbEntryToEdit {
-                        CarbEntryEditorView(state: state, carbEntry: carbEntry)
                     }
                     }
-                }
+            )
         }
         }
 
 
         @ViewBuilder func addButton(_ action: @escaping () -> Void) -> some View {
         @ViewBuilder func addButton(_ action: @escaping () -> Void) -> some View {
@@ -146,7 +128,7 @@ extension History {
             )
             )
         }
         }
 
 
-        private var progressText: ProgressText {
+        var progressText: ProgressText {
             switch (state.carbEntryDeleted, state.insulinEntryDeleted) {
             switch (state.carbEntryDeleted, state.insulinEntryDeleted) {
             case (true, false):
             case (true, false):
                 return .updatingCOB
                 return .updatingCOB
@@ -156,671 +138,5 @@ extension History {
                 return .updatingHistory
                 return .updatingHistory
             }
             }
         }
         }
-
-        private var logGlucoseButton: some View {
-            Button(
-                action: {
-                    showManualGlucose = true
-                    state.manualGlucose = 0
-                },
-                label: {
-                    Text("Log Glucose")
-                        .foregroundColor(Color.accentColor)
-                    Image(systemName: "plus")
-                        .foregroundColor(Color.accentColor)
-                }
-            ).buttonStyle(.borderless)
-        }
-
-        private var filterTreatmentsButton: some View {
-            Button(action: {
-                showTreatmentTypeFilter.toggle()
-            }) {
-                HStack {
-                    Text("Filter")
-                    Image(
-                        systemName: selectedTreatmentTypes.count == TreatmentType.allCases.count
-                            ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill"
-                    )
-                    if selectedTreatmentTypes.count < TreatmentType.allCases.count {
-                        Text(verbatim: "(\(selectedTreatmentTypes.count)/\(TreatmentType.allCases.count))")
-                    }
-                }.foregroundColor(Color.accentColor)
-            }
-            .popover(isPresented: $showTreatmentTypeFilter, arrowEdge: .top) {
-                VStack(alignment: .leading, spacing: 20) {
-                    Button(action: {
-                        if selectedTreatmentTypes.count == TreatmentType.allCases.count {
-                            // Deselect all - keep at least one selected
-                            selectedTreatmentTypes = []
-                        } else {
-                            // Select all
-                            selectedTreatmentTypes = Set(TreatmentType.allCases)
-                        }
-                    }) {
-                        HStack(spacing: 20) {
-                            Image(
-                                systemName: selectedTreatmentTypes.count == TreatmentType.allCases.count
-                                    ? "checkmark.square.fill" : "square"
-                            )
-                            .frame(width: 20)
-                            .foregroundColor(Color.accentColor)
-                            Text(
-                                selectedTreatmentTypes.count == TreatmentType.allCases
-                                    .count ? String(localized: "Deselect All") : String(localized: "Select All")
-                            )
-                            .foregroundColor(Color.primary)
-                        }.padding(4)
-                    }
-                    .buttonStyle(.borderless)
-
-                    Divider()
-
-                    ForEach(TreatmentType.allCases, id: \.rawValue) { treatmentType in
-                        Button(action: {
-                            toggleTreatmentType(treatmentType)
-                        }) {
-                            HStack(spacing: 20) {
-                                Image(
-                                    systemName: selectedTreatmentTypes
-                                        .contains(treatmentType) ? "checkmark.square.fill" : "square"
-                                )
-                                .frame(width: 20)
-                                .foregroundColor(Color.accentColor)
-                                Text(treatmentType.displayName)
-                                    .foregroundColor(Color.primary)
-                            }.padding(4)
-                        }
-                        .buttonStyle(.borderless)
-                    }
-
-                    Divider()
-
-                    Button("Done") {
-                        showTreatmentTypeFilter = false
-                    }
-                    .frame(maxWidth: .infinity)
-                    .buttonStyle(.borderless)
-                }
-                .padding()
-                .presentationCompactAdaptation(.popover)
-                .background(Color.chart)
-            }
-        }
-
-        private var filterFutureEntriesButton: some View {
-            Button(
-                action: {
-                    showFutureEntries.toggle()
-                },
-                label: {
-                    HStack {
-                        Text(showFutureEntries ? String(localized: "Hide Future") : String(localized: "Show Future"))
-                            .foregroundColor(Color.accentColor)
-                        Image(systemName: showFutureEntries ? "eye.slash" : "eye")
-                            .foregroundColor(Color.accentColor)
-                    }
-                }
-            ).buttonStyle(.borderless)
-        }
-
-        private func toggleTreatmentType(_ type: TreatmentType) {
-            if selectedTreatmentTypes.contains(type) {
-                selectedTreatmentTypes.remove(type)
-            } else {
-                selectedTreatmentTypes.insert(type)
-            }
-        }
-
-        private var filteredPumpEvents: [PumpEventStored] {
-            pumpEventStored.filter { item in
-                // First filter by date
-                let passesDateFilter = !showFutureEntries ? item.timestamp ?? Date() <= Date() : true
-
-                guard passesDateFilter else { return false }
-
-                // Then filter by treatment type
-                if let bolus = item.bolus {
-                    if bolus.isSMB {
-                        return selectedTreatmentTypes.contains(.smb)
-                    } else if bolus.isExternal {
-                        return selectedTreatmentTypes.contains(.externalBolus)
-                    } else {
-                        return selectedTreatmentTypes.contains(.bolus)
-                    }
-                } else if item.tempBasal != nil {
-                    return selectedTreatmentTypes.contains(.tempBasal)
-                } else if item.type == "PumpSuspend" {
-                    return selectedTreatmentTypes.contains(.suspend)
-                } else {
-                    return selectedTreatmentTypes.contains(.other)
-                }
-            }
-        }
-
-        private var treatmentsList: some View {
-            List {
-                HStack {
-                    filterTreatmentsButton
-                    Spacer()
-                    Text("Time").foregroundStyle(.secondary)
-                }
-                if !filteredPumpEvents.isEmpty {
-                    ForEach(filteredPumpEvents) { item in
-                        treatmentView(item)
-                    }
-                } else {
-                    ContentUnavailableView(
-                        String(localized: "No data."),
-                        systemImage: "syringe"
-                    )
-                }
-            }.listRowBackground(Color.chart)
-        }
-
-        private var mealsList: some View {
-            List {
-                HStack {
-                    Text("Type").foregroundStyle(.secondary)
-                    Spacer()
-                    filterFutureEntriesButton
-                }
-                if !carbEntryStored.isEmpty {
-                    ForEach(carbEntryStored.filter({ !showFutureEntries ? $0.date ?? Date() <= Date() : true })) { item in
-                        mealView(item)
-                    }
-                } else {
-                    ContentUnavailableView(
-                        String(localized: "No data."),
-                        systemImage: "fork.knife"
-                    )
-                }
-            }.listRowBackground(Color.chart)
-        }
-
-        private var adjustmentsList: some View {
-            List {
-                HStack {
-                    Text("Adjustment").foregroundStyle(.secondary)
-                    Spacer()
-                }
-                if !combinedAdjustments.isEmpty {
-                    ForEach(combinedAdjustments) { item in
-                        adjustmentView(for: item)
-                    }
-                } else {
-                    ContentUnavailableView(
-                        String(localized: "No data."),
-                        systemImage: "clock.arrow.2.circlepath"
-                    )
-                }
-            }
-            .listRowBackground(Color.chart)
-        }
-
-        private var combinedAdjustments: [AdjustmentItem] {
-            let overrides = overrideRunStored.map { override -> AdjustmentItem in
-                AdjustmentItem(
-                    id: override.objectID,
-                    name: override.name ?? String(localized: "Override"),
-                    startDate: override.startDate ?? Date(),
-                    endDate: override.endDate ?? Date(),
-                    target: override.target?.decimalValue,
-                    type: .override
-                )
-            }
-
-            let tempTargets = tempTargetRunStored.map { tempTarget -> AdjustmentItem in
-                AdjustmentItem(
-                    id: tempTarget.objectID,
-                    name: tempTarget.name ?? String(localized: "Temp Target"),
-                    startDate: tempTarget.startDate ?? Date(),
-                    endDate: tempTarget.endDate ?? Date(),
-                    target: tempTarget.target?.decimalValue,
-                    type: .tempTarget
-                )
-            }
-
-            let combined = overrides + tempTargets
-            return combined.sorted {
-                if $0.startDate == $1.startDate {
-                    return $0.endDate > $1.endDate
-                }
-                return $0.startDate > $1.startDate
-            } }
-
-        private struct AdjustmentItem: Identifiable {
-            let id: NSManagedObjectID
-            let name: String
-            let startDate: Date
-            let endDate: Date
-            let target: Decimal?
-            let type: AdjustmentType
-        }
-
-        private enum AdjustmentType {
-            case override
-            case tempTarget
-
-            var symbolName: String {
-                switch self {
-                case .override:
-                    return "clock.arrow.2.circlepath"
-                case .tempTarget:
-                    return "target"
-                }
-            }
-
-            var symbolColor: Color {
-                switch self {
-                case .override:
-                    return .orange
-                case .tempTarget:
-                    return .blue
-                }
-            }
-        }
-
-        @ViewBuilder private func adjustmentView(for item: AdjustmentItem) -> some View {
-            let formattedDates =
-                "\(Formatter.dateFormatter.string(from: item.startDate)) - \(Formatter.dateFormatter.string(from: item.endDate))"
-
-            let targetDescription: String = {
-                guard let target = item.target, target != 0 else {
-                    return ""
-                }
-                return "\(state.units == .mgdL ? target : target.asMmolL) \(state.units.rawValue)"
-            }()
-
-            let labels: [String] = [
-                targetDescription,
-                formattedDates
-            ].filter { !$0.isEmpty }
-
-            ZStack(alignment: .trailing) {
-                HStack {
-                    VStack(alignment: .leading) {
-                        HStack {
-                            Image(systemName: item.type.symbolName)
-                                .foregroundStyle(item.type == .override ? Color.purple : Color.green)
-                            Text(item.name)
-                                .font(.headline)
-                            Spacer()
-                        }
-                        HStack(spacing: 5) {
-                            ForEach(labels, id: \.self) { label in
-                                Text(label)
-                                if label != labels.last {
-                                    Divider()
-                                }
-                            }
-                            Spacer()
-                        }
-                        .padding(.top, 2)
-                        .foregroundColor(.secondary)
-                        .font(.caption)
-                    }
-                    .contentShape(Rectangle())
-                }
-            }
-            .padding(.vertical, 8)
-        }
-
-        private var glucoseList: some View {
-            List {
-                HStack {
-                    Text("Values")
-                    Spacer()
-                    Text("Time")
-                }.foregroundStyle(.secondary)
-
-                if !glucoseStored.isEmpty {
-                    ForEach(glucoseStored) { glucose in
-                        HStack {
-                            Text(formatGlucose(Decimal(glucose.glucose), isManual: glucose.isManual))
-
-                            /// check for manual glucose
-                            if glucose.isManual {
-                                Image(systemName: "drop.fill").symbolRenderingMode(.monochrome).foregroundStyle(.red)
-                            } else {
-                                Text("\(glucose.directionEnum?.symbol ?? "--")")
-                            }
-
-                            if state.settingsManager.settings.smoothGlucose, !glucose.isManual,
-                               let smoothedGlucose = glucose.smoothedGlucose, smoothedGlucose != 0
-                            {
-                                let smoothedGlucoseForDisplay = state.units == .mgdL ? smoothedGlucose
-                                    .description : smoothedGlucose.decimalValue
-                                    .formattedAsMmolL
-
-                                (
-                                    Text("(") +
-                                        Text(Image(systemName: "sparkles")) +
-                                        Text(" ") +
-                                        Text("\(smoothedGlucoseForDisplay)") +
-                                        Text(")")
-                                ).foregroundStyle(.secondary)
-                                    .padding(.leading, 10)
-                            }
-
-                            Spacer()
-
-                            Text(Formatter.dateFormatter.string(from: glucose.date ?? Date()))
-                        }.swipeActions {
-                            Button(
-                                "Delete",
-                                systemImage: "trash.fill",
-                                role: .none,
-                                action: {
-                                    alertGlucoseToDelete = glucose
-
-                                    let glucoseToDisplay = state.units == .mgdL ? glucose.glucose
-                                        .description : Int(glucose.glucose).formattedAsMmolL
-                                    alertTitle = String(localized: "Delete Glucose?", comment: "Alert title for deleting glucose")
-                                    alertMessage = Formatter.dateFormatter
-                                        .string(from: glucose.date ?? Date()) + ", " + glucoseToDisplay + " " + state.units
-                                        .rawValue
-
-                                    isRemoveHistoryItemAlertPresented = true
-                                }
-                            ).tint(.red)
-                        }
-                        .alert(
-                            Text(alertTitle),
-                            isPresented: $isRemoveHistoryItemAlertPresented
-                        ) {
-                            Button("Cancel", role: .cancel) {}
-                            Button("Delete", role: .destructive) {
-                                guard let glucoseToDelete = alertGlucoseToDelete else {
-                                    debug(.default, "Cannot gracefully unwrap alertCarbEntryToDelete!")
-                                    return
-                                }
-                                let glucoseToDeleteObjectID = glucoseToDelete.objectID
-                                state.invokeGlucoseDeletionTask(glucoseToDeleteObjectID)
-                            }
-                        } message: {
-                            Text("\n" + alertMessage)
-                        }
-                    }
-                } else {
-                    ContentUnavailableView(
-                        String(localized: "No data."),
-                        systemImage: "drop.fill"
-                    )
-                }
-            }.listRowBackground(Color.chart)
-                .alert(isPresented: $showAlert) {
-                    Alert(title: Text("Error"), message: Text(alertMessage), dismissButton: .default(Text("OK")))
-                }
-        }
-
-        private func deleteGlucose(at offsets: IndexSet) {
-            for index in offsets {
-                let glucoseToDelete = glucoseStored[index]
-                context.delete(glucoseToDelete)
-            }
-
-            do {
-                try context.save()
-                debugPrint("Data Table Root View: \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from core data")
-            } catch {
-                debugPrint(
-                    "Data Table Root View: \(#function) \(DebuggingIdentifiers.failed) error while deleting glucose from core data"
-                )
-                alertMessage = "Failed to delete glucose data: \(error.localizedDescription)"
-                showAlert = true
-            }
-        }
-
-        @ViewBuilder private func addGlucoseView() -> some View {
-            let limitLow: Decimal = state.units == .mgdL ? Decimal(14) : 14.asMmolL
-            let limitHigh: Decimal = state.units == .mgdL ? Decimal(720) : 720.asMmolL
-
-            NavigationView {
-                VStack {
-                    Form {
-                        Section {
-                            HStack {
-                                Text("New Glucose")
-                                TextFieldWithToolBar(
-                                    text: $state.manualGlucose,
-                                    placeholder: " ... ",
-                                    keyboardType: state.units == .mgdL ? .numberPad : .decimalPad,
-                                    numberFormatter: manualGlucoseFormatter,
-                                    initialFocus: true,
-                                    unitsText: state.units.rawValue
-                                )
-                            }
-                        }.listRowBackground(Color.chart)
-
-                        Section {
-                            HStack {
-                                Button {
-                                    state.addManualGlucose()
-                                    isAmountUnconfirmed = false
-                                    showManualGlucose = false
-                                    state.mode = .glucose
-                                }
-                                label: { Text("Save") }
-                                    .frame(maxWidth: .infinity, alignment: .center)
-                                    .disabled(state.manualGlucose < limitLow || state.manualGlucose > limitHigh)
-                            }
-                        }
-                        .listRowBackground(
-                            state.manualGlucose < limitLow || state
-                                .manualGlucose > limitHigh ? Color(.systemGray4) : Color(.systemBlue)
-                        )
-                        .tint(.white)
-                    }.scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
-                }
-                .onAppear(perform: configureView)
-                .navigationTitle("Add Glucose")
-                .navigationBarTitleDisplayMode(.inline)
-                .toolbar {
-                    ToolbarItem(placement: .topBarLeading) {
-                        Button("Close") {
-                            showManualGlucose = false
-                        }
-                    }
-                }
-            }
-        }
-
-        private var filterEntriesButton: some View {
-            Button(action: { showFutureEntries.toggle() }, label: {
-                HStack {
-                    Text(showFutureEntries ? String(localized: "Hide Future") : String(localized: "Show Future"))
-                        .foregroundColor(Color.secondary)
-                    Image(systemName: showFutureEntries ? "calendar.badge.minus" : "calendar.badge.plus")
-                }.frame(maxWidth: .infinity, alignment: .trailing)
-            }).buttonStyle(.borderless)
-        }
-
-        @ViewBuilder private func treatmentView(_ item: PumpEventStored) -> some View {
-            HStack {
-                if let bolus = item.bolus, let amount = bolus.amount {
-                    Image(systemName: "circle.fill").foregroundColor(Color.insulin)
-                    Text(bolus.isSMB ? "SMB" : item.type ?? "Bolus")
-                    Text(
-                        (Formatter.decimalFormatterWithThreeFractionDigits.string(from: amount) ?? "0") +
-                            String(localized: " U", comment: "Insulin unit")
-                    )
-                    .foregroundColor(.secondary)
-                    if bolus.isExternal {
-                        Text(String(localized: "External", comment: "External Insulin")).foregroundColor(.secondary)
-                    }
-                } else if let tempBasal = item.tempBasal, let rate = tempBasal.rate {
-                    Image(systemName: "circle.fill").foregroundColor(Color.insulin.opacity(0.4))
-                    Text("Temp Basal")
-                    Text(
-                        (Formatter.decimalFormatterWithThreeFractionDigits.string(from: rate) ?? "0") +
-                            String(localized: " U/hr", comment: "Unit insulin per hour")
-                    )
-                    .foregroundColor(.secondary)
-                    if tempBasal.duration > 0 {
-                        Text("\(tempBasal.duration.string) min").foregroundColor(.secondary)
-                    }
-                } else {
-                    Image(systemName: "circle.fill").foregroundColor(Color.loopGray)
-                    Text(item.type ?? "Pump Event")
-                }
-                Spacer()
-                Text(Formatter.dateFormatter.string(from: item.timestamp ?? Date())).moveDisabled(true)
-            }
-            .swipeActions {
-                if item.bolus != nil {
-                    Button(
-                        "Delete",
-                        systemImage: "trash.fill",
-                        role: .none,
-                        action: {
-                            alertTreatmentToDelete = item
-                            alertTitle = String(localized: "Delete Insulin?", comment: "Alert title for deleting insulin")
-                            alertMessage = Formatter.dateFormatter
-                                .string(from: item.timestamp ?? Date()) + ", " +
-                                (Formatter.decimalFormatterWithThreeFractionDigits.string(from: item.bolus?.amount ?? 0) ?? "0") +
-                                String(localized: " U", comment: "Insulin unit")
-
-                            if let bolus = item.bolus {
-                                // Add text snippet, so that alert message is more descriptive for SMBs
-                                alertMessage += bolus.isSMB ? String(
-                                    localized: " SMB",
-                                    comment: "Super Micro Bolus indicator in delete alert"
-                                )
-                                    : ""
-                            }
-
-                            isRemoveHistoryItemAlertPresented = true
-                        }
-                    ).tint(.red)
-                }
-            }
-            .alert(
-                Text(alertTitle),
-                isPresented: $isRemoveHistoryItemAlertPresented
-            ) {
-                Button("Cancel", role: .cancel) {}
-                Button("Delete", role: .destructive) {
-                    guard let treatmentToDelete = alertTreatmentToDelete else {
-                        debug(.default, "Cannot gracefully unwrap alertTreatmentToDelete!")
-                        return
-                    }
-                    let treatmentObjectID = treatmentToDelete.objectID
-
-                    state.invokeInsulinDeletionTask(treatmentObjectID)
-                }
-            } message: {
-                Text("\n" + alertMessage)
-            }
-        }
-
-        @ViewBuilder private func mealView(_ meal: CarbEntryStored) -> some View {
-            VStack {
-                HStack {
-                    if meal.isFPU {
-                        Image(systemName: "circle.fill").foregroundColor(Color.orange.opacity(0.5))
-                        Text("Fat / Protein")
-                        Text(
-                            (Formatter.decimalFormatterWithTwoFractionDigits.string(for: meal.carbs) ?? "0") +
-                                String(localized: " g", comment: "gram of carbs")
-                        )
-                    } else {
-                        Image(systemName: "circle.fill").foregroundColor(Color.loopYellow)
-                        Text("Carbs")
-                        Text(
-                            (Formatter.decimalFormatterWithTwoFractionDigits.string(for: meal.carbs) ?? "0") +
-                                String(localized: " g", comment: "gram of carb equilvalents")
-                        )
-                    }
-
-                    Spacer()
-
-                    Text(Formatter.dateFormatter.string(from: meal.date ?? Date()))
-                        .moveDisabled(true)
-                }
-                if let note = meal.note, note != "" {
-                    HStack {
-                        Image(systemName: "square.and.pencil")
-                        Text(note)
-                        Spacer()
-                    }.padding(.top, 5).foregroundColor(.secondary)
-                }
-            }
-            .swipeActions {
-                Button(
-                    "Delete",
-                    systemImage: "trash.fill",
-                    role: .none,
-                    action: {
-                        alertCarbEntryToDelete = meal
-
-                        // meal is carb-only
-                        if meal.fpuID == nil {
-                            alertTitle = String(localized: "Delete Carbs?", comment: "Alert title for deleting carbs")
-                            alertMessage = Formatter.dateFormatter
-                                .string(from: meal.date ?? Date()) + ", " +
-                                (Formatter.decimalFormatterWithTwoFractionDigits.string(for: meal.carbs) ?? "0") +
-                                String(localized: " g", comment: "gram of carbs")
-                        }
-                        // meal is complex-meal or fpu-only
-                        else {
-                            alertTitle = meal.isFPU ? String(
-                                localized: "Delete Carbs Equivalents?",
-                                comment: "Alert title for deleting carb equivalents"
-                            )
-                                : String(localized: "Delete Carbs?", comment: "Alert title for deleting carbs")
-                            alertMessage = String(
-                                localized: "All FPUs and the carbs of the meal will be deleted.",
-                                comment: "Alert message for meal deletion"
-                            )
-                        }
-
-                        isRemoveHistoryItemAlertPresented = true
-                    }
-                ).tint(.red)
-
-                Button(
-                    "Edit",
-                    systemImage: "pencil",
-                    role: .none,
-                    action: {
-                        state.carbEntryToEdit = meal
-                        state.showCarbEntryEditor = true
-                    }
-                )
-                .tint(!state.settingsManager.settings.useFPUconversion && meal.isFPU ? Color(.systemGray4) : Color.blue)
-                .disabled(!state.settingsManager.settings.useFPUconversion && meal.isFPU)
-            }
-            .alert(
-                Text(alertTitle),
-                isPresented: $isRemoveHistoryItemAlertPresented
-            ) {
-                Button("Cancel", role: .cancel) {}
-                Button("Delete", role: .destructive) {
-                    guard let carbEntryToDelete = alertCarbEntryToDelete else {
-                        debug(.default, "Cannot gracefully unwrap alertCarbEntryToDelete!")
-                        return
-                    }
-                    let treatmentObjectID = carbEntryToDelete.objectID
-
-                    state.invokeCarbDeletionTask(
-                        treatmentObjectID,
-                        isFpuOrComplexMeal: carbEntryToDelete.isFPU || carbEntryToDelete.fat > 0 || carbEntryToDelete.protein > 0
-                    )
-                }
-            } message: {
-                Text("\n" + alertMessage)
-            }
-        }
-
-        // MARK: - Format glucose
-
-        private func formatGlucose(_ value: Decimal, isManual: Bool) -> String {
-            let formatter = isManual ? manualGlucoseFormatter : Formatter.glucoseFormatter(for: state.units)
-            let glucoseValue = state.units == .mmolL ? value.asMmolL : value
-            let formattedValue = formatter.string(from: glucoseValue as NSNumber) ?? "--"
-
-            return formattedValue
-        }
     }
     }
 }
 }

+ 8 - 29
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -774,27 +774,6 @@ extension Home {
             }.padding(.horizontal, 10).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
             }.padding(.horizontal, 10).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
         }
         }
 
 
-        @ViewBuilder func bolusProgressBar(_ progress: Decimal) -> some View {
-            GeometryReader { geo in
-                RoundedRectangle(cornerRadius: 15)
-                    .frame(height: 6)
-                    .foregroundColor(.clear)
-                    .background(
-                        LinearGradient(colors: [
-                            Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
-                            Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569),
-                            Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765),
-                            Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961),
-                            Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902)
-                        ], startPoint: .leading, endPoint: .trailing)
-                            .mask(alignment: .leading) {
-                                RoundedRectangle(cornerRadius: 15)
-                                    .frame(width: geo.size.width * CGFloat(progress))
-                            }
-                    )
-            }
-        }
-
         @ViewBuilder func bolusView(geo: GeometryProxy, _ progress: Decimal) -> some View {
         @ViewBuilder func bolusView(geo: GeometryProxy, _ progress: Decimal) -> some View {
             /// ensure that state.lastPumpBolus has a value, i.e. there is a last bolus done by the pump and not an external bolus
             /// ensure that state.lastPumpBolus has a value, i.e. there is a last bolus done by the pump and not an external bolus
             /// - TRUE:  show the pump bolus
             /// - TRUE:  show the pump bolus
@@ -850,14 +829,14 @@ extension Home {
                         }
                         }
                     }.padding(.horizontal, 10)
                     }.padding(.horizontal, 10)
                         .padding(.trailing, 8)
                         .padding(.trailing, 8)
-
-                }.padding(.horizontal, 10).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
-                    .overlay(alignment: .bottom) {
-                        // Use a geo-based offset here to position progress bar independent of device size
-                        let offset = geo.size.height * 0.0725
-                        bolusProgressBar(progress).padding(.horizontal, 18)
-                            .offset(y: offset)
-                    }.clipShape(RoundedRectangle(cornerRadius: 15))
+                }
+                .padding(.horizontal, 10)
+                .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
+                .overlay(alignment: .bottom) {
+                    BolusProgressBar(progress: progress)
+                        .padding(.horizontal, 18)
+                        .padding(.bottom, 9)
+                }.clipShape(RoundedRectangle(cornerRadius: 15))
             }
             }
         }
         }
 
 

+ 12 - 0
Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift

@@ -16,6 +16,8 @@ extension ISFEditor {
     @Observable final class StateModel: BaseStateModel<Provider> {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage!
         @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage!
         @ObservationIgnored @Injected() private var nightscout: NightscoutManager!
         @ObservationIgnored @Injected() private var nightscout: NightscoutManager!
+        @ObservationIgnored @Injected() private var tidepoolManager: TidepoolManager!
+        @ObservationIgnored @Injected() private var broadcaster: Broadcaster!
 
 
         var items: [Item] = []
         var items: [Item] = []
         var initialItems: [Item] = []
         var initialItems: [Item] = []
@@ -118,6 +120,12 @@ extension ISFEditor {
             provider.saveProfile(profile)
             provider.saveProfile(profile)
             initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
             initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
 
 
+            DispatchQueue.main.async {
+                self.broadcaster.notify(InsulinSensitivitiesObserver.self, on: .main) {
+                    $0.insulinSensitivitiesDidChange(profile)
+                }
+            }
+
             Task.detached(priority: .low) {
             Task.detached(priority: .low) {
                 do {
                 do {
                     debug(.nightscout, "Attempting to upload ISF to Nightscout")
                     debug(.nightscout, "Attempting to upload ISF to Nightscout")
@@ -129,6 +137,10 @@ extension ISFEditor {
                     )
                     )
                 }
                 }
             }
             }
+
+            Task.detached(priority: .low) {
+                await self.tidepoolManager.uploadSettings()
+            }
         }
         }
 
 
         func validate() {
         func validate() {

+ 22 - 11
Trio/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift

@@ -7,15 +7,26 @@ extension NightscoutConfig {
         let resolver: Resolver
         let resolver: Resolver
         let displayClose: Bool
         let displayClose: Bool
         @StateObject var state = StateModel()
         @StateObject var state = StateModel()
-        @State private var shouldDisplayHint: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var hintDetent = PresentationDetent.large
-        @State var selectedVerboseHint: AnyView?
-        @State var hintLabel: String?
+        @State private var hintPayload: HintPayload?
         @State private var decimalPlaceholder: Decimal = 0.0
         @State private var decimalPlaceholder: Decimal = 0.0
         @State private var booleanPlaceholder: Bool = false
         @State private var booleanPlaceholder: Bool = false
         @State var backfillAlert: Alert?
         @State var backfillAlert: Alert?
         @State var isBackfillAlertPresented = false
         @State var isBackfillAlertPresented = false
 
 
+        private struct HintPayload: Identifiable {
+            let id = UUID()
+            let label: String
+            let content: AnyView
+        }
+
+        private var shouldDisplayHintBinding: Binding<Bool> {
+            Binding(
+                get: { hintPayload != nil },
+                set: { newValue in if !newValue { hintPayload = nil } }
+            )
+        }
+
         @Environment(\.colorScheme) var colorScheme
         @Environment(\.colorScheme) var colorScheme
         @Environment(AppState.self) var appState
         @Environment(AppState.self) var appState
 
 
@@ -79,14 +90,14 @@ extension NightscoutConfig {
                                     Spacer()
                                     Spacer()
                                     Button(
                                     Button(
                                         action: {
                                         action: {
-                                            hintLabel = String(localized: "Backfill Glucose from Nightscout")
-                                            selectedVerboseHint =
-                                                AnyView(
+                                            hintPayload = HintPayload(
+                                                label: String(localized: "Backfill Glucose from Nightscout"),
+                                                content: AnyView(
                                                     Text(
                                                     Text(
                                                         "This will backfill 24 hours of glucose data from your connected Nightscout URL to Trio"
                                                         "This will backfill 24 hours of glucose data from your connected Nightscout URL to Trio"
                                                     )
                                                     )
                                                 )
                                                 )
-                                            shouldDisplayHint.toggle()
+                                            )
                                         },
                                         },
                                         label: {
                                         label: {
                                             HStack {
                                             HStack {
@@ -104,12 +115,12 @@ extension NightscoutConfig {
                 }
                 }
                 .listSectionSpacing(sectionSpacing)
                 .listSectionSpacing(sectionSpacing)
             }
             }
-            .sheet(isPresented: $shouldDisplayHint) {
+            .sheet(item: $hintPayload) { payload in
                 SettingInputHintView(
                 SettingInputHintView(
                     hintDetent: $hintDetent,
                     hintDetent: $hintDetent,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    hintLabel: hintLabel ?? "",
-                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    shouldDisplayHint: shouldDisplayHintBinding,
+                    hintLabel: payload.label,
+                    hintText: payload.content,
                     sheetTitle: String(localized: "Help", comment: "Help sheet title")
                     sheetTitle: String(localized: "Help", comment: "Help sheet title")
                 )
                 )
             }
             }

+ 27 - 12
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

@@ -26,21 +26,31 @@ extension Onboarding {
 
 
         // MARK: - App Diagnostics
         // MARK: - App Diagnostics
 
 
-        private var persistedDiagnosticsSharing: Bool? {
-            get { PropertyPersistentFlags.shared.diagnosticsSharingEnabled }
-            set { PropertyPersistentFlags.shared.diagnosticsSharingEnabled = newValue }
-        }
-
-        var diagnosticsSharingOption: DiagnosticsSharingOption = .enabled
+        var diagnosticsSharingOption: DiagnosticsSharingOption = .full
         var hasAcceptedPrivacyPolicy: Bool = false
         var hasAcceptedPrivacyPolicy: Bool = false
 
 
         func syncDiagnosticsOptionFromStorage() {
         func syncDiagnosticsOptionFromStorage() {
-            diagnosticsSharingOption = (persistedDiagnosticsSharing ?? true) ? .enabled : .disabled
+            // Onboarding *is* the consent decision point, so a fresh install
+            // sees `.full` (truly opt-out). If the user has already picked
+            // something — e.g. backed out of this step and returned — restore
+            // their saved selection so they see their current choice.
+            if PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true {
+                let crashlytics = PropertyPersistentFlags.shared.diagnosticsSharingEnabled ?? true
+                let telemetry = PropertyPersistentFlags.shared.telemetryEnabled ?? false
+                diagnosticsSharingOption = DiagnosticsSharingOption(
+                    crashlyticsEnabled: crashlytics,
+                    telemetryEnabled: telemetry
+                )
+            } else {
+                diagnosticsSharingOption = .full
+            }
         }
         }
 
 
         func updateDiagnosticsOption(to option: DiagnosticsSharingOption) {
         func updateDiagnosticsOption(to option: DiagnosticsSharingOption) {
             diagnosticsSharingOption = option
             diagnosticsSharingOption = option
-            persistedDiagnosticsSharing = (option == .enabled)
+            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = option.crashlyticsEnabled
+            PropertyPersistentFlags.shared.telemetryEnabled = option.telemetryEnabled
+            PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
         }
         }
 
 
         // MARK: - Determine Initial Build State
         // MARK: - Determine Initial Build State
@@ -695,11 +705,16 @@ extension Onboarding {
             saveISFValues()
             saveISFValues()
         }
         }
 
 
-        /// Persists the current diagnostics sharing option to UserDefaults as a boolean.
+        /// Persists the current diagnostics sharing option and applies it to Crashlytics + telemetry.
         func applyDiagnostics() {
         func applyDiagnostics() {
-            let booleanValue = diagnosticsSharingOption == .enabled
-            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = booleanValue
-            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(booleanValue)
+            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = diagnosticsSharingOption.crashlyticsEnabled
+            PropertyPersistentFlags.shared.telemetryEnabled = diagnosticsSharingOption.telemetryEnabled
+            PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
+            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(diagnosticsSharingOption.crashlyticsEnabled)
+            if diagnosticsSharingOption.telemetryEnabled {
+                TelemetryClient.shared.scheduleRecurring()
+                Task.detached { await TelemetryClient.shared.maybeSend() }
+            }
         }
         }
 
 
         /// Applies the selected glucose units to the app's settings.
         /// Applies the selected glucose units to the app's settings.

+ 1 - 1
Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift

@@ -71,7 +71,7 @@ extension Onboarding {
 
 
         // Next button conditional
         // Next button conditional
         private var shouldDisableNextButton: Bool {
         private var shouldDisableNextButton: Bool {
-            (currentStep == .diagnostics && state.diagnosticsSharingOption == .enabled && !state.hasAcceptedPrivacyPolicy)
+            (currentStep == .diagnostics && state.diagnosticsSharingOption != .disabled && !state.hasAcceptedPrivacyPolicy)
                 ||
                 ||
                 (currentStep == .nightscout && didSelectNightscoutSetupOption)
                 (currentStep == .nightscout && didSelectNightscoutSetupOption)
                 ||
                 ||

+ 22 - 11
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/DiagnosticsStepView.swift

@@ -7,7 +7,7 @@ struct DiagnosticsStepView: View {
 
 
     var body: some View {
     var body: some View {
         VStack(alignment: .leading, spacing: 20) {
         VStack(alignment: .leading, spacing: 20) {
-            Text("If you prefer not to share this anonymized data, you can opt-out of data sharing.")
+            Text("Help us improve Trio. Pick how much you'd like to share — or opt out entirely.")
                 .font(.headline)
                 .font(.headline)
                 .padding(.horizontal)
                 .padding(.horizontal)
                 .multilineTextAlignment(.leading)
                 .multilineTextAlignment(.leading)
@@ -16,13 +16,19 @@ struct DiagnosticsStepView: View {
                 Button(action: {
                 Button(action: {
                     state.updateDiagnosticsOption(to: option)
                     state.updateDiagnosticsOption(to: option)
                 }) {
                 }) {
-                    HStack {
+                    HStack(alignment: .top, spacing: 12) {
                         Image(systemName: state.diagnosticsSharingOption == option ? "largecircle.fill.circle" : "circle")
                         Image(systemName: state.diagnosticsSharingOption == option ? "largecircle.fill.circle" : "circle")
                             .foregroundColor(state.diagnosticsSharingOption == option ? .accentColor : .secondary)
                             .foregroundColor(state.diagnosticsSharingOption == option ? .accentColor : .secondary)
                             .imageScale(.large)
                             .imageScale(.large)
 
 
-                        Text(option.displayName)
-                            .foregroundColor(.primary)
+                        VStack(alignment: .leading, spacing: 4) {
+                            Text(option.displayName)
+                                .foregroundColor(.primary)
+                                .bold()
+                            Text(option.caption)
+                                .font(.footnote)
+                                .foregroundColor(.secondary)
+                        }
 
 
                         Spacer()
                         Spacer()
                     }
                     }
@@ -33,6 +39,14 @@ struct DiagnosticsStepView: View {
                 .buttonStyle(.plain)
                 .buttonStyle(.plain)
             }
             }
 
 
+            NavigationLink {
+                TelemetryPreviewView()
+            } label: {
+                Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass")
+                    .font(.footnote)
+            }
+            .padding(.horizontal)
+
             Toggle(isOn: $state.hasAcceptedPrivacyPolicy) {
             Toggle(isOn: $state.hasAcceptedPrivacyPolicy) {
                 HStack {
                 HStack {
                     Text("I have read and accept the")
                     Text("I have read and accept the")
@@ -59,28 +73,25 @@ struct DiagnosticsStepView: View {
                 VStack(alignment: .leading, spacing: 4) {
                 VStack(alignment: .leading, spacing: 4) {
                     BulletPoint(
                     BulletPoint(
                         String(
                         String(
-                            localized: "App diagnostic insights help us enhance app stability, ensure safety for all users, and enable us to quickly identify and resolve critical issues."
+                            localized: "App diagnostic insights — based on crash reports only — help us enhance app stability, ensure safety for all users, and quickly identify and resolve critical issues."
                         )
                         )
                     )
                     )
                     BulletPoint(
                     BulletPoint(
                         String(
                         String(
-                            localized: "Trio collects the app's state on crash, device, iOS and general system info, and a stack trace."
+                            localized: "Crash reports include the app's state on crash, device, iOS info, and a stack trace. They are sent to Google Firebase Crashlytics, maintained by the Trio team."
                         )
                         )
                     )
                     )
                     BulletPoint(
                     BulletPoint(
                         String(
                         String(
-                            localized: "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, setting values, or similar."
+                            localized: "Anonymous usage statistics include the app version, your device and iOS version, your paired pump and CGM, and whether Nightscout, Tidepool, and Apple Health are configured (yes/no). No URLs, tokens, or credentials are included."
                         )
                         )
                     )
                     )
                     BulletPoint(
                     BulletPoint(
                         String(
                         String(
-                            localized: "Trio does not track any usage metrics or any other personal data about users other than the used iPhone model and iOS version."
+                            localized: "Trio never collects glucose readings, insulin rates or doses, meal data, therapy setting values, or any other health information."
                         )
                         )
                     )
                     )
                 }
                 }
-                Text(
-                    "Diagnostics are sent to a Google Firebase Crashlytics project, which is securely maintained and accessed only by the Trio team."
-                )
             }
             }
             .multilineTextAlignment(.leading)
             .multilineTextAlignment(.leading)
             .padding(.horizontal)
             .padding(.horizontal)

+ 47 - 3
Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift

@@ -483,20 +483,64 @@ enum DeliveryLimitSubstep: Int, CaseIterable, Identifiable {
     }
     }
 }
 }
 
 
+/// Three-state diagnostics-sharing consent.
+///
+/// Maps to a pair of independent `Bool?` flags in `PropertyPersistentFlags`:
+/// `diagnosticsSharingEnabled` (Crashlytics) and `telemetryEnabled` (the
+/// anonymous-usage POST). See `TelemetryClient`.
 enum DiagnosticsSharingOption: String, Equatable, CaseIterable, Identifiable {
 enum DiagnosticsSharingOption: String, Equatable, CaseIterable, Identifiable {
-    case enabled
+    case full
+    case crashOnly
     case disabled
     case disabled
 
 
     var id: String { rawValue }
     var id: String { rawValue }
 
 
     var displayName: String {
     var displayName: String {
         switch self {
         switch self {
-        case .enabled:
-            return String(localized: "Enable Sharing")
+        case .full:
+            return String(localized: "Enable Full Sharing")
+        case .crashOnly:
+            return String(localized: "Crash Reports Only")
         case .disabled:
         case .disabled:
             return String(localized: "Disable Sharing")
             return String(localized: "Disable Sharing")
         }
         }
     }
     }
+
+    var caption: String {
+        switch self {
+        case .full:
+            return String(localized: "Share anonymous crash reports + usage data.")
+        case .crashOnly:
+            return String(localized: "Share only crash reports — no usage data.")
+        case .disabled:
+            return String(localized: "Do not share any diagnostic data.")
+        }
+    }
+
+    var crashlyticsEnabled: Bool {
+        switch self {
+        case .crashOnly,
+             .full: return true
+        case .disabled: return false
+        }
+    }
+
+    var telemetryEnabled: Bool {
+        switch self {
+        case .full: return true
+        case .crashOnly,
+             .disabled: return false
+        }
+    }
+
+    init(crashlyticsEnabled: Bool, telemetryEnabled: Bool) {
+        switch (crashlyticsEnabled, telemetryEnabled) {
+        case (true, true): self = .full
+        case (true, false): self = .crashOnly
+        case (false, true): self = .full // unreachable in normal flow
+        case (false, false): self = .disabled
+        }
+    }
 }
 }
 
 
 enum PumpOptionForOnboardingUnits: String, Equatable, CaseIterable, Identifiable {
 enum PumpOptionForOnboardingUnits: String, Equatable, CaseIterable, Identifiable {

+ 12 - 0
Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift

@@ -96,6 +96,18 @@ struct TherapySettingEditorView: View {
                                 .transition(.slide)
                                 .transition(.slide)
                             }
                             }
                         }
                         }
+                        .contextMenu {
+                            if let index = items.firstIndex(where: { $0.id == item.id }), items.count > 1 {
+                                Button(role: .destructive) {
+                                    items.remove(at: index)
+                                    selectedItemID = nil
+                                    validateTherapySettingItems()
+                                } label: {
+                                    Label("Delete", systemImage: "trash")
+                                }
+                                .tint(.red)
+                            }
+                        }
                         .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                         .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                             if let index = items.firstIndex(where: { $0.id == item.id }), items.count > 1 {
                             if let index = items.firstIndex(where: { $0.id == item.id }), items.count > 1 {
                                 Button(role: .destructive) {
                                 Button(role: .destructive) {

+ 2 - 1
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -286,7 +286,8 @@ enum SettingItems {
                 "Time in Range Type",
                 "Time in Range Type",
                 "Time in Tight Range (TITR)",
                 "Time in Tight Range (TITR)",
                 "Time in Normoglycemia (TING)",
                 "Time in Normoglycemia (TING)",
-                "X-Axis Interval Step"
+                "X-Axis Interval Step",
+                "Require Adjustments Confirmation"
             ],
             ],
             scrollTargetLabels: [
             scrollTargetLabels: [
                 "Show Y-Axis Grid Lines": "Show X-Axis Grid Lines",
                 "Show Y-Axis Grid Lines": "Show X-Axis Grid Lines",

+ 0 - 1
Trio/Sources/Modules/Settings/SettingsStateModel.swift

@@ -31,7 +31,6 @@ extension Settings {
 
 
             subscribeSetting(\.debugOptions, on: $debugOptions) { debugOptions = $0 }
             subscribeSetting(\.debugOptions, on: $debugOptions) { debugOptions = $0 }
             subscribeSetting(\.closedLoop, on: $closedLoop) { closedLoop = $0 }
             subscribeSetting(\.closedLoop, on: $closedLoop) { closedLoop = $0 }
-
             broadcaster.register(SettingsObserver.self, observer: self)
             broadcaster.register(SettingsObserver.self, observer: self)
 
 
             buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
             buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"

+ 1 - 1
Trio/Sources/Modules/Settings/View/TidepoolStartView.swift

@@ -99,7 +99,7 @@ struct TidepoolStartView: BaseView {
                 shouldDisplayHint: $shouldDisplayHint,
                 shouldDisplayHint: $shouldDisplayHint,
                 hintLabel: "Connect to Tidepool",
                 hintLabel: "Connect to Tidepool",
                 hintText: Text(
                 hintText: Text(
-                    "When connected, uploading of carbs, bolus, basal and glucose from Trio to your Tidepool account is enabled.\n\nUse your Tidepool credentials to login. If you dont already have a Tidepool account, you can sign up for one on the login page."
+                    "Use your Tidepool credentials to log in. If you don't have a Tidepool account, you can sign up on the login page.\n\nWhen connected, Trio uploads your glucose, carb entries, insulin (bolus and basal), pump settings, and therapy settings to Tidepool.\n\nTherapy settings include basal schedules, carb ratios, insulin sensitivities, and glucose targets."
                 ),
                 ),
                 sheetTitle: String(localized: "Help", comment: "Help sheet title")
                 sheetTitle: String(localized: "Help", comment: "Help sheet title")
             )
             )

+ 7 - 0
Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift

@@ -809,6 +809,13 @@ extension SettingsExport {
                     name: String(localized: "Time in Range Type"),
                     name: String(localized: "Time in Range Type"),
                     value: trioSettings.timeInRangeType.rawValue
                     value: trioSettings.timeInRangeType.rawValue
                 )
                 )
+                addSetting(
+                    category: featuresCategory,
+                    subcategory: userInterfaceSubcategory,
+                    name: String(localized: "Require Adjustments Confirmation"),
+                    value: trioSettings
+                        .requireAdjustmentsConfirmation ? String(localized: "Enabled") : String(localized: "Disabled")
+                )
 
 
                 // Appearance setting from UserDefaults
                 // Appearance setting from UserDefaults
                 let colorSchemePreference = UserDefaults.standard.string(forKey: "colorSchemePreference") ?? "systemDefault"
                 let colorSchemePreference = UserDefaults.standard.string(forKey: "colorSchemePreference") ?? "systemDefault"

+ 5 - 0
Trio/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift

@@ -3,6 +3,7 @@ import SwiftUI
 extension TargetsEditor {
 extension TargetsEditor {
     final class StateModel: BaseStateModel<Provider> {
     final class StateModel: BaseStateModel<Provider> {
         @Injected() private var nightscout: NightscoutManager!
         @Injected() private var nightscout: NightscoutManager!
+        @Injected() private var tidepoolManager: TidepoolManager!
         @Injected() private var broadcaster: Broadcaster!
         @Injected() private var broadcaster: Broadcaster!
 
 
         @Published var items: [Item] = []
         @Published var items: [Item] = []
@@ -113,6 +114,10 @@ extension TargetsEditor {
                     )
                     )
                 }
                 }
             }
             }
+
+            Task.detached(priority: .low) {
+                await self.tidepoolManager.uploadSettings()
+            }
         }
         }
 
 
         func validate() {
         func validate() {

+ 5 - 0
Trio/Sources/Modules/Telemetry/TelemetryDataFlow.swift

@@ -0,0 +1,5 @@
+enum Telemetry {
+    enum Config {}
+}
+
+protocol TelemetryProvider {}

+ 3 - 0
Trio/Sources/Modules/Telemetry/TelemetryProvider.swift

@@ -0,0 +1,3 @@
+extension Telemetry {
+    final class Provider: BaseProvider, TelemetryProvider {}
+}

+ 9 - 0
Trio/Sources/Modules/Telemetry/TelemetryStateModel.swift

@@ -0,0 +1,9 @@
+import Observation
+
+extension Telemetry {
+    @Observable final class StateModel: BaseStateModel<Provider> {}
+}
+
+extension Telemetry.StateModel: SettingsObserver {
+    func settingsDidChange(_: TrioSettings) {}
+}

+ 145 - 0
Trio/Sources/Modules/Telemetry/View/TelemetryMigrationSheetView.swift

@@ -0,0 +1,145 @@
+import FirebaseCrashlytics
+import SwiftUI
+
+/// One-shot sheet shown on first foreground for users who completed onboarding
+/// before telemetry existed. Mirrors the onboarding `DiagnosticsStepView`
+/// chooser but is presented standalone, with a Privacy-Policy acceptance gate
+/// and no "skip" path — the user must explicitly pick one of the three options.
+///
+/// Once dismissed, `telemetryConsentDecisionMade` is set to `true` so the sheet
+/// never re-appears for this install.
+struct TelemetryMigrationSheetView: View {
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.openURL) private var openURL
+
+    @State private var selectedOption: DiagnosticsSharingOption = .full
+    // User already accepted the Privacy Policy during onboarding. This toggle
+    // is a re-acknowledgment that the policy has been updated to cover the new
+    // telemetry section — pre-checked so Continue works out of the box; users
+    // who want to read the updated policy can uncheck and tap the link.
+    @State private var hasAcceptedPrivacyPolicy: Bool = false
+
+    var onDecision: (() -> Void)?
+
+    var body: some View {
+        NavigationView {
+            ScrollView {
+                VStack(alignment: .leading, spacing: 20) {
+                    Text("Help us improve Trio")
+                        .font(.title2)
+                        .bold()
+
+                    Text(
+                        "Until now, Trio could only sent crash reports. You can now also share anonymous usage statistics — things like your iPhone and iOS version, and which pump and CGM you have paired. This helps the Trio team prioritize what to fix and improve next."
+                    )
+                    .font(.subheadline)
+
+                    Text(
+                        "Your glucose data, therapy settings, credentials, and logs always stay on your device. Pick what you'd like to share — you can change this any time in Settings → App Diagnostics."
+                    )
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+
+                    ForEach(DiagnosticsSharingOption.allCases, id: \.self) { option in
+                        Button(action: {
+                            selectedOption = option
+                        }) {
+                            HStack(alignment: .top, spacing: 12) {
+                                Image(systemName: selectedOption == option ? "largecircle.fill.circle" : "circle")
+                                    .foregroundColor(selectedOption == option ? .accentColor : .secondary)
+                                    .imageScale(.large)
+
+                                VStack(alignment: .leading, spacing: 4) {
+                                    Text(option.displayName)
+                                        .foregroundColor(.primary)
+                                        .bold()
+                                    Text(option.caption)
+                                        .font(.footnote)
+                                        .foregroundColor(.secondary)
+                                }
+
+                                Spacer()
+                            }
+                            .padding()
+                            .background(Color(.secondarySystemBackground))
+                            .cornerRadius(10)
+                        }
+                        .buttonStyle(.plain)
+                    }
+
+                    Toggle(isOn: $hasAcceptedPrivacyPolicy) {
+                        HStack {
+                            Text("I have read and accept the")
+                            Button("Privacy Policy") {
+                                if let url = URL(string: "https://github.com/nightscout/Trio/blob/dev/PRIVACY_POLICY.md") {
+                                    openURL(url)
+                                }
+                            }
+                            .foregroundColor(.accentColor)
+                            .underline()
+                        }
+                        .font(.footnote)
+                    }
+                    .toggleStyle(CheckboxToggleStyle(tint: Color.accentColor))
+                    .disabled(selectedOption == .disabled)
+                    .opacity(selectedOption == .disabled ? 0.35 : 1)
+
+                    NavigationLink {
+                        TelemetryPreviewView()
+                    } label: {
+                        Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass")
+                    }
+                    .padding(.top, 4)
+                }
+                .padding()
+
+                Spacer()
+
+                Button {
+                    confirm()
+                } label: {
+                    Text("Confirm").bold().frame(maxWidth: .infinity, minHeight: 30, alignment: .center)
+                }
+                .buttonStyle(.borderedProminent)
+                .disabled(selectedOption != .disabled && !hasAcceptedPrivacyPolicy)
+                .padding(.top)
+                .padding(.horizontal)
+            }
+            .navigationTitle("Improved Diagnostics")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .principal) {
+                    HStack(spacing: 6) {
+                        Text("NEW")
+                            .font(.caption2)
+                            .bold()
+                            .foregroundColor(.white)
+                            .padding(.horizontal, 6)
+                            .padding(.vertical, 2)
+                            .background(Color.accentColor)
+                            .clipShape(Capsule())
+                        Text("Improved Diagnostics")
+                            .font(.headline)
+                    }
+                }
+            }
+            .interactiveDismissDisabled(true)
+        }
+    }
+
+    private func confirm() {
+        let wasTelemetryOn = PropertyPersistentFlags.shared.telemetryEnabled == true
+        PropertyPersistentFlags.shared.diagnosticsSharingEnabled = selectedOption.crashlyticsEnabled
+        PropertyPersistentFlags.shared.telemetryEnabled = selectedOption.telemetryEnabled
+        PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
+        Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(selectedOption.crashlyticsEnabled)
+
+        if selectedOption.telemetryEnabled, !wasTelemetryOn {
+            TelemetryClient.shared.scheduleRecurring()
+            Task.detached { await TelemetryClient.shared.maybeSend() }
+        }
+
+        onDecision?()
+        dismiss()
+    }
+}

+ 84 - 0
Trio/Sources/Modules/Telemetry/View/TelemetryPreviewView.swift

@@ -0,0 +1,84 @@
+import SwiftUI
+
+/// Renders the exact payload that would be sent right now, with a copy button.
+/// Linked to from Settings → App Diagnostics and from the migration sheet.
+struct TelemetryPreviewView: View {
+    @State private var jsonText: String = ""
+    @State private var showResetConfirm: Bool = false
+    @State private var resetStatus: String?
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: 12) {
+                Text(
+                    "Below is the exact JSON object Trio would send right now. No glucose, insulin, carbs, credentials, or settings values are included."
+                )
+                .font(.subheadline)
+                .foregroundColor(.secondary)
+                .padding(.bottom, 4)
+
+                Text(jsonText)
+                    .font(.system(.footnote, design: .monospaced))
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                    .textSelection(.enabled)
+                    .padding(8)
+                    .background(Color(.secondarySystemBackground))
+                    .cornerRadius(6)
+
+                Button {
+                    UIPasteboard.general.string = jsonText
+                } label: {
+                    Label("Copy JSON", systemImage: "doc.on.doc")
+                }
+                .buttonStyle(.bordered)
+
+                Button(role: .destructive) {
+                    showResetConfirm = true
+                } label: {
+                    Label("Reset App Attest state", systemImage: "arrow.counterclockwise.circle")
+                }
+                .buttonStyle(.bordered)
+
+                if let resetStatus {
+                    Text(resetStatus)
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }
+            .padding()
+        }
+        .navigationTitle("What's sent")
+        .navigationBarTitleDisplayMode(.inline)
+        .onAppear { jsonText = Self.renderPayload() }
+        .confirmationDialog(
+            "Reset App Attest state?",
+            isPresented: $showResetConfirm,
+            titleVisibility: .visible
+        ) {
+            Button("Reset and retry send", role: .destructive) {
+                TelemetryAttestor.shared.resetAttestState()
+                resetStatus = "Reset done — attempting a fresh send. Check logs for status."
+                Task { await TelemetryClient.shared.maybeSend() }
+            }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text(
+                "Clears the local App Attest key, registered flag, and forbidden flag. The next telemetry send will re-attest from scratch. Use only if telemetry is stuck."
+            )
+        }
+    }
+
+    private static func renderPayload() -> String {
+        let payload = TelemetryClient.shared.buildPayload()
+        guard
+            let data = try? JSONSerialization.data(
+                withJSONObject: payload,
+                options: [.prettyPrinted, .sortedKeys]
+            ),
+            let text = String(data: data, encoding: .utf8)
+        else {
+            return String(localized: "Unable to render payload.")
+        }
+        return text
+    }
+}

Plik diff jest za duży
+ 54 - 0
Trio/Sources/Modules/Telemetry/View/TelemetryPrivacyView.swift


+ 70 - 17
Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift

@@ -124,6 +124,7 @@ extension Treatments {
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
         let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
         let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
         let determinationFetchContext = CoreDataStack.shared.newTaskContext()
         let determinationFetchContext = CoreDataStack.shared.newTaskContext()
+        let pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
 
 
         var isActive: Bool = false
         var isActive: Bool = false
 
 
@@ -137,8 +138,9 @@ extension Treatments {
 
 
         typealias PumpEvent = PumpEventStored.EventType
         typealias PumpEvent = PumpEventStored.EventType
 
 
-        var isBolusInProgress: Bool = false
-        private var bolusProgressCancellable: AnyCancellable?
+        var bolusProgress: Decimal?
+        var isBolusInProgress: Bool { bolusProgress != nil }
+        var lastPumpBolus: PumpEventStored?
 
 
         func unsubscribe() {
         func unsubscribe() {
             subscriptions.forEach { $0.cancel() }
             subscriptions.forEach { $0.cancel() }
@@ -173,7 +175,7 @@ extension Treatments {
             hasCleanedUp = true
             hasCleanedUp = true
 
 
             unsubscribe()
             unsubscribe()
-            bolusProgressCancellable?.cancel()
+            lifetime = Lifetime()
 
 
             broadcaster?.unregister(DeterminationObserver.self, observer: self)
             broadcaster?.unregister(DeterminationObserver.self, observer: self)
             broadcaster?.unregister(BolusFailureObserver.self, observer: self)
             broadcaster?.unregister(BolusFailureObserver.self, observer: self)
@@ -198,6 +200,9 @@ extension Treatments {
                         group.addTask {
                         group.addTask {
                             self.registerObservers()
                             self.registerObservers()
                         }
                         }
+                        group.addTask {
+                            self.setupLastBolus()
+                        }
 
 
                         // Wait for all tasks to complete
                         // Wait for all tasks to complete
                         try await group.waitForAll()
                         try await group.waitForAll()
@@ -208,22 +213,21 @@ extension Treatments {
             }
             }
         }
         }
 
 
-        /// Observes changes to the `bolusProgress` published by the `apsManager` to update the `isBolusInProgress` property in real time.
-        ///
-        /// - Important:
-        ///   - `apsManager.bolusProgress` is a `CurrentValueSubject<Decimal?, Never>`.
-        ///   - When a bolus starts, this subject emits `0` (or a fraction like `0.1, 0.5, etc.`).
-        ///   - When the bolus finishes, the subject is typically set to `nil`.
-        ///   - This treats ANY non-nil value as "bolus in progress."
-        ///
+        /// Mirrors `apsManager.bolusProgress` (a `CurrentValueSubject<Decimal?, Never>`) directly into the
+        /// state model so the View can read both the progress fraction (0.0–1.0) and a derived in-progress
+        /// flag. Stored in `lifetime` to match the Home module's pattern (HomeStateModel.registerObservers).
         private func subscribeToBolusProgress() {
         private func subscribeToBolusProgress() {
-            bolusProgressCancellable = apsManager.bolusProgress
+            apsManager.bolusProgress
                 .receive(on: DispatchQueue.main)
                 .receive(on: DispatchQueue.main)
-                .sink { [weak self] progressValue in
-                    guard let self = self else { return }
-                    // If progressValue is non-nil, a bolus is in progress.
-                    self.isBolusInProgress = (progressValue != nil)
-                }
+                .weakAssign(to: \.bolusProgress, on: self)
+                .store(in: &lifetime)
+        }
+
+        func cancelBolus() {
+            Task {
+                await apsManager.cancelBolus(nil)
+                try? await apsManager.determineBasalSync()
+            }
         }
         }
 
 
         // MARK: - Basal
         // MARK: - Basal
@@ -744,6 +748,11 @@ extension Treatments.StateModel {
             guard let self = self else { return }
             guard let self = self else { return }
             self.setupGlucoseArray()
             self.setupGlucoseArray()
         }.store(in: &subscriptions)
         }.store(in: &subscriptions)
+
+        // Refresh `lastPumpBolus` whenever a new pump event lands (mirrors HomeStateModel)
+        coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
+            self?.setupLastBolus()
+        }.store(in: &subscriptions)
     }
     }
 
 
     private func registerSubscribers() {
     private func registerSubscribers() {
@@ -999,3 +1008,47 @@ private extension Predictions {
         iob == nil && zt == nil && cob == nil && uam == nil
         iob == nil && zt == nil && cob == nil && uam == nil
     }
     }
 }
 }
+
+// MARK: - Last Pump Bolus
+
+extension Treatments.StateModel {
+    /// Mirrors `HomeStateModel.setupLastBolus` so the in-progress visualizer can show the
+    /// running pump-bolus's amount as the denominator (not the user's pending entry).
+    /// Filters out external boluses via `NSPredicate.lastPumpBolus`.
+    func setupLastBolus() {
+        Task {
+            do {
+                guard let id = try await fetchLastBolus() else { return }
+                await updateLastBolus(with: id)
+            } catch {
+                debug(.default, "\(DebuggingIdentifiers.failed) Error setting up last bolus: \(error)")
+            }
+        }
+    }
+
+    private func fetchLastBolus() async throws -> NSManagedObjectID? {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: pumpHistoryFetchContext,
+            predicate: NSPredicate.lastPumpBolus,
+            key: "timestamp",
+            ascending: false,
+            fetchLimit: 1
+        )
+
+        return try await pumpHistoryFetchContext.perform {
+            guard let fetched = results as? [PumpEventStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
+            return fetched.map(\.objectID).first
+        }
+    }
+
+    @MainActor private func updateLastBolus(with id: NSManagedObjectID) {
+        do {
+            lastPumpBolus = try viewContext.existingObject(with: id) as? PumpEventStored
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) updateLastBolus: \(error)")
+        }
+    }
+}

+ 126 - 36
Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift

@@ -42,6 +42,23 @@ extension Treatments {
             return formatter
             return formatter
         }
         }
 
 
+        private var bolusProgressFormatter: NumberFormatter {
+            let fractionDigits: Int = switch state.settingsManager.preferences.bolusIncrement {
+            case 0.1: 1
+            case 0.025: 3
+            default: 2
+            }
+
+            let formatter = NumberFormatter()
+            formatter.numberStyle = .decimal
+            formatter.minimum = 0
+            formatter.maximumFractionDigits = fractionDigits
+            formatter.minimumFractionDigits = fractionDigits
+            formatter.allowsFloats = true
+            formatter.roundingIncrement = Double(state.settingsManager.preferences.bolusIncrement) as NSNumber
+            return formatter
+        }
+
         private var mealFormatter: NumberFormatter {
         private var mealFormatter: NumberFormatter {
             let formatter = NumberFormatter()
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
             formatter.numberStyle = .decimal
@@ -475,6 +492,9 @@ extension Treatments {
         }
         }
 
 
         var treatmentButton: some View {
         var treatmentButton: some View {
+            let shouldDisplayBolusProgress = state.isBolusInProgress && state.amount > 0 &&
+                !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
+
             var treatmentButtonBackground = Color(.systemBlue)
             var treatmentButtonBackground = Color(.systemBlue)
             if limitExceeded {
             if limitExceeded {
                 treatmentButtonBackground = Color(.systemRed)
                 treatmentButtonBackground = Color(.systemRed)
@@ -483,41 +503,43 @@ extension Treatments {
             }
             }
 
 
             return Section {
             return Section {
-                Button {
-                    if bolusWarning.shouldConfirm {
-                        showConfirmDialogForBolusing = true
-                    } else {
-                        state.invokeTreatmentsTask()
-                    }
-                } label: {
-                    HStack {
-                        if state.isBolusInProgress && state.amount > 0 &&
-                            !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
-                        {
-                            ProgressView()
+                if shouldDisplayBolusProgress {
+                    bolusInProgressView
+                        .listRowBackground(Color.clear)
+                        .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
+                } else {
+                    Button {
+                        if bolusWarning.shouldConfirm {
+                            showConfirmDialogForBolusing = true
+                        } else {
+                            state.invokeTreatmentsTask()
                         }
                         }
-                        taskButtonLabel
+                    } label: {
+                        HStack {
+                            taskButtonLabel
+                        }
+                        .font(.headline)
+                        .foregroundStyle(Color.white)
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .frame(height: 35)
                     }
                     }
-                    .font(.headline)
-                    .foregroundStyle(Color.white)
-                    .frame(maxWidth: .infinity, alignment: .center)
-                    .frame(height: 35)
-                }
-                .disabled(disableTaskButton)
-                .listRowBackground(treatmentButtonBackground)
-                .shadow(radius: 3)
-                .clipShape(RoundedRectangle(cornerRadius: 8))
-                .confirmationDialog(
-                    bolusWarning.warningMessage + " Bolus \(state.amount.description) U?",
-                    isPresented: $showConfirmDialogForBolusing,
-                    titleVisibility: .visible
-                ) {
-                    Button("Cancel", role: .cancel) {}
-                    Button(
-                        bolusWarning.warningMessage.isEmpty ? "Enact Bolus" : "Ignore Warning and Enact Bolus",
-                        role: bolusWarning.warningMessage.isEmpty ? nil : .destructive
+                    .disabled(disableTaskButton)
+                    .listRowBackground(treatmentButtonBackground)
+                    .shadow(radius: 3)
+                    .clipShape(RoundedRectangle(cornerRadius: 8))
+                    .confirmationDialog(
+                        bolusWarning.warningMessage + " Bolus \(state.amount.description) U?",
+                        isPresented: $showConfirmDialogForBolusing,
+                        titleVisibility: .visible
                     ) {
                     ) {
-                        state.invokeTreatmentsTask()
+                        Button("Cancel", role: .cancel) {}
+                        Button(
+                            bolusWarning.warningMessage
+                                .isEmpty ? String(localized: "Enact Bolus") : String(localized: "Ignore Warning and Enact Bolus"),
+                            role: bolusWarning.warningMessage.isEmpty ? nil : .destructive
+                        ) {
+                            state.invokeTreatmentsTask()
+                        }
                     }
                     }
                 }
                 }
             } header: {
             } header: {
@@ -532,6 +554,75 @@ extension Treatments {
             }
             }
         }
         }
 
 
+        /// Card-style in-progress visualizer matching Home's `bolusView` look:
+        /// insulin-tinted background, cross.vial.fill icon, "Bolusing" + "X of Y U" text,
+        /// xmark.app cancel, gradient progress bar overlaid at the bottom.
+        @ViewBuilder private var bolusInProgressView: some View {
+            let progress = state.bolusProgress ?? 0
+            let bolusTotal = state.lastPumpBolus?.bolus?.amount as Decimal?
+            let bolusFraction = (bolusTotal ?? 0) * progress
+            let bolusString: String = {
+                guard let bolusTotal = bolusTotal else { return String(localized: "Bolus In Progress...") }
+                return (bolusProgressFormatter.string(from: bolusFraction as NSNumber) ?? "0")
+                    + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view")
+                    + (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
+                    + String(localized: " U", comment: "Insulin unit")
+            }()
+
+            ZStack {
+                // background card
+                RoundedRectangle(cornerRadius: 15)
+                    .fill(
+                        colorScheme == .dark
+                            ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745)
+                            : Color.insulin.opacity(0.2)
+                    )
+                    .frame(height: 56)
+                    .shadow(
+                        color: colorScheme == .dark
+                            ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706)
+                            : Color.black.opacity(0.33),
+                        radius: 3
+                    )
+
+                // bolus content
+                HStack {
+                    Image(systemName: "cross.vial.fill")
+                        .font(.system(size: 25))
+
+                    Spacer()
+
+                    VStack {
+                        Text("Bolusing")
+                            .font(.subheadline)
+                            .frame(maxWidth: .infinity, alignment: .leading)
+                        Text(bolusString)
+                            .font(.caption)
+                            .frame(maxWidth: .infinity, alignment: .leading)
+                    }
+                    .padding(.leading, 5)
+
+                    Spacer()
+
+                    Button { state.cancelBolus() } label: {
+                        Image(systemName: "xmark.app")
+                            .font(.system(size: 25))
+                    }.tint(Color.tabBar)
+                        .buttonStyle(.borderless)
+                        .accessibilityLabel("Cancel bolus")
+                }
+                .padding(.horizontal, 10)
+                .padding(.trailing, 8)
+            }
+            .padding(.horizontal, 10)
+            .overlay(alignment: .bottom) {
+                BolusProgressBar(progress: progress)
+                    .padding(.horizontal, 18)
+                    .padding(.bottom, 1)
+            }
+            .clipShape(RoundedRectangle(cornerRadius: 15))
+        }
+
         private var taskButtonLabel: some View {
         private var taskButtonLabel: some View {
             if pumpBolusLimitExceeded {
             if pumpBolusLimitExceeded {
                 return Text("Max Bolus of \(state.maxBolus.description) U Exceeded")
                 return Text("Max Bolus of \(state.maxBolus.description) U Exceeded")
@@ -550,9 +641,8 @@ extension Treatments {
             let hasFatOrProtein = state.fat > 0 || state.protein > 0
             let hasFatOrProtein = state.fat > 0 || state.protein > 0
             let bolusString = state.externalInsulin ? String(localized: "External Insulin") : String(localized: "Enact Bolus")
             let bolusString = state.externalInsulin ? String(localized: "External Insulin") : String(localized: "Enact Bolus")
 
 
-            if state.isBolusInProgress && hasInsulin && !state.externalInsulin && (!hasCarbs || !hasFatOrProtein) {
-                return Text("Bolus In Progress...")
-            }
+            // Note: when a pump bolus is in progress, the row is rendered by `bolusInProgressView`
+            // (Home-style card), so this label's in-progress branch is intentionally absent.
 
 
             switch (hasInsulin, hasCarbs, hasFatOrProtein) {
             switch (hasInsulin, hasCarbs, hasFatOrProtein) {
             case (true, true, true):
             case (true, true, true):
@@ -562,7 +652,7 @@ extension Treatments {
             case (true, false, true):
             case (true, false, true):
                 return Text("Log FPU and \(bolusString)")
                 return Text("Log FPU and \(bolusString)")
             case (true, false, false):
             case (true, false, false):
-                return Text(state.externalInsulin ? "Log External Insulin" : "Enact Bolus")
+                return Text(state.externalInsulin ? String(localized: "Log External Insulin") : String(localized: "Enact Bolus"))
             case (false, true, true):
             case (false, true, true):
                 return Text("Log Meal")
                 return Text("Log Meal")
             case (false, true, false):
             case (false, true, false):

+ 4 - 0
Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift

@@ -14,6 +14,7 @@ extension UserInterfaceSettings {
         @Published var glucoseColorScheme: GlucoseColorScheme = .staticColor
         @Published var glucoseColorScheme: GlucoseColorScheme = .staticColor
         @Published var eA1cDisplayUnit: EstimatedA1cDisplayUnit = .percent
         @Published var eA1cDisplayUnit: EstimatedA1cDisplayUnit = .percent
         @Published var timeInRangeType: TimeInRangeType = .timeInTightRange
         @Published var timeInRangeType: TimeInRangeType = .timeInTightRange
+        @Published var requireAdjustmentsConfirmation: Bool = false
 
 
         var units: GlucoseUnits = .mgdL
         var units: GlucoseUnits = .mgdL
 
 
@@ -44,6 +45,9 @@ extension UserInterfaceSettings {
             subscribeSetting(\.eA1cDisplayUnit, on: $eA1cDisplayUnit) { eA1cDisplayUnit = $0 }
             subscribeSetting(\.eA1cDisplayUnit, on: $eA1cDisplayUnit) { eA1cDisplayUnit = $0 }
 
 
             subscribeSetting(\.timeInRangeType, on: $timeInRangeType) { timeInRangeType = $0 }
             subscribeSetting(\.timeInRangeType, on: $timeInRangeType) { timeInRangeType = $0 }
+
+            subscribeSetting(\.requireAdjustmentsConfirmation, on: $requireAdjustmentsConfirmation) {
+                requireAdjustmentsConfirmation = $0 }
         }
         }
     }
     }
 }
 }

+ 23 - 0
Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift

@@ -561,6 +561,29 @@ extension UserInterfaceSettings {
                     ),
                     ),
                     headerText: String(localized: "Carbs Required Badge")
                     headerText: String(localized: "Carbs Required Badge")
                 )
                 )
+
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.requireAdjustmentsConfirmation,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0.map { AnyView($0) }
+                            hintLabel = String(localized: "Require Adjustments Confirmation")
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: String(localized: "Require Adjustments Confirmation"),
+                    miniHint: String(
+                        localized: "If enabled, a confirmation dialog will be shown when activating adjustment presets."
+                    ),
+                    verboseHint: Text(
+                        "Turning this on will show a confirmation dialog when you activate an Override or Temporary Target preset. This is for users who would like avoid accidentally activating a preset by mistake."
+                    ),
+                    headerText: String(localized: "Adjustments")
+                )
             }
             }
             .listSectionSpacing(sectionSpacing)
             .listSectionSpacing(sectionSpacing)
             .sheet(isPresented: $shouldDisplayHint) {
             .sheet(isPresented: $shouldDisplayHint) {

+ 0 - 1
Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift

@@ -73,7 +73,6 @@ extension BaseNightscoutManager {
             .filteredByEntityName("GlucoseStored")
             .filteredByEntityName("GlucoseStored")
             .sink { [weak self] _ in
             .sink { [weak self] _ in
                 self?.requestUpload(.glucose)
                 self?.requestUpload(.glucose)
-                self?.requestUpload(.manualGlucose)
             }
             }
             .store(in: &subscriptions)
             .store(in: &subscriptions)
     }
     }

+ 10 - 5
Trio/Sources/Services/Network/Nightscout/NightscoutAPI.swift

@@ -188,14 +188,21 @@ extension NightscoutAPI {
         return
         return
     }
     }
 
 
-    func deleteManualGlucose(withId id: String) async throws {
+    func deleteGlucose(withId id: String, withDate date: Date) async throws {
         var components = URLComponents()
         var components = URLComponents()
         components.scheme = url.scheme
         components.scheme = url.scheme
         components.host = url.host
         components.host = url.host
         components.port = url.port
         components.port = url.port
-        components.path = Config.treatmentsPath
+        components.path = Config.uploadEntriesPath
         components.queryItems = [
         components.queryItems = [
-            URLQueryItem(name: "find[id][$eq]", value: id)
+            URLQueryItem(
+                name: "find[$or][0][id][$eq]",
+                value: id
+            ),
+            URLQueryItem(
+                name: "find[$or][1][dateString][$eq]",
+                value: Formatter.iso8601withFractionalSeconds.string(from: date)
+            )
         ]
         ]
 
 
         guard let url = components.url else {
         guard let url = components.url else {
@@ -216,8 +223,6 @@ extension NightscoutAPI {
         guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
         guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
             throw URLError(.badServerResponse)
             throw URLError(.badServerResponse)
         }
         }
-
-        debugPrint("Delete successful for ID \(id)")
     }
     }
 
 
     func deleteInsulin(withId id: String) async throws {
     func deleteInsulin(withId id: String) async throws {

+ 6 - 90
Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift

@@ -11,14 +11,13 @@ protocol NightscoutManager: GlucoseSource {
     func fetchTempTargets() async -> [TempTarget]
     func fetchTempTargets() async -> [TempTarget]
     func deleteCarbs(withID id: String) async
     func deleteCarbs(withID id: String) async
     func deleteInsulin(withID id: String) async
     func deleteInsulin(withID id: String) async
-    func deleteManualGlucose(withID id: String) async
+    func deleteGlucose(withID id: String, withDate date: Date) async
     func uploadDeviceStatus() async throws
     func uploadDeviceStatus() async throws
     func uploadGlucose() async
     func uploadGlucose() async
     func uploadCarbs() async
     func uploadCarbs() async
     func uploadPumpHistory() async
     func uploadPumpHistory() async
     func uploadOverrides() async
     func uploadOverrides() async
     func uploadTempTargets() async
     func uploadTempTargets() async
-    func uploadManualGlucose() async
     func uploadProfiles() async throws
     func uploadProfiles() async throws
     func uploadNoteTreatment(note: String) async
     func uploadNoteTreatment(note: String) async
     func importSettings() async -> ScheduledNightscoutProfile?
     func importSettings() async -> ScheduledNightscoutProfile?
@@ -54,7 +53,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     /// coalesce into a single upload run for that pipeline.
     /// coalesce into a single upload run for that pipeline.
     let uploadPipelineInterval: [NightscoutUploadPipeline: TimeInterval] = [
     let uploadPipelineInterval: [NightscoutUploadPipeline: TimeInterval] = [
         .carbs: 2, .pumpHistory: 2, .overrides: 2, .tempTargets: 2,
         .carbs: 2, .pumpHistory: 2, .overrides: 2, .tempTargets: 2,
-        .glucose: 2, .manualGlucose: 2, .deviceStatus: 2
+        .glucose: 2, .deviceStatus: 2
     ]
     ]
 
 
     /// Subjects used to request an upload pipeline. The pipeline applies a throttle so
     /// Subjects used to request an upload pipeline. The pipeline applies a throttle so
@@ -95,7 +94,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         case .overrides: await uploadOverrides()
         case .overrides: await uploadOverrides()
         case .tempTargets: await uploadTempTargets()
         case .tempTargets: await uploadTempTargets()
         case .glucose: await uploadGlucose()
         case .glucose: await uploadGlucose()
-        case .manualGlucose: await uploadManualGlucose()
         case .deviceStatus:
         case .deviceStatus:
             do { try await uploadDeviceStatus() }
             do { try await uploadDeviceStatus() }
             catch { debug(.nightscout, "deviceStatus upload failed: \(error)") }
             catch { debug(.nightscout, "deviceStatus upload failed: \(error)") }
@@ -339,15 +337,16 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         }
         }
     }
     }
 
 
-    func deleteManualGlucose(withID id: String) async {
+    func deleteGlucose(withID id: String, withDate date: Date) async {
         guard let nightscout = nightscoutAPI, isUploadEnabled else { return }
         guard let nightscout = nightscoutAPI, isUploadEnabled else { return }
 
 
         do {
         do {
-            try await nightscout.deleteManualGlucose(withId: id)
+            try await nightscout.deleteGlucose(withId: id, withDate: date)
+            debug(.nightscout, "Glucose deleted")
         } catch {
         } catch {
             debug(
             debug(
                 .nightscout,
                 .nightscout,
-                "\(DebuggingIdentifiers.failed) Failed to delete Manual Glucose from Nightscout with error: \(error)"
+                "\(DebuggingIdentifiers.failed) Failed to delete Glucose from Nightscout with error: \(error)"
             )
             )
         }
         }
     }
     }
@@ -582,10 +581,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         } catch {
         } catch {
             debug(.nightscout, String(describing: error))
             debug(.nightscout, String(describing: error))
         }
         }
-
-        Task.detached {
-            await self.uploadPodAge()
-        }
     }
     }
 
 
     private func updateOrefDeterminationAsUploaded(_ determination: [Determination]) async {
     private func updateOrefDeterminationAsUploaded(_ determination: [Determination]) async {
@@ -610,33 +605,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         }
         }
     }
     }
 
 
-    func uploadPodAge() async {
-        let uploadedPodAge = storage.retrieve(OpenAPS.Nightscout.uploadedPodAge, as: [NightscoutTreatment].self) ?? []
-        if let podAge = storage.retrieve(OpenAPS.Monitor.podAge, as: Date.self),
-           uploadedPodAge.last?.createdAt == nil || podAge != uploadedPodAge.last!.createdAt!
-        {
-            let siteTreatment = NightscoutTreatment(
-                duration: nil,
-                rawDuration: nil,
-                rawRate: nil,
-                absolute: nil,
-                rate: nil,
-                eventType: .nsSiteChange,
-                createdAt: podAge,
-                enteredBy: NightscoutTreatment.local,
-                bolus: nil,
-                insulin: nil,
-                notes: nil,
-                carbs: nil,
-                fat: nil,
-                protein: nil,
-                targetTop: nil,
-                targetBottom: nil
-            )
-            await uploadNonCoreDataTreatments([siteTreatment])
-        }
-    }
-
     func uploadProfiles() async throws {
     func uploadProfiles() async throws {
         if isUploadEnabled {
         if isUploadEnabled {
             do {
             do {
@@ -803,17 +771,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         }
         }
     }
     }
 
 
-    func uploadManualGlucose() async {
-        do {
-            try await uploadManualGlucose(glucoseStorage.getManualGlucoseNotYetUploadedToNightscout())
-        } catch {
-            debug(
-                .nightscout,
-                "\(DebuggingIdentifiers.failed) failed to upload manual glucose with error: \(error)"
-            )
-        }
-    }
-
     func uploadPumpHistory() async {
     func uploadPumpHistory() async {
         do {
         do {
             try await uploadPumpHistory(pumpHistoryStorage.getPumpHistoryNotYetUploadedToNightscout())
             try await uploadPumpHistory(pumpHistoryStorage.getPumpHistoryNotYetUploadedToNightscout())
@@ -959,47 +916,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         }
         }
     }
     }
 
 
-    private func uploadManualGlucose(_ treatments: [NightscoutTreatment]) async {
-        guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
-            return
-        }
-
-        do {
-            for chunk in treatments.chunks(ofCount: 100) {
-                try await nightscout.uploadTreatments(Array(chunk))
-            }
-
-            // If successful, update the isUploadedToNS property of the GlucoseStored objects
-            await updateManualGlucoseAsUploaded(treatments)
-
-            debug(.nightscout, "Treatments uploaded")
-        } catch {
-            debug(.nightscout, String(describing: error))
-        }
-    }
-
-    private func updateManualGlucoseAsUploaded(_ treatments: [NightscoutTreatment]) async {
-        await backgroundContext.perform {
-            let ids = treatments.map(\.id) as NSArray
-            let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
-            fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
-
-            do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
-                for result in results {
-                    result.isUploadedToNS = true
-                }
-
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
-            } catch let error as NSError {
-                debugPrint(
-                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
-                )
-            }
-        }
-    }
-
     private func uploadCarbs(_ treatments: [NightscoutTreatment]) async {
     private func uploadCarbs(_ treatments: [NightscoutTreatment]) async {
         guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
         guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
             return
             return

+ 0 - 1
Trio/Sources/Services/Network/Nightscout/NightscoutUploadPipeline.swift

@@ -9,7 +9,6 @@ public enum NightscoutUploadPipeline: String, CaseIterable {
     case overrides
     case overrides
     case tempTargets
     case tempTargets
     case glucose
     case glucose
-    case manualGlucose
     case deviceStatus
     case deviceStatus
 }
 }
 
 

+ 304 - 2
Trio/Sources/Services/Network/TidepoolManager.swift

@@ -1,11 +1,13 @@
 import Combine
 import Combine
 import CoreData
 import CoreData
+import CryptoKit
 import Foundation
 import Foundation
 import HealthKit
 import HealthKit
 import LoopKit
 import LoopKit
 import LoopKitUI
 import LoopKitUI
 import Swinject
 import Swinject
 import TidepoolServiceKit
 import TidepoolServiceKit
+import UIKit
 
 
 protocol TidepoolManager {
 protocol TidepoolManager {
     func addTidepoolService(service: Service)
     func addTidepoolService(service: Service)
@@ -16,6 +18,7 @@ protocol TidepoolManager {
     func uploadInsulin() async
     func uploadInsulin() async
     func deleteInsulin(withSyncId id: String, amount: Decimal, at: Date)
     func deleteInsulin(withSyncId id: String, amount: Decimal, at: Date)
     func uploadGlucose() async
     func uploadGlucose() async
+    func uploadSettings() async
     func forceTidepoolDataUpload()
     func forceTidepoolDataUpload()
 }
 }
 
 
@@ -27,8 +30,25 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
     @Injected() private var storage: FileStorage!
     @Injected() private var storage: FileStorage!
     @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
     @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
     @Injected() private var apsManager: APSManager!
     @Injected() private var apsManager: APSManager!
+    @Injected() private var settingsManager: SettingsManager!
+
+    // Lazy access to avoid circular dependency (TidepoolManager ↔ FetchGlucoseManager)
+    private var resolver: Resolver?
 
 
     private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue")
     private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue")
+
+    /// Pending debounce work item for settings upload; cancelled and rescheduled
+    /// each time an observer fires, so rapid changes coalesce into one upload.
+    /// - Important: Only access from `processQueue` to ensure thread safety.
+    private var pendingSettingsUpload: DispatchWorkItem?
+
+    /// Delay before a debounced settings upload fires.
+    private static let settingsUploadDebounceDelay: TimeInterval = 1.5
+
+    /// Last-seen therapy-relevant TrioSettings values.
+    /// Used to filter `settingsDidChange` so UI-only changes don't trigger uploads.
+    private var lastClosedLoop: Bool?
+    private var lastUnits: GlucoseUnits?
     private var tidepoolService: RemoteDataService? {
     private var tidepoolService: RemoteDataService? {
         didSet {
         didSet {
             if let tidepoolService = tidepoolService {
             if let tidepoolService = tidepoolService {
@@ -49,6 +69,7 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
     @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue?
     @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue?
 
 
     init(resolver: Resolver) {
     init(resolver: Resolver) {
+        self.resolver = resolver
         injectServices(resolver)
         injectServices(resolver)
         loadTidepoolManager()
         loadTidepoolManager()
 
 
@@ -133,6 +154,10 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
                 await self.uploadGlucose()
                 await self.uploadGlucose()
             }
             }
         }.store(in: &subscriptions)
         }.store(in: &subscriptions)
+
+        // Register for settings that aren't saved from a single editor screen
+        broadcaster.register(SettingsObserver.self, observer: self)
+        broadcaster.register(PreferencesObserver.self, observer: self)
     }
     }
 
 
     func sourceInfo() -> [String: Any]? {
     func sourceInfo() -> [String: Any]? {
@@ -145,14 +170,14 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
             await uploadInsulin()
             await uploadInsulin()
             await uploadCarbs()
             await uploadCarbs()
             await uploadGlucose()
             await uploadGlucose()
+            await uploadSettings()
         }
         }
     }
     }
 }
 }
 
 
 extension BaseTidepoolManager: ServiceDelegate {
 extension BaseTidepoolManager: ServiceDelegate {
     var hostIdentifier: String {
     var hostIdentifier: String {
-        // TODO: shouldn't this rather be `org.nightscout.Trio` ?
-        "com.loopkit.Loop" // To check
+        "org.nightscout.Trio"
     }
     }
 
 
     var hostVersion: String {
     var hostVersion: String {
@@ -648,6 +673,79 @@ extension BaseTidepoolManager {
     }
     }
 }
 }
 
 
+/// Settings Upload Functionality
+extension BaseTidepoolManager {
+    /// Debounces settings upload requests.
+    /// Cancels any pending upload and schedules a new one after the debounce delay.
+    /// This prevents redundant uploads when multiple settings observers fire in rapid succession.
+    /// All access to `pendingSettingsUpload` is serialized on `processQueue`.
+    private func scheduleSettingsUpload() {
+        processQueue.async { [weak self] in
+            guard let self = self else { return }
+            self.pendingSettingsUpload?.cancel()
+            let workItem = DispatchWorkItem { [weak self] in
+                guard let self = self else { return }
+                Task {
+                    await self.uploadSettings()
+                }
+            }
+            self.pendingSettingsUpload = workItem
+            self.processQueue.asyncAfter(
+                deadline: .now() + Self.settingsUploadDebounceDelay,
+                execute: workItem
+            )
+        }
+    }
+
+    func uploadSettings() async {
+        guard let tidepoolService = self.tidepoolService as? TidepoolService else {
+            return
+        }
+
+        // Get CGM device info (lazily resolved to avoid circular dependency)
+        let fetchGlucoseManager = resolver?.resolve(FetchGlucoseManager.self)
+        let cgmDevice = fetchGlucoseManager?.cgmManager?.cgmManagerStatus.device
+
+        guard let settings = createStoredSettings(cgmDevice: cgmDevice) else {
+            return
+        }
+
+        processQueue.async {
+            tidepoolService.uploadSettingsData([settings]) { result in
+                switch result {
+                case .success:
+                    debug(.service, "Settings uploaded to Tidepool (syncId: \(settings.syncIdentifier))")
+                case let .failure(error):
+                    debug(.service, "Failed to upload settings to Tidepool: \(error)")
+                }
+            }
+        }
+    }
+}
+
+// MARK: - Settings Change Observers
+
+extension BaseTidepoolManager: SettingsObserver {
+    func settingsDidChange(_ settings: TrioSettings) {
+        // Only trigger upload when therapy-relevant properties change.
+        // TrioSettings has ~56 properties, most are UI-only (badges, colors, etc.).
+        let closedLoopChanged = lastClosedLoop != settings.closedLoop
+        let unitsChanged = lastUnits != settings.units
+
+        lastClosedLoop = settings.closedLoop
+        lastUnits = settings.units
+
+        guard closedLoopChanged || unitsChanged else { return }
+        scheduleSettingsUpload()
+    }
+}
+
+extension BaseTidepoolManager: PreferencesObserver {
+    func preferencesDidChange(_: Preferences) {
+        scheduleSettingsUpload()
+    }
+}
+
 extension BaseTidepoolManager: StatefulPluggableDelegate {
 extension BaseTidepoolManager: StatefulPluggableDelegate {
     func pluginDidUpdateState(_: LoopKit.StatefulPluggable) {}
     func pluginDidUpdateState(_: LoopKit.StatefulPluggable) {}
 
 
@@ -656,6 +754,210 @@ extension BaseTidepoolManager: StatefulPluggableDelegate {
     }
     }
 }
 }
 
 
+// MARK: - Settings Conversion
+
+extension BaseTidepoolManager {
+    /// Creates a StoredSettings object from current Trio settings
+    /// - Parameter cgmDevice: Optional CGM device info (pass from FetchGlucoseManager to avoid circular dependency)
+    func createStoredSettings(cgmDevice: HKDevice? = nil) -> StoredSettings? {
+        guard let basalProfile: [BasalProfileEntry] = storage
+            .retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self),
+            let carbRatios: CarbRatios = storage.retrieve(OpenAPS.Settings.carbRatios, as: CarbRatios.self),
+            let insulinSensitivities: InsulinSensitivities = storage.retrieve(
+                OpenAPS.Settings.insulinSensitivities,
+                as: InsulinSensitivities.self
+            ),
+            let bgTargets: BGTargets = storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self)
+        else {
+            debug(.service, "Failed to load Trio therapy settings for Tidepool upload")
+            return nil
+        }
+
+        let pumpSettings = settingsManager.pumpSettings
+        let preferences: Preferences? = storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
+
+        let basalRateSchedule = convertBasalProfile(basalProfile)
+        let carbRatioSchedule = convertCarbRatios(carbRatios)
+        let insulinSensitivitySchedule = convertInsulinSensitivities(insulinSensitivities)
+        let glucoseTargetRangeSchedule = convertBGTargets(bgTargets)
+
+        let pumpDevice = apsManager.pumpManager?.status.device
+        let bgUnit: HKUnit = settingsManager.settings.units == .mmolL ? .millimolesPerLiter : .milligramsPerDeciliter
+
+        // threshold_setting is always stored in mg/dL; TidepoolServiceKit calls
+        // convertTo(unit:) internally, so we pass it through in its native unit
+        let suspendThreshold: GlucoseThreshold? = preferences.map { prefs in
+            let thresholdValue = Double(prefs.threshold_setting)
+            return GlucoseThreshold(unit: .milligramsPerDeciliter, value: thresholdValue)
+        }
+
+        return StoredSettings(
+            date: Date(),
+            controllerTimeZone: TimeZone.current,
+            dosingEnabled: settingsManager.settings.closedLoop,
+            glucoseTargetRangeSchedule: glucoseTargetRangeSchedule,
+            preMealTargetRange: nil,
+            workoutTargetRange: nil,
+            overridePresets: nil,
+            scheduleOverride: nil,
+            preMealOverride: nil,
+            maximumBasalRatePerHour: Double(pumpSettings.maxBasal),
+            maximumBolus: Double(pumpSettings.maxBolus),
+            suspendThreshold: suspendThreshold,
+            insulinType: apsManager.pumpManager?.status.insulinType,
+            defaultRapidActingModel: convertInsulinModel(preferences: preferences, pumpSettings: pumpSettings),
+            basalRateSchedule: basalRateSchedule,
+            insulinSensitivitySchedule: insulinSensitivitySchedule,
+            carbRatioSchedule: carbRatioSchedule,
+            notificationSettings: nil,
+            controllerDevice: createControllerDevice(),
+            cgmDevice: cgmDevice,
+            pumpDevice: pumpDevice,
+            bloodGlucoseUnit: bgUnit,
+            syncIdentifier: contentBasedSyncIdentifier(
+                basalProfile: basalProfile,
+                carbRatios: carbRatios,
+                insulinSensitivities: insulinSensitivities,
+                bgTargets: bgTargets,
+                pumpSettings: pumpSettings,
+                preferences: preferences,
+                dosingEnabled: settingsManager.settings.closedLoop
+            )
+        )
+    }
+
+    private func convertBasalProfile(_ entries: [BasalProfileEntry]) -> BasalRateSchedule? {
+        let items = entries.map { entry in
+            let startTime = TimeInterval(entry.minutes * 60)
+            return RepeatingScheduleValue(startTime: startTime, value: Double(entry.rate))
+        }
+        return BasalRateSchedule(dailyItems: items, timeZone: TimeZone.current)
+    }
+
+    private func convertCarbRatios(_ carbRatios: CarbRatios) -> CarbRatioSchedule? {
+        let items = carbRatios.schedule.map { entry in
+            let startTime = TimeInterval(entry.offset * 60)
+            return RepeatingScheduleValue(startTime: startTime, value: Double(entry.ratio))
+        }
+        return CarbRatioSchedule(unit: .gram(), dailyItems: items, timeZone: TimeZone.current)
+    }
+
+    private func convertInsulinSensitivities(_ sensitivities: InsulinSensitivities) -> InsulinSensitivitySchedule? {
+        // sensitivities.units comes from the data model itself, not the user's display preference
+        let unit: HKUnit = sensitivities.units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter
+        let items = sensitivities.sensitivities.map { entry in
+            let startTime = TimeInterval(entry.offset * 60)
+            return RepeatingScheduleValue(startTime: startTime, value: Double(entry.sensitivity))
+        }
+        return InsulinSensitivitySchedule(unit: unit, dailyItems: items, timeZone: TimeZone.current)
+    }
+
+    private func convertBGTargets(_ bgTargets: BGTargets) -> GlucoseRangeSchedule? {
+        // bgTargets.units comes from the data model itself, not the user's display preference
+        let unit: HKUnit = bgTargets.units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter
+        let items = bgTargets.targets.map { entry in
+            let startTime = TimeInterval(entry.offset * 60)
+            let minValue = Double(entry.low)
+            let maxValue = Double(entry.high)
+            return RepeatingScheduleValue(startTime: startTime, value: DoubleRange(minValue: minValue, maxValue: maxValue))
+        }
+        let schedule = DailyQuantitySchedule(unit: unit, dailyItems: items, timeZone: TimeZone.current)
+        return schedule.map { GlucoseRangeSchedule(rangeSchedule: $0) }
+    }
+
+    private func convertInsulinModel(preferences: Preferences?, pumpSettings: PumpSettings) -> StoredInsulinModel? {
+        guard let curve = preferences?.curve else { return nil }
+
+        let modelType: StoredInsulinModel.ModelType
+        let preset: ExponentialInsulinModelPreset
+        switch curve {
+        case .bilinear,
+             .rapidActing:
+            modelType = .rapidAdult
+            preset = .rapidActingAdult
+        case .ultraRapid:
+            // Distinguish Fiasp vs Lyumjev using the pump's configured insulin type
+            let isLyumjev = apsManager.pumpManager?.status.insulinType == .lyumjev
+            modelType = isLyumjev ? .lyumjev : .fiasp
+            preset = isLyumjev ? .lyumjev : .fiasp
+        }
+
+        let dia = Double(pumpSettings.insulinActionCurve)
+
+        // Use custom peak time if enabled, otherwise fall back to LoopKit preset default
+        let peakActivity: TimeInterval
+        if let prefs = preferences, prefs.useCustomPeakTime {
+            peakActivity = .minutes(Double(prefs.insulinPeakTime))
+        } else {
+            peakActivity = preset.peakActivity
+        }
+
+        return StoredInsulinModel(
+            modelType: modelType,
+            delay: preset.delay,
+            actionDuration: .hours(dia),
+            peakActivity: peakActivity
+        )
+    }
+
+    /// Generates a deterministic UUID based on the content of the therapy settings.
+    /// If settings haven't changed, the same UUID is produced, enabling Tidepool
+    /// server-side deduplication via the origin ID.
+    private func contentBasedSyncIdentifier(
+        basalProfile: [BasalProfileEntry],
+        carbRatios: CarbRatios,
+        insulinSensitivities: InsulinSensitivities,
+        bgTargets: BGTargets,
+        pumpSettings: PumpSettings,
+        preferences: Preferences?,
+        dosingEnabled: Bool
+    ) -> UUID {
+        var hasher = SHA256()
+
+        for entry in basalProfile {
+            hasher.update(data: Data("\(entry.minutes):\(entry.rate)".utf8))
+        }
+        for entry in carbRatios.schedule {
+            hasher.update(data: Data("\(entry.offset):\(entry.ratio)".utf8))
+        }
+        for entry in insulinSensitivities.sensitivities {
+            hasher.update(data: Data("\(entry.offset):\(entry.sensitivity)".utf8))
+        }
+        for entry in bgTargets.targets {
+            hasher.update(data: Data("\(entry.offset):\(entry.low):\(entry.high)".utf8))
+        }
+
+        hasher.update(data: Data("maxBasal:\(pumpSettings.maxBasal)".utf8))
+        hasher.update(data: Data("maxBolus:\(pumpSettings.maxBolus)".utf8))
+
+        if let prefs = preferences {
+            hasher.update(data: Data("threshold:\(prefs.threshold_setting)".utf8))
+        }
+
+        hasher.update(data: Data("dosingEnabled:\(dosingEnabled)".utf8))
+
+        let digest = hasher.finalize()
+        let bytes = Array(digest.prefix(16))
+        return UUID(uuid: (
+            bytes[0], bytes[1], bytes[2], bytes[3],
+            bytes[4], bytes[5], bytes[6], bytes[7],
+            bytes[8], bytes[9], bytes[10], bytes[11],
+            bytes[12], bytes[13], bytes[14], bytes[15]
+        ))
+    }
+
+    private func createControllerDevice() -> StoredSettings.ControllerDevice {
+        let device = UIDevice.current
+        return StoredSettings.ControllerDevice(
+            name: "Trio",
+            systemName: device.systemName,
+            systemVersion: device.systemVersion,
+            model: device.model,
+            modelIdentifier: device.getDeviceId
+        )
+    }
+}
+
 // Service extension for rawValue
 // Service extension for rawValue
 extension Service {
 extension Service {
     typealias RawValue = [String: Any]
     typealias RawValue = [String: Any]

+ 338 - 0
Trio/Sources/Services/Telemetry/TelemetryAttestor.swift

@@ -0,0 +1,338 @@
+import CryptoKit
+import DeviceCheck
+import Foundation
+import Swinject
+
+// MARK: - TelemetryAttestor
+
+/// Apple App Attest wrapper for the telemetry uploader. Owns:
+///   - the per-install App Attest key (generated once, persisted in Keychain)
+///   - the "this install has been registered with the server" flag (Keychain)
+///   - challenge fetch + assertion generation per send cycle
+///
+/// Designed to fail soft: if the device doesn't support App Attest
+/// (simulators, older iOS, etc.), `isSupported` is false and the caller
+/// should silently skip the send. Server-side rejections (403 from the
+/// register endpoint) are sticky — recorded in PropertyPersistentFlags so
+/// subsequent cycles don't retry indefinitely.
+///
+/// Wire protocol matches `nightscout/trio-telemetry`:
+///   1. POST /api/auth/ios/challenge       → { "challenge": "<base64url>" }
+///   2. POST /api/attest/register          (once per install)
+///   3. /checkin                           (per ping, headers below)
+final class TelemetryAttestor: Injectable {
+    static let shared = TelemetryAttestor()
+
+    @Injected() private var keychain: Keychain!
+
+    private let service = DCAppAttestService.shared
+    private let lock = NSRecursiveLock()
+    private var didInjectServices = false
+
+    private static let keyIDStorageKey = "TelemetryAttest.keyID"
+    private static let registeredStorageKey = "TelemetryAttest.registered"
+
+    private init() {}
+
+    private func injectIfNeeded() {
+        lock.lock()
+        defer { lock.unlock() }
+        guard !didInjectServices else { return }
+        injectServices(TrioApp.resolver)
+        didInjectServices = true
+    }
+
+    /// True when the running device supports App Attest. Returns false on the
+    /// simulator and on devices that lack a Secure Enclave.
+    var isSupported: Bool {
+        service.isSupported
+    }
+
+    /// True once a 403 from `/api/attest/register` has flagged this install
+    /// as permanently rejected — typically a misconfigured `app_id`. Callers
+    /// should stop attempting to send.
+    var isForbidden: Bool {
+        PropertyPersistentFlags.shared.telemetryAttestForbidden == true
+    }
+
+    // MARK: - Registration
+
+    /// Idempotent: returns immediately if already registered. Otherwise
+    /// performs `generateKey` → fetch challenge → `attestKey` → POST register.
+    /// Throws on transport / server errors; sets the sticky "forbidden" flag
+    /// on a 403 so future cycles short-circuit.
+    func registerIfNeeded(baseURL: URL) async throws {
+        injectIfNeeded()
+
+        guard isSupported else { throw AttestError.unsupportedDevice }
+        guard !isForbidden else { throw AttestError.forbidden }
+
+        if (keychain.getValue(Bool.self, forKey: Self.registeredStorageKey) ?? false) == true {
+            return
+        }
+
+        // generateKey() returns a base64url-encoded key identifier (Apple's docs).
+        // We persist it as-is for use in the assertion path below.
+        let keyID = try await currentOrCreateKeyID()
+        let challenge = try await fetchChallenge(baseURL: baseURL)
+
+        // App Attest expects a SHA-256 of the "client data" — for the
+        // attestation step, that's the challenge bytes alone.
+        let challengeBytes = Data(challenge.utf8)
+        let clientDataHash = Data(SHA256.hash(data: challengeBytes))
+
+        // Diagnostics for `attestKey` failures. We log shape, not values:
+        // keyID prefix only (the keyID is per-install and shouldn't end up in
+        // shareable logs in full). If any of these look off, the failure is
+        // ours; if they look right and Apple still rejects, the failure is
+        // server-side at Apple.
+        let keyIDPrefix = String(keyID.prefix(8))
+        debug(
+            .telemetry,
+            "attestKey input: isSupported=\(service.isSupported) keyID.count=\(keyID.count) keyID.prefix=\(keyIDPrefix) hash.count=\(clientDataHash.count) challenge.count=\(challenge.count) bundle=\(Bundle.main.bundleIdentifier ?? "nil")"
+        )
+
+        let attestationCBOR: Data
+        do {
+            attestationCBOR = try await service.attestKey(keyID, clientDataHash: clientDataHash)
+        } catch {
+            // `attestKey` is one-shot per key per device, but only on success.
+            // Branch on the DCError code so logs distinguish the recoverable
+            // cases from real failures:
+            //   .invalidKey         — keyID is permanently burnt; drop it.
+            //   .invalidInput       — Apple rejected an argument as malformed.
+            //                         In practice we see this when the keyID
+            //                         is stale (e.g. survived an uninstall via
+            //                         Keychain) and no longer matches Apple's
+            //                         expected identity for this install. Drop
+            //                         the keyID — same recovery as invalidKey.
+            //   .serverUnavailable  — Apple's App Attest backend is down or
+            //                         throttling. Key is still valid; the
+            //                         next cycle retries with the same keyID.
+            if let dcError = error as? DCError {
+                switch dcError.code {
+                case .invalidInput,
+                     .invalidKey:
+                    keychain.removeObject(forKey: Self.keyIDStorageKey)
+                    let reason = dcError.code == .invalidKey ? "invalidKey" : "invalidInput"
+                    debug(.telemetry, "attestKey \(reason): discarded keyID; will regenerate next cycle")
+                case .serverUnavailable:
+                    debug(.telemetry, "attestKey serverUnavailable: Apple App Attest backend transient — will retry next cycle")
+                default:
+                    break
+                }
+            }
+            debug(.telemetry, "attestKey failed: \(error.localizedDescription)")
+            throw AttestError.attestationFailed(error)
+        }
+
+        guard let appID = Self.currentAppID() else {
+            throw AttestError.unknownAppID
+        }
+
+        let body: [String: Any] = [
+            "attestation": attestationCBOR.base64EncodedString(),
+            "key_id": keyID,
+            "challenge": challenge,
+            "app_id": appID
+        ]
+
+        var request = URLRequest(url: baseURL.appendingPathComponent("api/attest/register"))
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.httpBody = try JSONSerialization.data(withJSONObject: body)
+        request.timeoutInterval = 15
+
+        let (_, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw AttestError.transportError
+        }
+
+        switch http.statusCode {
+        case 200,
+             201:
+            keychain.setValue(true, forKey: Self.registeredStorageKey)
+            debug(.telemetry, "register ok status=\(http.statusCode)")
+        case 403:
+            // app_id rejected. Sticky — flag the install and surface to caller.
+            PropertyPersistentFlags.shared.telemetryAttestForbidden = true
+            debug(.telemetry, "register forbidden — app_id=\(appID) rejected; no further attempts")
+            throw AttestError.forbidden
+        case 400 ..< 500:
+            throw AttestError.clientError(http.statusCode)
+        case 500 ..< 600:
+            throw AttestError.serverError(http.statusCode)
+        default:
+            throw AttestError.serverError(http.statusCode)
+        }
+    }
+
+    /// Clears the local App Attest state so the next `registerIfNeeded`
+    /// generates a fresh key and re-runs the handshake from scratch. Both the
+    /// keyID and the "registered" flag are dropped: `attestKey` may be called
+    /// at most once per key per device, so reusing the old keyID would throw
+    /// `DCError.invalidKey`. Use when `/checkin` returns 401 (server lost our
+    /// registration).
+    func invalidateRegistration() {
+        injectIfNeeded()
+        keychain.removeObject(forKey: Self.keyIDStorageKey)
+        keychain.removeObject(forKey: Self.registeredStorageKey)
+    }
+
+    /// Full local-state reset for stuck installs. In addition to what
+    /// `invalidateRegistration` clears, this also drops the sticky
+    /// `telemetryAttestForbidden` flag — so a tester who got 403'd and wants
+    /// to retry can do so without reinstalling. Exposed through a button in
+    /// the telemetry inspector. Does not touch consent or installId.
+    func resetAttestState() {
+        injectIfNeeded()
+        keychain.removeObject(forKey: Self.keyIDStorageKey)
+        keychain.removeObject(forKey: Self.registeredStorageKey)
+        PropertyPersistentFlags.shared.telemetryAttestForbidden = false
+        debug(.telemetry, "reset App Attest state: keyID, registered flag, and forbidden flag cleared")
+    }
+
+    // MARK: - Per-ping assertion
+
+    /// Builds the App Attest assertion for a single `/checkin` send.
+    ///
+    /// `clientDataHash` for the assertion is `SHA256(payloadBytes || challengeBytes)`.
+    /// **Order matters**: payload first, then the challenge (per the server
+    /// spec). Returns the base64-encoded assertion CBOR, the keyID (already a
+    /// base64url string), and the challenge string — all three become headers
+    /// on the outgoing request.
+    func assertion(forPayload payload: Data, baseURL: URL) async throws -> (assertion: String, keyID: String, challenge: String) {
+        injectIfNeeded()
+
+        guard isSupported else { throw AttestError.unsupportedDevice }
+        guard !isForbidden else { throw AttestError.forbidden }
+
+        let keyID = try await currentOrCreateKeyID()
+        let challenge = try await fetchChallenge(baseURL: baseURL)
+
+        var hasher = SHA256()
+        hasher.update(data: payload)
+        hasher.update(data: Data(challenge.utf8))
+        let clientDataHash = Data(hasher.finalize())
+
+        let assertionCBOR: Data
+        do {
+            assertionCBOR = try await service.generateAssertion(keyID, clientDataHash: clientDataHash)
+        } catch {
+            throw AttestError.assertionFailed(error)
+        }
+        return (assertionCBOR.base64EncodedString(), keyID, challenge)
+    }
+
+    // MARK: - Helpers
+
+    /// Reads the cached App Attest key identifier from Keychain, generating a
+    /// new one (and persisting it) on first call. The keyID is the only thing
+    /// we store — Apple holds the actual private key in the Secure Enclave.
+    private func currentOrCreateKeyID() async throws -> String {
+        if let cached = keychain.getValue(String.self, forKey: Self.keyIDStorageKey),
+           !cached.isEmpty
+        {
+            return cached
+        }
+        let newKey: String
+        do {
+            newKey = try await service.generateKey()
+        } catch {
+            throw AttestError.keyGenerationFailed(error)
+        }
+        keychain.setValue(newKey, forKey: Self.keyIDStorageKey)
+        debug(.telemetry, "generated new App Attest keyID")
+        return newKey
+    }
+
+    private func fetchChallenge(baseURL: URL) async throws -> String {
+        var request = URLRequest(url: baseURL.appendingPathComponent("api/auth/ios/challenge"))
+        request.httpMethod = "POST"
+        request.timeoutInterval = 15
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw AttestError.transportError
+        }
+        guard (200 ..< 300).contains(http.statusCode) else {
+            if (500 ..< 600).contains(http.statusCode) {
+                throw AttestError.serverError(http.statusCode)
+            }
+            throw AttestError.clientError(http.statusCode)
+        }
+
+        struct ChallengeResponse: Decodable { let challenge: String }
+        do {
+            let cr = try JSONDecoder().decode(ChallengeResponse.self, from: data)
+            return cr.challenge
+        } catch {
+            throw AttestError.malformedResponse
+        }
+    }
+
+    /// Produces the `<TEAMID>.<bundle-id>` string the server expects in
+    /// `app_id` — matches the regex `^[A-Z0-9]+\.org\.nightscout\.[^.]+\.trio$`
+    /// when the build is configured correctly.
+    ///
+    /// Reads `application-identifier` from `embedded.mobileprovision`. On iOS
+    /// the SDK doesn't expose `SecTaskCopyValueForEntitlement` to Swift, and
+    /// parsing the mobile-provision file is the standard workaround. Returns
+    /// nil for App Store builds (no embedded.mobileprovision) — which Trio
+    /// doesn't ship, so this path is fine for sideload + TestFlight.
+    static func currentAppID() -> String? {
+        guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"),
+              let raw = try? Data(contentsOf: url)
+        else { return nil }
+
+        // The mobileprovision file is a CMS-signed envelope around a plist.
+        // Pull the plist substring between the XML prolog and `</plist>`.
+        // ISO Latin-1 maps every byte 0x00–0xFF 1:1, so the conversion never
+        // fails on the binary CMS bytes surrounding the plist — `.ascii` would
+        // return nil here.
+        guard let scanned = String(data: raw, encoding: .isoLatin1),
+              let start = scanned.range(of: "<?xml"),
+              let end = scanned.range(of: "</plist>")
+        else { return nil }
+
+        let plistString = String(scanned[start.lowerBound ..< end.upperBound])
+        guard let plistData = plistString.data(using: .utf8),
+              let plist = try? PropertyListSerialization
+              .propertyList(from: plistData, options: [], format: nil) as? [String: Any],
+              let entitlements = plist["Entitlements"] as? [String: Any],
+              let appID = entitlements["application-identifier"] as? String
+        else { return nil }
+
+        return appID
+    }
+
+    // MARK: - Errors
+
+    enum AttestError: Error, CustomStringConvertible {
+        case unsupportedDevice
+        case forbidden
+        case unknownAppID
+        case keyGenerationFailed(Error)
+        case attestationFailed(Error)
+        case assertionFailed(Error)
+        case transportError
+        case malformedResponse
+        case clientError(Int)
+        case serverError(Int)
+
+        var description: String {
+            switch self {
+            case .unsupportedDevice: return "App Attest unsupported on this device"
+            case .forbidden: return "app_id forbidden by server"
+            case .unknownAppID: return "unable to read application-identifier entitlement"
+            case let .keyGenerationFailed(e): return "generateKey failed: \(e.localizedDescription)"
+            case let .attestationFailed(e): return "attestKey failed: \(e.localizedDescription)"
+            case let .assertionFailed(e): return "generateAssertion failed: \(e.localizedDescription)"
+            case .transportError: return "non-HTTP response"
+            case .malformedResponse: return "malformed challenge response"
+            case let .clientError(code): return "client error \(code)"
+            case let .serverError(code): return "server error \(code)"
+            }
+        }
+    }
+}

+ 352 - 0
Trio/Sources/Services/Telemetry/TelemetryClient.swift

@@ -0,0 +1,352 @@
+import Foundation
+import LoopKit
+import Swinject
+import UIKit
+
+// MARK: - TelemetryClient
+
+/// Opt-out anonymous usage check-in. Sends a small JSON payload to a self-hosted
+/// endpoint at most once every 24 hours, plus once after a new build is installed.
+/// Consent is collected during onboarding (or via a one-time migration sheet for
+/// existing users) and editable in Settings → App Diagnostics.
+///
+/// No health data, credentials, or personally-identifying information is sent.
+/// See `buildPayload()` for the exact set of fields and `TelemetryPreviewView`
+/// for the in-app inspector that renders the same payload.
+final class TelemetryClient: Injectable {
+    static let shared = TelemetryClient()
+
+    // MARK: Endpoint configuration
+
+    private static let productionBaseURL: URL? = URL(string: "https://telemetry.triodocs.org")
+
+    /// Effective base URL: respects the debug override in
+    /// `PropertyPersistentFlags.telemetryDebugServerURL`, then falls back to
+    /// `productionBaseURL`. Used by both the registration and `/checkin` paths.
+    private static var baseURL: URL? {
+        if let override = PropertyPersistentFlags.shared.telemetryDebugServerURL?
+            .trimmingCharacters(in: .whitespacesAndNewlines),
+            !override.isEmpty,
+            let url = URL(string: override)
+        {
+            return url
+        }
+        return productionBaseURL
+    }
+
+    private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60
+    private static let dailyInterval: TimeInterval = 24 * 60 * 60
+    private static let maxPayloadBytes = 4096
+
+    // MARK: Injected services
+
+    @Injected() private var apsManager: APSManager!
+    @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
+    @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var tidepoolManager: TidepoolManager!
+    @Injected() private var healthKitManager: HealthKitManager!
+    @Injected() private var keychain: Keychain!
+
+    private let lock = NSRecursiveLock()
+    private var didInjectServices = false
+    private var timer: DispatchTimer?
+
+    private init() {}
+
+    private func injectIfNeeded() {
+        lock.lock()
+        defer { lock.unlock() }
+        guard !didInjectServices else { return }
+        injectServices(TrioApp.resolver)
+        didInjectServices = true
+    }
+
+    // MARK: - Cold launches
+
+    /// Records a cold launch in a sliding 7-day window of timestamps. The count
+    /// of entries in the window ships as `coldLaunches7d` in every ping — a
+    /// "how often does iOS recycle this process" signal that is directly
+    /// comparable across pings regardless of the cadence between them.
+    func recordColdLaunch(now: Date = Date()) {
+        let cutoff = now.addingTimeInterval(-Self.weeklyInterval)
+        var recent = PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []
+        recent.removeAll { $0 < cutoff }
+        recent.append(now)
+        PropertyPersistentFlags.shared.telemetryColdLaunchTimes = recent
+    }
+
+    // MARK: - Install identifier
+
+    /// Stable per-install UUID, generated lazily on first call. IDFV resets if
+    /// the user deletes every Trio-team app at once; this survives
+    /// independently and is wiped only by deleting Trio itself.
+    private func installId() -> String {
+        if let existing = PropertyPersistentFlags.shared.telemetryInstallId, !existing.isEmpty {
+            return existing
+        }
+        let new = UUID().uuidString
+        PropertyPersistentFlags.shared.telemetryInstallId = new
+        return new
+    }
+
+    // MARK: - Cadence
+
+    /// True when the running build's commit SHA differs from the SHA recorded
+    /// at the last successful send. Used at startup to fire one immediate ping
+    /// after an app update — the 24h scheduler can't notice a build change and
+    /// would otherwise wait out the previous interval.
+    func buildShaChangedSinceLastSend() -> Bool {
+        let currentSha = BuildDetails.shared.trioCommitSHA
+        return PropertyPersistentFlags.shared.telemetryLastSentSha != currentSha
+    }
+
+    /// Arms (or re-arms) the 24h send timer. Idempotent. Bails out without
+    /// scheduling if the user hasn't decided on consent yet or has opted out
+    /// — there's nothing for the timer to do.
+    func scheduleRecurring() {
+        guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
+              PropertyPersistentFlags.shared.telemetryEnabled == true
+        else {
+            return
+        }
+
+        lock.lock()
+        defer { lock.unlock() }
+
+        if timer == nil {
+            let t = DispatchTimer(timeInterval: Self.dailyInterval)
+            t.eventHandler = { [weak self] in
+                Task.detached { await self?.maybeSend() }
+            }
+            t.resume()
+            timer = t
+        }
+    }
+
+    /// Single entry point for all sends (scheduler tick, consent-yes, startup
+    /// SHA-change). Gated on consent + opt-in. *When* to send is the caller's
+    /// decision — startup handles the SHA-change shortcut, the timer handles
+    /// 24h cadence.
+    func maybeSend() async {
+        guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
+              PropertyPersistentFlags.shared.telemetryEnabled == true
+        else {
+            return
+        }
+        await send()
+    }
+
+    // MARK: - Payload
+
+    /// The exact payload that would be POSTed right now. Pure function: shared
+    /// by `send()` and `TelemetryPreviewView`.
+    func buildPayload() -> [String: Any] {
+        injectIfNeeded()
+
+        let bd = BuildDetails.shared
+        let info = Bundle.main.infoDictionary ?? [:]
+
+        var payload: [String: Any] = [:]
+
+        if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v }
+        // appDevVersion is Trio's 4-component dev counter (e.g. "0.7.0.14") —
+        // the most precise build identifier we have. Always emit, even when
+        // the Info.plist key is missing, so dashboards can rely on the field.
+        payload["appDevVersion"] = Bundle.main.appDevVersion ?? "unknown"
+        payload["commitSha"] = bd.trioCommitSHA
+        payload["branch"] = bd.trioBranch
+
+        // Date-only prefix of the build-date string. Keeps the field a
+        // low-resolution build identifier, not a precise timestamp.
+        if let raw = bd.buildDateString, raw.count >= 10 {
+            payload["buildDate"] = String(raw.prefix(10))
+        }
+
+        payload["isTestFlight"] = bd.isTestFlightBuild()
+
+        if let idfv = UIDevice.current.identifierForVendor?.uuidString {
+            payload["idfv"] = idfv
+        }
+        payload["installId"] = installId()
+
+        payload["device"] = Self.hardwareIdentifier()
+        payload["platform"] = Self.detectPlatform()
+        payload["osVersion"] = UIDevice.current.systemVersion
+
+        // Pump model — omitted entirely when no pump is paired.
+        if let pump = apsManager?.pumpManager {
+            payload["pumpModel"] = pump.localizedTitle
+        }
+
+        // CGM: enum tells us the configured *type*; the live manager (if any)
+        // tells us the specific model name. Both are useful — `cgmType`
+        // distinguishes Dexcom-via-Nightscout from Dexcom-via-direct, etc.
+        let settings = settingsManager?.settings
+        payload["cgmType"] = settings?.cgm.rawValue ?? CGMType.none.rawValue
+        if let cgm = fetchGlucoseManager?.cgmManager {
+            payload["cgmModel"] = cgm.localizedTitle
+        }
+
+        // Nightscout: keys present in keychain ⇒ configured. We never include
+        // the URL or token themselves.
+        let nsUrl = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        let nsSecret = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        payload["nightscoutPaired"] = !nsUrl.isEmpty && !nsSecret.isEmpty
+
+        payload["tidepoolPaired"] = tidepoolManager?.getTidepoolServiceUI() != nil
+
+        let useHealth = settings?.useAppleHealth ?? false
+        let healthAuthorized = healthKitManager?.hasGrantedFullWritePermissions ?? false
+        payload["appleHealthEnabled"] = useHealth && healthAuthorized
+
+        if let settings = settings {
+            payload["closedLoop"] = settings.closedLoop
+            payload["units"] = settings.units.rawValue
+            payload["useLiveActivity"] = settings.useLiveActivity
+            payload["useCalendar"] = settings.useCalendar
+        }
+
+        payload["coldLaunches7d"] = (PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []).count
+
+        // Submodule SHAs — small, useful for tracking which LoopKit / OmniBLE /
+        // etc. revision the user is on. Branch is dropped to keep payload size small.
+        let submoduleShas = bd.submodules.mapValues { $0.commitSHA }
+        if !submoduleShas.isEmpty {
+            payload["submodules"] = submoduleShas
+        }
+
+        return payload
+    }
+
+    // MARK: - Send
+
+    /// Build payload, attest it via App Attest, POST it, update last-sent state
+    /// on 2xx. Fire-and-forget; errors are logged at debug level only.
+    ///
+    /// Flow:
+    /// 1. Skip if `TelemetryAttestor.isSupported == false` (simulator, older
+    ///    devices). This is the primary opt-out for unsupported hardware —
+    ///    sending without attestation would just bounce off the server.
+    /// 2. Skip if the install has been flagged forbidden by a previous 403.
+    /// 3. Register if needed (idempotent; first launch + once on retry after
+    ///    transient failures).
+    /// 4. Serialize the payload. Reject if > 4096 bytes (server-enforced cap).
+    /// 5. Ask the attestor for an assertion over `SHA256(payload || challenge)`.
+    /// 6. POST `/checkin` with the three App Attest headers.
+    ///
+    /// Backoff: failures don't update `telemetryLastSentAt`, so the next
+    /// scheduler tick / cold launch retries naturally. The 24h cadence is the
+    /// natural backoff floor; no per-attempt exponential timer is added.
+    func send() async {
+        guard let baseURL = Self.baseURL else {
+            debug(.telemetry, "skip send: server URL not configured")
+            return
+        }
+
+        let attestor = TelemetryAttestor.shared
+        guard attestor.isSupported else {
+            debug(.telemetry, "skip send: App Attest unsupported (simulator or older device)")
+            return
+        }
+        guard !attestor.isForbidden else {
+            debug(.telemetry, "skip send: app_id previously rejected (403)")
+            return
+        }
+
+        do {
+            try await attestor.registerIfNeeded(baseURL: baseURL)
+        } catch TelemetryAttestor.AttestError.forbidden {
+            // Already logged + sticky-flagged in registerIfNeeded.
+            return
+        } catch {
+            debug(.telemetry, "register failed: \(error) — will retry next cycle")
+            return
+        }
+
+        let payload = buildPayload()
+        guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
+            debug(.telemetry, "skip send: payload not JSON-serializable")
+            return
+        }
+        guard body.count <= Self.maxPayloadBytes else {
+            debug(.telemetry, "skip send: payload exceeds \(Self.maxPayloadBytes) bytes (\(body.count))")
+            return
+        }
+
+        let assertion: (assertion: String, keyID: String, challenge: String)
+        do {
+            assertion = try await attestor.assertion(forPayload: body, baseURL: baseURL)
+        } catch {
+            debug(.telemetry, "assertion failed: \(error)")
+            return
+        }
+
+        var request = URLRequest(url: baseURL.appendingPathComponent("checkin"))
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue(assertion.keyID, forHTTPHeaderField: "X-AppAttest-KeyId")
+        request.setValue(assertion.assertion, forHTTPHeaderField: "X-AppAttest-Assertion")
+        request.setValue(assertion.challenge, forHTTPHeaderField: "X-Challenge")
+        request.httpBody = body
+        request.timeoutInterval = 15
+
+        do {
+            let (_, response) = try await URLSession.shared.data(for: request)
+            guard let http = response as? HTTPURLResponse else {
+                debug(.telemetry, "send: non-HTTP response")
+                return
+            }
+            switch http.statusCode {
+            case 200 ..< 300:
+                PropertyPersistentFlags.shared.telemetryLastSentAt = Date()
+                PropertyPersistentFlags.shared.telemetryLastSentSha = BuildDetails.shared.trioCommitSHA
+                debug(.telemetry, "send ok status=\(http.statusCode)")
+            case 401:
+                // Server doesn't recognize our registration (e.g. its registry
+                // was wiped). Drop the local keyID + registered flag so the
+                // next cycle generates a fresh key and re-attests — `attestKey`
+                // can't be re-run on the existing keyID (one-shot per Apple).
+                attestor.invalidateRegistration()
+                debug(.telemetry, "send 401: stale registration, will re-register next cycle")
+            default:
+                debug(.telemetry, "send non-2xx status=\(http.statusCode)")
+            }
+        } catch {
+            debug(.telemetry, "send error: \(error.localizedDescription)")
+        }
+    }
+
+    // MARK: - Helpers
+
+    /// `iPhone15,2`-style identifier from `utsname.machine`. Returns
+    /// `Simulator <SIMULATOR_MODEL_IDENTIFIER>` on the simulator so analysis
+    /// can ignore those rows.
+    static func hardwareIdentifier() -> String {
+        #if targetEnvironment(simulator)
+            let env = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "Unknown"
+            return "Simulator \(env)"
+        #else
+            var sys = utsname()
+            uname(&sys)
+            let mirror = Mirror(reflecting: sys.machine)
+            let machine = mirror.children.reduce(into: "") { acc, child in
+                guard let v = child.value as? Int8, v != 0 else { return }
+                acc.append(Character(UnicodeScalar(UInt8(v))))
+            }
+            return machine.isEmpty ? "Unknown" : machine
+        #endif
+    }
+
+    static func detectPlatform() -> String {
+        #if targetEnvironment(macCatalyst)
+            return "macCatalyst"
+        #else
+            switch UIDevice.current.userInterfaceIdiom {
+            case .pad: return "iPadOS"
+            default: return "iOS"
+            }
+        #endif
+    }
+}

+ 27 - 0
Trio/Sources/Views/BolusProgressBar.swift

@@ -0,0 +1,27 @@
+import SwiftUI
+
+struct BolusProgressBar: View {
+    let progress: Decimal
+
+    var body: some View {
+        GeometryReader { geo in
+            RoundedRectangle(cornerRadius: 15)
+                .frame(height: 6)
+                .foregroundColor(.clear)
+                .background(
+                    LinearGradient(colors: [
+                        Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
+                        Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569),
+                        Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765),
+                        Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961),
+                        Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902)
+                    ], startPoint: .leading, endPoint: .trailing)
+                        .mask(alignment: .leading) {
+                            RoundedRectangle(cornerRadius: 15)
+                                .frame(width: geo.size.width * CGFloat(progress))
+                        }
+                )
+        }
+        .frame(height: 6)
+    }
+}

+ 0 - 15
TrioTests/CoreDataTests/GlucoseStorageTests.swift

@@ -127,21 +127,6 @@ import Testing
         #expect(notUploadedEntries[0].glucose == 160, "Glucose value should match")
         #expect(notUploadedEntries[0].glucose == 160, "Glucose value should match")
     }
     }
 
 
-    @Test("Get manual glucose not yet uploaded to Nightscout") func testGetManualGlucoseNotYetUploadedToNightscout() async throws {
-        // Given
-        storage.addManualGlucose(glucose: 180)
-
-        // When
-        let notUploadedEntries = try await storage.getManualGlucoseNotYetUploadedToNightscout()
-
-        // Then
-        #expect(!notUploadedEntries.isEmpty, "Should have manual entries not uploaded to NS")
-        let entry = notUploadedEntries[0]
-        #expect(entry.glucose == "180", "Glucose value should match")
-        #expect(entry.glucoseType == "Manual", "Type should be mbg for manual entries")
-        #expect(entry.eventType == .capillaryGlucose, "Type should be capillaryGlucose")
-    }
-
     @Test(
     @Test(
         "Test glucose alarms",
         "Test glucose alarms",
         .enabled(if: false, "Flaky test, disabled while investigating")
         .enabled(if: false, "Flaky test, disabled while investigating")

+ 67 - 0
TrioTests/GlucoseSmoothingTests.swift

@@ -217,6 +217,73 @@ import Testing
         }
         }
     }
     }
 
 
+    // MARK: - fetchGlucose Window Tests
+
+    @Test(
+        "fetchGlucose retains the most recent 350 readings (not the oldest) when 24h holds more than 350"
+    ) func testFetchGlucoseKeepsMostRecentWhenOverLimit() async throws {
+        // GIVEN: 360 readings within the last 24h (3 min spacing => ~18h span).
+        // Each reading carries a unique glucose value so we can verify which subset survives the limit.
+        let count = 360
+        let values: [Int16] = (0 ..< count).map { Int16(100 + $0) }
+        await createGlucoseSequence(values: values, interval: 3 * 60, isManual: false)
+
+        // WHEN
+        let objectIDs = try await fetchGlucoseManager.fetchGlucose(context: testContext)
+
+        // THEN
+        #expect(objectIDs.count == 350, "fetchGlucose should respect the 350 limit, got \(objectIDs.count).")
+
+        await testContext.perform {
+            let fetched = objectIDs.compactMap { self.testContext.object(with: $0) as? GlucoseStored }
+            #expect(fetched.count == 350, "All returned object IDs must resolve to GlucoseStored instances.")
+
+            // Returned order must be oldest-first (chronological) — the smoother walks the array this way.
+            let dates = fetched.compactMap(\.date)
+            #expect(dates == dates.sorted(), "fetchGlucose must return readings in chronological (ascending) order.")
+
+            // The most recent reading (current BG) must be the LAST element after the chronological reverse.
+            #expect(
+                fetched.last?.glucose == Int16(100 + count - 1),
+                "Most recent reading (current BG) must be retained after the 350-limit truncation."
+            )
+
+            // The oldest 10 readings must be dropped — verify the limit cut from the OLD end, not the recent end.
+            let returnedGlucoseValues = Set(fetched.map(\.glucose))
+            #expect(
+                !returnedGlucoseValues.contains(Int16(100)),
+                "Oldest reading must be excluded by the limit (truncation should cut old, not recent)."
+            )
+            #expect(
+                returnedGlucoseValues.contains(Int16(100 + count - 1)),
+                "Newest reading must be included after truncation."
+            )
+        }
+    }
+
+    @Test(
+        "Exponential smoothing writes a smoothed value for the current BG when 24h holds more than 350 readings"
+    ) func testExponentialSmoothingCoversCurrentBGAboveLimit() async throws {
+        // GIVEN: 360 contiguous CGM readings within the last 24h (3 min spacing, no gaps).
+        let count = 360
+        let values: [Int16] = (0 ..< count).map { _ in Int16(120) }
+        await createGlucoseSequence(values: values, interval: 3 * 60, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN: the most recent reading must have received a smoothed value.
+        // Regression test for the bug where ascending+fetchLimit kept the OLDEST 350 readings,
+        // so the current BG fell outside the smoothing window and was never written.
+        let ascending = try await fetchAndSortGlucose()
+        #expect(ascending.count == count)
+
+        #expect(
+            ascending.last?.smoothedGlucose != nil,
+            "Most recent reading (current BG) must receive a smoothed value when over the 350-row limit."
+        )
+    }
+
     // MARK: - OpenAPS Glucose Selection Tests
     // MARK: - OpenAPS Glucose Selection Tests
 
 
     @Test("Algorithm uses smoothed glucose when enabled") func testAlgorithmUsesSmoothedGlucose() async throws {
     @Test("Algorithm uses smoothed glucose when enabled") func testAlgorithmUsesSmoothedGlucose() async throws {

+ 583 - 0
TrioTests/TidepoolTherapySettingsTests.swift

@@ -0,0 +1,583 @@
+import CryptoKit
+import HealthKit
+import LoopKit
+import Testing
+import TidepoolKit
+
+@testable import TidepoolServiceKit
+@testable import Trio
+
+// Both Trio and TidepoolServiceKit define mgPerDL,
+// causing ambiguity. Use HealthKit's native API to avoid the conflict.
+private let mgPerDL = HKUnit.gramUnit(with: .milli).unitDivided(by: HKUnit.literUnit(with: .deci))
+private let mmolPerL = HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter())
+
+// MARK: - StoredSettings → Tidepool Datum Tests
+
+/// Tests that verify Trio's StoredSettings correctly converts to Tidepool's pumpSettings datum.
+/// These test the REAL TidepoolServiceKit conversion code path.
+@Suite("StoredSettings Tidepool Format Tests") struct StoredSettingsTidepoolFormatTests {
+    private static let encoder: JSONEncoder = {
+        let encoder = JSONEncoder.tidepool
+        encoder.outputFormatting.insert(.prettyPrinted)
+        encoder.outputFormatting.insert(.sortedKeys)
+        return encoder
+    }()
+
+    // MARK: - JSON Format
+
+    @Test("Pump settings JSON contains required fields") func pumpSettingsJSONFormat() {
+        let datum = StoredSettings.test.datumPumpSettings(for: "trio-user-123", hostIdentifier: "Trio", hostVersion: "0.6.0")
+        let data = try! Self.encoder.encode(datum)
+        let json = String(data: data, encoding: .utf8)!
+
+        let requiredFields = [
+            "\"type\" : \"pumpSettings\"",
+            "\"activeSchedule\" : \"Default\"",
+            "\"basalSchedules\"",
+            "\"bgTargets\"",
+            "\"carbRatios\"",
+            "\"insulinSensitivities\"",
+            "\"automatedDelivery\"",
+            "\"name\" : \"Trio\"",
+            "\"version\" : \"0.6.0\""
+        ]
+
+        for field in requiredFields {
+            #expect(json.contains(field), "Missing required field: \(field)")
+        }
+    }
+
+    @Test("Pump settings with minimal data") func pumpSettingsWithMinimalData() {
+        let datum = StoredSettings.minimal.datumPumpSettings(for: "test-user", hostIdentifier: "Trio", hostVersion: "0.6.0")
+        #expect(datum.activeScheduleName == "Default")
+        #expect(datum.origin?.name == "Trio")
+        #expect(datum.origin?.version == "0.6.0")
+    }
+
+    // MARK: - Schedule Naming
+
+    @Test("All schedules use 'Default' name") func scheduleNaming() {
+        let datum = StoredSettings.test.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.activeScheduleName == "Default")
+        #expect(datum.basalRateSchedules?.keys.count == 1)
+        #expect(datum.basalRateSchedules?["Default"] != nil)
+        #expect(datum.bloodGlucoseTargetSchedules?["Default"] != nil)
+        #expect(datum.carbohydrateRatioSchedules?["Default"] != nil)
+        #expect(datum.insulinSensitivitySchedules?["Default"] != nil)
+    }
+
+    // MARK: - Device Metadata
+
+    @Test("Pump device metadata is included") func pumpDeviceMetadata() {
+        let pumpDevice = HKDevice(
+            name: "Omnipod", manufacturer: "Insulet", model: "Dash",
+            hardwareVersion: "1.0", firmwareVersion: "2.9.0", softwareVersion: nil,
+            localIdentifier: "pod-123", udiDeviceIdentifier: nil
+        )
+
+        let settings = makeSettings(pumpDevice: pumpDevice)
+        let data = try! Self.encoder.encode(
+            settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+        )
+        let json = String(data: data, encoding: .utf8)!
+
+        #expect(json.contains("Omnipod"), "Missing pump device name")
+        #expect(json.contains("Insulet"), "Missing pump manufacturer")
+    }
+
+    @Test("CGM device metadata is included") func cgmDeviceMetadata() {
+        let cgmDevice = HKDevice(
+            name: "Dexcom G7", manufacturer: "Dexcom", model: "G7",
+            hardwareVersion: nil, firmwareVersion: "1.2.3", softwareVersion: "4.5.6",
+            localIdentifier: "CGM123", udiDeviceIdentifier: nil
+        )
+
+        let settings = makeSettings(cgmDevice: cgmDevice)
+        let datum = settings.datumCGMSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+        let data = try! Self.encoder.encode(datum)
+        let json = String(data: data, encoding: .utf8)!
+
+        #expect(json.contains("Dexcom G7"), "Missing CGM device name")
+        #expect(json.contains("Dexcom"), "Missing CGM manufacturer")
+    }
+
+    // MARK: - Suspend Threshold
+
+    @Test("Suspend threshold value is preserved") func suspendThreshold() {
+        let settings = makeSettings(
+            suspendThreshold: GlucoseThreshold(unit: mgPerDL, value: 70.0)
+        )
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.bloodGlucoseSafetyLimit == 70, "Suspend threshold value should match")
+    }
+
+    @Test("Suspend threshold in mg/dL passes through for mmol/L user") func suspendThresholdMmolLUser() {
+        // threshold_setting is always stored in mg/dL even when user displays mmol/L.
+        // The adapter creates GlucoseThreshold in mg/dL; TidepoolServiceKit converts internally.
+        let settings = makeSettings(
+            suspendThreshold: GlucoseThreshold(unit: mgPerDL, value: 70.0),
+            bgUnit: mmolPerL
+        )
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(
+            datum.bloodGlucoseSafetyLimit == 70,
+            "Threshold in mg/dL should pass through correctly regardless of display unit"
+        )
+    }
+
+    // MARK: - Max Basal / Max Bolus
+
+    @Test("Maximum basal and bolus values are preserved") func maximumValues() {
+        let settings = makeSettings(maxBasal: 30.0, maxBolus: 25.0)
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.basal?.rateMaximum?.value == 30.0, "Max basal should handle high values")
+        #expect(datum.bolus?.amountMaximum?.value == 25.0, "Max bolus should handle high values")
+    }
+
+    @Test("Minimum basal and bolus values are preserved") func minimumValues() {
+        let settings = makeSettings(maxBasal: 0.5, maxBolus: 1.0)
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.basal?.rateMaximum?.value == 0.5, "Should preserve low max basal")
+        #expect(datum.bolus?.amountMaximum?.value == 1.0, "Should preserve low max bolus")
+    }
+
+    // MARK: - Automated Delivery Flag
+
+    @Test("Automated delivery flag reflects dosing state") func automatedDeliveryFlag() {
+        let enabled = makeSettings(dosingEnabled: true)
+        let disabled = makeSettings(dosingEnabled: false)
+
+        let enabledDatum = enabled.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+        let disabledDatum = disabled.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(enabledDatum.automatedDelivery == true)
+        #expect(disabledDatum.automatedDelivery == false)
+    }
+
+    // MARK: - Unit Conversion
+
+    @Test("mmol/L values are converted to mg/dL by Tidepool") func mmolLUnitConversion() {
+        let targetSchedule = GlucoseRangeSchedule(
+            rangeSchedule: DailyQuantitySchedule(
+                unit: mmolPerL,
+                dailyItems: [RepeatingScheduleValue(
+                    startTime: 0,
+                    value: DoubleRange(minValue: 5.0, maxValue: 6.0)
+                )],
+                timeZone: .current
+            )!,
+            override: nil
+        )
+        let isfSchedule = InsulinSensitivitySchedule(
+            unit: mmolPerL,
+            dailyItems: [RepeatingScheduleValue(startTime: 0, value: 3.0)],
+            timeZone: .current
+        )
+
+        let settings = makeSettings(
+            glucoseTargetRangeSchedule: targetSchedule,
+            insulinSensitivitySchedule: isfSchedule,
+            bgUnit: mmolPerL
+        )
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        // Tidepool converts to mg/dL (5.0 mmol/L ≈ 90 mg/dL)
+        let target = datum.bloodGlucoseTargetSchedules?["Default"]?.first
+        #expect(abs((target?.low ?? 0) - 90) <= 1)
+        #expect(abs((target?.high ?? 0) - 108) <= 1)
+
+        let isf = datum.insulinSensitivitySchedules?["Default"]?.first
+        #expect(abs((isf?.amount ?? 0) - 54) <= 1)
+    }
+
+    // MARK: - Insulin Model
+
+    @Test("Insulin model preserves DIA and peak time") func insulinModel() {
+        let model = StoredInsulinModel(
+            modelType: .rapidAdult,
+            delay: .minutes(10),
+            actionDuration: .hours(8),
+            peakActivity: .minutes(65)
+        )
+        let settings = makeSettings(insulinModel: model)
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.insulinModel != nil, "Insulin model should be present")
+        #expect(datum.insulinModel?.actionDuration == .hours(8), "DIA should match user setting")
+        #expect(datum.insulinModel?.actionPeakOffset == .minutes(65), "Peak time should match user setting")
+    }
+
+    @Test("Fiasp insulin model maps correctly") func fiaspInsulinModel() {
+        let model = StoredInsulinModel(
+            modelType: .fiasp,
+            delay: .minutes(10),
+            actionDuration: .hours(6),
+            peakActivity: .minutes(55)
+        )
+        let settings = makeSettings(insulinModel: model)
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.insulinModel?.modelType == .fiasp, "Ultra-rapid should map to fiasp")
+        #expect(datum.insulinModel?.actionDuration == .hours(6))
+        #expect(datum.insulinModel?.actionPeakOffset == .minutes(55))
+    }
+
+    // MARK: - Helpers
+
+    private func makeSettings(
+        dosingEnabled: Bool = true,
+        glucoseTargetRangeSchedule: GlucoseRangeSchedule? = nil,
+        insulinSensitivitySchedule: InsulinSensitivitySchedule? = nil,
+        maxBasal: Double? = 5.0,
+        maxBolus: Double? = 10.0,
+        suspendThreshold: GlucoseThreshold? = nil,
+        insulinModel: StoredInsulinModel? = nil,
+        cgmDevice: HKDevice? = nil,
+        pumpDevice: HKDevice? = nil,
+        bgUnit: HKUnit = mgPerDL
+    ) -> StoredSettings {
+        let tz = TimeZone(secondsFromGMT: 0)!
+
+        let defaultTarget = GlucoseRangeSchedule(
+            rangeSchedule: DailyQuantitySchedule(
+                unit: mgPerDL,
+                dailyItems: [RepeatingScheduleValue(
+                    startTime: 0,
+                    value: DoubleRange(minValue: 100.0, maxValue: 110.0)
+                )],
+                timeZone: tz
+            )!,
+            override: nil
+        )
+
+        let defaultBasal = BasalRateSchedule(
+            dailyItems: [RepeatingScheduleValue(startTime: 0, value: 1.0)],
+            timeZone: tz
+        )!
+
+        let defaultISF = InsulinSensitivitySchedule(
+            unit: mgPerDL,
+            dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)],
+            timeZone: tz
+        )!
+
+        let defaultCarb = CarbRatioSchedule(
+            unit: .gram(),
+            dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)],
+            timeZone: tz
+        )!
+
+        return StoredSettings(
+            date: Date(),
+            controllerTimeZone: .current,
+            dosingEnabled: dosingEnabled,
+            glucoseTargetRangeSchedule: glucoseTargetRangeSchedule ?? defaultTarget,
+            preMealTargetRange: nil,
+            workoutTargetRange: nil,
+            overridePresets: nil,
+            scheduleOverride: nil,
+            preMealOverride: nil,
+            maximumBasalRatePerHour: maxBasal,
+            maximumBolus: maxBolus,
+            suspendThreshold: suspendThreshold,
+            insulinType: nil,
+            defaultRapidActingModel: insulinModel,
+            basalRateSchedule: defaultBasal,
+            insulinSensitivitySchedule: insulinSensitivitySchedule ?? defaultISF,
+            carbRatioSchedule: defaultCarb,
+            notificationSettings: nil,
+            controllerDevice: nil,
+            cgmDevice: cgmDevice,
+            pumpDevice: pumpDevice,
+            bloodGlucoseUnit: bgUnit,
+            syncIdentifier: UUID()
+        )
+    }
+}
+
+// MARK: - Conversion Logic Tests
+
+/// Tests for the conversion math used in BaseTidepoolManager.
+/// These verify the patterns used in the real adapter code.
+@Suite("BaseTidepoolManager Conversion Tests") struct BaseTidepoolManagerTests {
+    // MARK: - Basal Profile Conversion
+
+    @Test("Basal profile minutes convert to seconds") func basalProfileMinutesToSeconds() {
+        let entries: [(minutes: Int, expectedSeconds: TimeInterval)] = [
+            (0, 0),
+            (210, 12600),
+            (360, 21600),
+            (720, 43200),
+            (1125, 67500),
+            (1439, 86340)
+        ]
+
+        for (minutes, expected) in entries {
+            let startTime = TimeInterval(minutes * 60)
+            #expect(startTime == expected, "\(minutes) minutes should be \(expected) seconds")
+        }
+    }
+
+    @Test("Basal profile uses minutes field for start time") func basalProfileUsesMinutesField() {
+        let entries = [
+            BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 1.0),
+            BasalProfileEntry(start: "06:00:00", minutes: 360, rate: 1.5),
+            BasalProfileEntry(start: "12:00:00", minutes: 720, rate: 1.25)
+        ]
+
+        let items = entries.map { entry in
+            RepeatingScheduleValue(
+                startTime: TimeInterval(entry.minutes * 60),
+                value: Double(entry.rate)
+            )
+        }
+        let schedule = BasalRateSchedule(dailyItems: items, timeZone: .current)
+
+        #expect(schedule != nil)
+        #expect(schedule?.items[0].startTime == 0)
+        #expect(schedule?.items[1].startTime == 21600)
+        #expect(schedule?.items[2].startTime == 43200)
+    }
+
+    // MARK: - Carb Ratio Conversion
+
+    @Test("Carb ratio offset converts to seconds") func carbRatioOffsetToSeconds() {
+        let entries = [
+            CarbRatioEntry(start: "00:00", offset: 0, ratio: 15.0),
+            CarbRatioEntry(start: "06:00", offset: 360, ratio: 12.0),
+            CarbRatioEntry(start: "12:00", offset: 720, ratio: 10.0)
+        ]
+
+        let items = entries.map { entry in
+            RepeatingScheduleValue(
+                startTime: TimeInterval(entry.offset * 60),
+                value: Double(entry.ratio)
+            )
+        }
+
+        #expect(items[0].startTime == 0)
+        #expect(items[1].startTime == 21600)
+        #expect(items[2].startTime == 43200)
+    }
+
+    // MARK: - ISF Conversion
+
+    @Test("ISF offset converts to seconds") func insulinSensitivityOffsetToSeconds() {
+        let entries = [
+            InsulinSensitivityEntry(sensitivity: 50.0, offset: 0, start: "00:00"),
+            InsulinSensitivityEntry(sensitivity: 45.0, offset: 480, start: "08:00")
+        ]
+
+        let items = entries.map { entry in
+            RepeatingScheduleValue(
+                startTime: TimeInterval(entry.offset * 60),
+                value: Double(entry.sensitivity)
+            )
+        }
+
+        #expect(items[0].startTime == 0)
+        #expect(items[1].startTime == 28800, "480 min = 28800 sec")
+    }
+
+    // MARK: - BG Target Conversion
+
+    @Test("BG target offset converts to seconds") func bgTargetOffsetToSeconds() {
+        let entries = [
+            BGTargetEntry(low: 100, high: 110, start: "00:00", offset: 0),
+            BGTargetEntry(low: 110, high: 120, start: "22:00", offset: 1320)
+        ]
+
+        #expect(TimeInterval(entries[0].offset * 60) == 0)
+        #expect(TimeInterval(entries[1].offset * 60) == 79200, "1320 min = 79200 sec")
+    }
+
+    @Test("BG target low and high values are preserved") func bgTargetLowHighValues() {
+        let entry = BGTargetEntry(low: 90, high: 120, start: "00:00", offset: 0)
+        #expect(Double(entry.low) == 90)
+        #expect(Double(entry.high) == 120)
+    }
+
+    // MARK: - Insulin Model Conversion
+
+    @Test("Preset peak times match expected values when custom peak disabled") func presetPeakTimes() {
+        // When useCustomPeakTime is false, should use LoopKit preset defaults
+        let rapidAdultPeak = ExponentialInsulinModelPreset.rapidActingAdult.peakActivity
+        let fiaspPeak = ExponentialInsulinModelPreset.fiasp.peakActivity
+
+        #expect(rapidAdultPeak == .minutes(75), "rapidActingAdult preset peak should be 75 min")
+        #expect(fiaspPeak == .minutes(55), "fiasp preset peak should be 55 min")
+    }
+
+    @Test("Custom peak time range boundaries") func customPeakTimeRange() {
+        // insulinPeakTime picker: min 35, max 120, step 1 (minutes)
+        let minPeak: TimeInterval = .minutes(35)
+        let maxPeak: TimeInterval = .minutes(120)
+
+        #expect(minPeak == 2100, "35 minutes = 2100 seconds")
+        #expect(maxPeak == 7200, "120 minutes = 7200 seconds")
+    }
+
+    @Test("DIA range boundaries") func diaRange() {
+        // insulinActionCurve picker: min 5, max 10, step 0.5 (hours)
+        let minDIA: TimeInterval = .hours(5)
+        let maxDIA: TimeInterval = .hours(10)
+
+        #expect(minDIA == 18000, "5 hours = 18000 seconds")
+        #expect(maxDIA == 36000, "10 hours = 36000 seconds")
+    }
+
+    // MARK: - Content-Based Sync Identifier
+
+    @Test("Same settings produce the same sync identifier") func syncIdentifierDeterminism() {
+        let id1 = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true)
+        let id2 = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true)
+        #expect(id1 == id2, "Same settings should produce the same sync identifier")
+    }
+
+    @Test("Different settings produce different sync identifiers") func syncIdentifierChanges() {
+        let baseline = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true)
+        let changedBasal = computeTestSyncId(maxBasal: "6.0", maxBolus: "10.0", dosingEnabled: true)
+        let changedDosing = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: false)
+
+        #expect(baseline != changedBasal, "Different maxBasal should produce different ID")
+        #expect(baseline != changedDosing, "Different dosingEnabled should produce different ID")
+        #expect(changedBasal != changedDosing, "All three should be unique")
+    }
+
+    // MARK: - Helpers
+
+    /// Replicates the SHA-256 hash algorithm from BaseTidepoolManager.contentBasedSyncIdentifier
+    private func computeTestSyncId(maxBasal: String, maxBolus: String, dosingEnabled: Bool) -> UUID {
+        var hasher = SHA256()
+        hasher.update(data: Data("0:1.0".utf8)) // basal entry
+        hasher.update(data: Data("0:15".utf8)) // carb ratio
+        hasher.update(data: Data("0:50".utf8)) // ISF
+        hasher.update(data: Data("0:100:110".utf8)) // BG target
+        hasher.update(data: Data("maxBasal:\(maxBasal)".utf8))
+        hasher.update(data: Data("maxBolus:\(maxBolus)".utf8))
+        hasher.update(data: Data("threshold:100".utf8))
+        hasher.update(data: Data("dosingEnabled:\(dosingEnabled)".utf8))
+        let digest = hasher.finalize()
+        let bytes = Array(digest.prefix(16))
+        return UUID(uuid: (
+            bytes[0], bytes[1], bytes[2], bytes[3],
+            bytes[4], bytes[5], bytes[6], bytes[7],
+            bytes[8], bytes[9], bytes[10], bytes[11],
+            bytes[12], bytes[13], bytes[14], bytes[15]
+        ))
+    }
+}
+
+// MARK: - Test Fixtures
+
+private extension StoredSettings {
+    static var test: StoredSettings {
+        let tz = TimeZone(secondsFromGMT: 0)!
+
+        let pumpDevice = HKDevice(
+            name: "Omnipod", manufacturer: "Insulet", model: "Dash",
+            hardwareVersion: "1.0", firmwareVersion: "2.9.0", softwareVersion: nil,
+            localIdentifier: "pod-serial-123", udiDeviceIdentifier: nil
+        )
+
+        return StoredSettings(
+            date: Date(),
+            controllerTimeZone: TimeZone(identifier: "America/Los_Angeles")!,
+            dosingEnabled: true,
+            glucoseTargetRangeSchedule: GlucoseRangeSchedule(
+                rangeSchedule: DailyQuantitySchedule(
+                    unit: mgPerDL,
+                    dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0))],
+                    timeZone: tz
+                )!,
+                override: nil
+            ),
+            preMealTargetRange: nil,
+            workoutTargetRange: nil,
+            overridePresets: nil,
+            scheduleOverride: nil,
+            preMealOverride: nil,
+            maximumBasalRatePerHour: 5.0,
+            maximumBolus: 10.0,
+            suspendThreshold: nil,
+            insulinType: .humalog,
+            defaultRapidActingModel: nil,
+            basalRateSchedule: BasalRateSchedule(dailyItems: [
+                RepeatingScheduleValue(startTime: 0, value: 1.0),
+                RepeatingScheduleValue(startTime: 21600, value: 1.5),
+                RepeatingScheduleValue(startTime: 43200, value: 1.25),
+                RepeatingScheduleValue(startTime: 64800, value: 1.0)
+            ], timeZone: tz)!,
+            insulinSensitivitySchedule: InsulinSensitivitySchedule(
+                unit: mgPerDL,
+                dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)],
+                timeZone: tz
+            )!,
+            carbRatioSchedule: CarbRatioSchedule(
+                unit: .gram(),
+                dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)],
+                timeZone: tz
+            )!,
+            notificationSettings: nil,
+            controllerDevice: nil,
+            cgmDevice: nil,
+            pumpDevice: pumpDevice,
+            bloodGlucoseUnit: mgPerDL,
+            syncIdentifier: UUID()
+        )
+    }
+
+    static var minimal: StoredSettings {
+        let tz = TimeZone(secondsFromGMT: 0)!
+
+        return StoredSettings(
+            date: Date(),
+            controllerTimeZone: .current,
+            dosingEnabled: true,
+            glucoseTargetRangeSchedule: GlucoseRangeSchedule(
+                rangeSchedule: DailyQuantitySchedule(
+                    unit: mgPerDL,
+                    dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0))],
+                    timeZone: tz
+                )!,
+                override: nil
+            ),
+            preMealTargetRange: nil,
+            workoutTargetRange: nil,
+            overridePresets: nil,
+            scheduleOverride: nil,
+            preMealOverride: nil,
+            maximumBasalRatePerHour: nil,
+            maximumBolus: nil,
+            suspendThreshold: nil,
+            insulinType: nil,
+            defaultRapidActingModel: nil,
+            basalRateSchedule: BasalRateSchedule(
+                dailyItems: [RepeatingScheduleValue(startTime: 0, value: 1.0)],
+                timeZone: tz
+            )!,
+            insulinSensitivitySchedule: InsulinSensitivitySchedule(
+                unit: mgPerDL,
+                dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)],
+                timeZone: tz
+            )!,
+            carbRatioSchedule: CarbRatioSchedule(
+                unit: .gram(),
+                dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)],
+                timeZone: tz
+            )!,
+            notificationSettings: nil,
+            controllerDevice: nil,
+            cgmDevice: nil,
+            pumpDevice: nil,
+            bloodGlucoseUnit: mgPerDL,
+            syncIdentifier: UUID()
+        )
+    }
+}

+ 1 - 1
scripts/define_common_trio.sh

@@ -31,6 +31,6 @@ TRIO_PROJECTS=( \
     loopandlearn:RileyLinkKit:dev \
     loopandlearn:RileyLinkKit:dev \
     loopandlearn:TidepoolService:dev \
     loopandlearn:TidepoolService:dev \
     loopandlearn:DanaKit:dev \
     loopandlearn:DanaKit:dev \
-    loopandlearn:EversenseKit:dev \
     loopandlearn:MedtrumKit:dev \
     loopandlearn:MedtrumKit:dev \
+    loopandlearn:EversenseKit:dev \
 )
 )