فهرست منبع

Merge branch 'dev' into feature/fix-ios-app-notification-duplicates-and-add-snooze-options

Deniz Cengiz 1 ماه پیش
والد
کامیت
4f2fdfc2cd
71فایلهای تغییر یافته به همراه9214 افزوده شده و 1621 حذف شده
  1. 1 1
      .github/workflows/add_identifiers.yml
  2. 2 2
      .github/workflows/build_trio.yml
  3. 2 2
      .github/workflows/create_certs.yml
  4. 4 4
      .github/workflows/unit_tests.yml
  5. 1 1
      .github/workflows/validate_secrets.yml
  6. 1 1
      CGMBLEKit
  7. 1 1
      Config.xcconfig
  8. 1 1
      DanaKit
  9. 1 1
      G7SensorKit
  10. 1 1
      LibreTransmitter
  11. 1 1
      LoopKit
  12. 1 1
      MinimedKit
  13. 2 1
      Model/Classes+Properties/GlucoseStored+CoreDataProperties.swift
  14. 5 0
      Model/Helper/PumpEvent+helper.swift
  15. 2 1
      Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
  16. 1 1
      OmniBLE
  17. 1 1
      OmniKit
  18. 1 1
      RileyLinkKit
  19. 1 1
      TidepoolService
  20. 32 12
      Trio.xcodeproj/project.pbxproj
  21. 0 1
      Trio/Resources/json/defaults/freeaps/freeaps_settings.json
  22. 7 2
      Trio/Sources/APS/APSManager.swift
  23. 212 40
      Trio/Sources/APS/FetchGlucoseManager.swift
  24. 108 12
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  25. 114 59
      Trio/Sources/APS/Storage/CarbsStorage.swift
  26. 3 0
      Trio/Sources/Helpers/PropertyPersistentFlags.swift
  27. 2 1
      Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift
  28. 0 172
      Trio/Sources/Helpers/SavitzkyGolayFilter.swift
  29. 7618 1061
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  30. 86 0
      Trio/Sources/Models/AlgorithmGlucose.swift
  31. 0 12
      Trio/Sources/Models/BloodGlucose.swift
  32. 22 0
      Trio/Sources/Models/BolusDisplayThreshold.swift
  33. 6 7
      Trio/Sources/Models/DecimalPickerSettings.swift
  34. 1 5
      Trio/Sources/Models/TrioSettings.swift
  35. 24 5
      Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift
  36. 4 4
      Trio/Sources/Modules/History/View/CarbEntryEditorView.swift
  37. 21 3
      Trio/Sources/Modules/History/View/HistoryRootView.swift
  38. 3 0
      Trio/Sources/Modules/Home/HomeStateModel.swift
  39. 27 37
      Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift
  40. 6 3
      Trio/Sources/Modules/Home/View/Chart/ChartElements/InsulinView.swift
  41. 14 3
      Trio/Sources/Modules/Home/View/Chart/ChartElements/SelectionPopoverView.swift
  42. 4 2
      Trio/Sources/Modules/Home/View/Chart/MainChartView.swift
  43. 7 4
      Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
  44. 0 4
      Trio/Sources/Modules/MealSettings/MealSettingsStateModel.swift
  45. 14 43
      Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift
  46. 1 2
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  47. 2 2
      Trio/Sources/Modules/Onboarding/View/OnboardingView+AlgorithmUtil.swift
  48. 4 4
      Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift
  49. 7 7
      Trio/Sources/Modules/Settings/SettingItems.swift
  50. 1 8
      Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift
  51. 4 4
      Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift
  52. 39 17
      Trio/Sources/Modules/Treatments/View/ForecastChart.swift
  53. 4 4
      Trio/Sources/Modules/Treatments/View/MealPreset/AddMealPresetView.swift
  54. 32 10
      Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift
  55. 2 0
      Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift
  56. 42 0
      Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift
  57. 12 7
      Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift
  58. 1 0
      Trio/Sources/Services/OnboardingManager/OnboardingManager.swift
  59. 9 3
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  60. 1 1
      Trio/Sources/Shortcuts/Bolus/BolusIntent.swift
  61. 1 1
      Trio/Sources/Shortcuts/Bolus/BolusIntentRequest.swift
  62. 59 17
      Trio/Sources/Shortcuts/Carbs/AddCarbPresetIntent.swift
  63. 12 14
      Trio/Sources/Shortcuts/Carbs/CarbPresetIntentRequest.swift
  64. 0 2
      Trio/Sources/Views/SettingInputSection.swift
  65. 234 0
      TrioTests/CoreDataTests/CarbsStorageTests.swift
  66. 315 0
      TrioTests/GlucoseSmoothingTests.swift
  67. 2 2
      TrioTests/JSONImporterTests.swift
  68. 15 0
      TrioTests/Mocks/MockTDDStorage.swift
  69. 1 1
      dexcom-share-client-swift
  70. 36 0
      scripts/define_common_trio.sh
  71. 13 0
      scripts/update_submodules_trio.sh

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

@@ -12,7 +12,7 @@ jobs:
   identifiers:
     name: Add Identifiers
     needs: validate
-    runs-on: macos-15
+    runs-on: macos-26
     steps:
       # Checks-out the repo
       - name: Checkout Repo

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

@@ -165,7 +165,7 @@ jobs:
   build:
     name: Build
     needs: [check_certs, check_status]
-    runs-on: macos-15
+    runs-on: macos-26
     permissions:
       contents: write
     if:
@@ -175,7 +175,7 @@ jobs:
         (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' )
     steps:
       - name: Select Xcode version
-        run: "sudo xcode-select --switch /Applications/Xcode_16.4.app/Contents/Developer"
+        run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer"
       
       - name: Checkout Repo for building
         uses: actions/checkout@v4

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

@@ -21,7 +21,7 @@ jobs:
   create_certs:
     name: Certificates
     needs: validate
-    runs-on: macos-15
+    runs-on: macos-26
     outputs:
       new_certificate_needed: ${{ steps.set_output.outputs.new_certificate_needed }}
 
@@ -90,7 +90,7 @@ jobs:
   nuke_certs:
       name: Nuke certificates
       needs: [validate, create_certs]
-      runs-on: macos-15
+      runs-on: macos-26
       if: ${{ (needs.create_certs.outputs.new_certificate_needed == 'true' && vars.ENABLE_NUKE_CERTS == 'true') || vars.FORCE_NUKE_CERTS == 'true' }}
       steps:
         - name: Output from step id 'check_certs'

+ 4 - 4
.github/workflows/unit_tests.yml

@@ -23,12 +23,12 @@ on:
 jobs:
   test:
     name: Run Unit Tests
-    runs-on: macos-15
+    runs-on: macos-26
     if: github.repository_owner == 'nightscout'
 
     steps:
       - name: Select Xcode version
-        run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer
+        run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
 
       - name: Checkout code
         uses: actions/checkout@v4
@@ -64,7 +64,7 @@ jobs:
           time xcodebuild build-for-testing \
             -workspace Trio.xcworkspace \
             -scheme "Trio Tests" \
-            -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' \
+            -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
 
       - name: Check for uncommitted changes
         run: |
@@ -107,7 +107,7 @@ jobs:
           time xcodebuild test-without-building \
             -workspace Trio.xcworkspace \
             -scheme "Trio Tests" \
-            -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' \
+            -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
             $([ "$ENABLE_PARALLEL_TESTING" = "true" ] && echo "-parallel-testing-enabled YES") \
             2>&1 | tee xcodebuild.log
 

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

@@ -105,7 +105,7 @@ jobs:
   validate-fastlane-secrets:
     name: Fastlane
     needs: [validate-access-token]
-    runs-on: macos-15
+    runs-on: macos-26
     env:
       GH_PAT: ${{ secrets.GH_PAT }}
       GH_TOKEN: ${{ secrets.GH_PAT }}

+ 1 - 1
CGMBLEKit

@@ -1 +1 @@
-Subproject commit a442ea0a21078e82264176a89617d2f9a3a6f36d
+Subproject commit 134396b96170d410b18f9699b92409bc6d35aedb

+ 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
 APP_VERSION = 0.6.0
-APP_DEV_VERSION = 0.6.0.46
+APP_DEV_VERSION = 0.6.0.61
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 1 - 1
DanaKit

@@ -1 +1 @@
-Subproject commit bad8fad9ccf980f4a3384b2454a7cd41abe69464
+Subproject commit 3970b2aadc55044c851130567879fd0ae3ade6cc

+ 1 - 1
G7SensorKit

@@ -1 +1 @@
-Subproject commit ee064ddcc1c13e0050ee56d0eec38a6bdc0d3c76
+Subproject commit 0c093050084b63d7af0dc99307dba09239eb3180

+ 1 - 1
LibreTransmitter

@@ -1 +1 @@
-Subproject commit 38cc483f3d7716735ceee6e57b6ed4dd68eaf1d0
+Subproject commit 12dec3ce191afe761fd68a70172ef75638319e20

+ 1 - 1
LoopKit

@@ -1 +1 @@
-Subproject commit edd4e6037d263ef32dd8dd4c0d699c5429097373
+Subproject commit 9c09a6fea98e2638d76d610ba097c4fae14ca220

+ 1 - 1
MinimedKit

@@ -1 +1 @@
-Subproject commit d52c0f8f1fe615760794fdac233ba78657449870
+Subproject commit 942996e3f53c4875553c9827aeee1799a8dbf434

+ 2 - 1
Model/Classes+Properties/GlucoseStored+CoreDataProperties.swift

@@ -11,9 +11,10 @@ public extension GlucoseStored {
     @NSManaged var glucose: Int16
     @NSManaged var id: UUID?
     @NSManaged var isManual: Bool
-    @NSManaged var isUploadedToNS: Bool
     @NSManaged var isUploadedToHealth: Bool
+    @NSManaged var isUploadedToNS: Bool
     @NSManaged var isUploadedToTidepool: Bool
+    @NSManaged var smoothedGlucose: NSDecimalNumber?
 }
 
 extension GlucoseStored: Identifiable {}

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

@@ -84,6 +84,11 @@ extension NSPredicate {
         return NSPredicate(format: "timestamp >= %@", date as NSDate)
     }
 
+    static var pumpHistoryLast48h: NSPredicate {
+        let date = Date() - TimeInterval(hours: 48)
+        return NSPredicate(format: "timestamp >= %@", date as NSDate)
+    }
+
     static var pumpHistoryLast24h: NSPredicate {
         let date = Date.oneDayAgo
         return NSPredicate(format: "timestamp >= %@", date as NSDate)

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

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="25B78" 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">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@@ -78,6 +78,7 @@
         <attribute name="isUploadedToHealth" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToNS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToTidepool" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
+        <attribute name="smoothedGlucose" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
         <fetchIndex name="byDate">
             <fetchIndexElement property="date" type="Binary" order="ascending"/>
         </fetchIndex>

+ 1 - 1
OmniBLE

@@ -1 +1 @@
-Subproject commit ffec85de22d979e4bee6535c374ab72c692e101b
+Subproject commit 3782c584fe800116b6e60754e3be3cd818e033ee

+ 1 - 1
OmniKit

@@ -1 +1 @@
-Subproject commit 64731f0b31d61cae14d00528a9c2bf78ea6da9a6
+Subproject commit 1446be89bfab23ac021d3e22f03b34bda8ce30cf

+ 1 - 1
RileyLinkKit

@@ -1 +1 @@
-Subproject commit 83b211a442672612e1790c2f0d393aeb23600b5f
+Subproject commit 8dad76d15295e13e091be74f6f47dbca5f0eb022

+ 1 - 1
TidepoolService

@@ -1 +1 @@
-Subproject commit b4fb9a0672f6e4a7bfed619fc3193b03a8a2ab79
+Subproject commit a10f9d3ba097daae85de61d4a5bca063f34d64dc

+ 32 - 12
Trio.xcodeproj/project.pbxproj

@@ -260,6 +260,7 @@
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
 		3BF85FE32E427312000D7351 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF85FE12E427312000D7351 /* IOBService.swift */; };
 		3E28F2AB2EB5337F00FB9EEB /* ConnectIQ in Frameworks */ = {isa = PBXBuildFile; productRef = 3E28F2AA2EB5337F00FB9EEB /* ConnectIQ */; };
+		3E62C7822F54CC1B00433237 /* BolusDisplayThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
 		45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; };
 		491D6FBD2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */; };
@@ -503,7 +504,6 @@
 		CE95BF622BA7715900DC3DE3 /* MockKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3818AA4F274C26A300843DB3 /* MockKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		CE95BF632BA771BE00DC3DE3 /* LoopTestingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3818AA70274C278200843DB3 /* LoopTestingKit.framework */; };
 		CE95BF642BA771BE00DC3DE3 /* LoopTestingKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3818AA70274C278200843DB3 /* LoopTestingKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
-		CEA4F62329BE10F70011ADF7 /* SavitzkyGolayFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */; };
 		CEB434E328B8F9DB00B70274 /* BluetoothStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB434E228B8F9DB00B70274 /* BluetoothStateManager.swift */; };
 		CEB434E528B8FF5D00B70274 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB434E428B8FF5D00B70274 /* UIColor.swift */; };
 		CEB434E728B9053300B70274 /* LoopUIColorPalette+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB434E628B9053300B70274 /* LoopUIColorPalette+Default.swift */; };
@@ -623,6 +623,7 @@
 		DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */; };
 		DD9ECB722CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */; };
 		DD9ECB742CA9A0C300AA7C45 /* RemoteControlConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */; };
+		DDA40BBA2F4DB18800257798 /* AlgorithmGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */; };
 		DDA6E2502D22187500C2988C /* ChartLegendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E24F2D22187500C2988C /* ChartLegendView.swift */; };
 		DDA6E2852D2361F800C2988C /* LoopStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E2842D2361F800C2988C /* LoopStatusView.swift */; };
 		DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E31F2D258E0500C2988C /* OverrideHelpView.swift */; };
@@ -652,6 +653,10 @@
 		DDD78A912DC4064800AC63F3 /* carbhistory.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78A902DC4064800AC63F3 /* carbhistory.json */; };
 		DDD78AD92DC421B500AC63F3 /* enacted.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78AD72DC421B500AC63F3 /* enacted.json */; };
 		DDD78ADA2DC421B500AC63F3 /* suggested.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78AD82DC421B500AC63F3 /* suggested.json */; };
+		DDD7C8C12F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */; };
+		DDD7C8C22F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */; };
+		DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */; };
+		DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */; };
 		DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */; };
 		DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */; };
 		DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */; };
@@ -668,8 +673,6 @@
 		DDE179612C910127003CDDB7 /* StatsData+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179412C910127003CDDB7 /* StatsData+CoreDataProperties.swift */; };
 		DDE179622C910127003CDDB7 /* Forecast+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179422C910127003CDDB7 /* Forecast+CoreDataClass.swift */; };
 		DDE179632C910127003CDDB7 /* Forecast+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179432C910127003CDDB7 /* Forecast+CoreDataProperties.swift */; };
-		DDE179642C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179442C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift */; };
-		DDE179652C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179452C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift */; };
 		DDE179662C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179462C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift */; };
 		DDE179672C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179472C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift */; };
 		DDE179682C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179482C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift */; };
@@ -1090,6 +1093,7 @@
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
 		3BF85FE12E427312000D7351 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = "<group>"; };
+		3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDisplayThreshold.swift; sourceTree = "<group>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
 		3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = "<group>"; };
 		42369F66CF91F30624C0B3A6 /* BasalProfileEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorProvider.swift; sourceTree = "<group>"; };
@@ -1335,7 +1339,6 @@
 		CE95BF4A2BA5CED700DC3DE3 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CE95BF562BA5F5FE00DC3DE3 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = "<group>"; };
 		CE95BF592BA62E4A00DC3DE3 /* PluginSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginSource.swift; sourceTree = "<group>"; };
-		CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavitzkyGolayFilter.swift; sourceTree = "<group>"; };
 		CEB434DB28B8F5B900B70274 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEB434DE28B8F5C400B70274 /* OmniBLE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OmniBLE.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEB434E228B8F9DB00B70274 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = "<group>"; };
@@ -1457,6 +1460,7 @@
 		DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigProvider.swift; sourceTree = "<group>"; };
 		DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigDataFlow.swift; sourceTree = "<group>"; };
 		DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfig.swift; sourceTree = "<group>"; };
+		DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlgorithmGlucose.swift; sourceTree = "<group>"; };
 		DDA6E24F2D22187500C2988C /* ChartLegendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartLegendView.swift; sourceTree = "<group>"; };
 		DDA6E2842D2361F800C2988C /* LoopStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStatusView.swift; sourceTree = "<group>"; };
 		DDA6E31F2D258E0500C2988C /* OverrideHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideHelpView.swift; sourceTree = "<group>"; };
@@ -1489,6 +1493,10 @@
 		DDD78A902DC4064800AC63F3 /* carbhistory.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = carbhistory.json; sourceTree = "<group>"; };
 		DDD78AD72DC421B500AC63F3 /* enacted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = enacted.json; sourceTree = "<group>"; };
 		DDD78AD82DC421B500AC63F3 /* suggested.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = suggested.json; sourceTree = "<group>"; };
+		DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataClass.swift"; sourceTree = "<group>"; };
+		DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
+		DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSmoothingTests.swift; sourceTree = "<group>"; };
+		DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTDDStorage.swift; sourceTree = "<group>"; };
 		DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopStatRecord+CoreDataClass.swift"; sourceTree = "<group>"; };
@@ -1505,8 +1513,6 @@
 		DDE179412C910127003CDDB7 /* StatsData+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatsData+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179422C910127003CDDB7 /* Forecast+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Forecast+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179432C910127003CDDB7 /* Forecast+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Forecast+CoreDataProperties.swift"; sourceTree = "<group>"; };
-		DDE179442C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataClass.swift"; sourceTree = "<group>"; };
-		DDE179452C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179462C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenAPS_Battery+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179472C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenAPS_Battery+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179482C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempBasalStored+CoreDataClass.swift"; sourceTree = "<group>"; };
@@ -2369,6 +2375,8 @@
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */,
+				3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */,
 				DD3D60302F0377350021A33B /* ExportSetting.swift */,
 				DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */,
 				DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */,
@@ -2463,7 +2471,6 @@
 				3811DEE325CA063400A708ED /* PropertyWrappers */,
 				3811DE5525C9D4D500A708ED /* Publisher.swift */,
 				DD6B7CB12C7B6F0800B75029 /* Rounding.swift */,
-				CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */,
 				38E98A3625F5509500C0CED0 /* String+Extensions.swift */,
 				49239B422EEA27AD00469145 /* TempTargetCalculations.swift */,
 				DDCAE8322D78D49C00B1BB51 /* TherapySettingsUtil.swift */,
@@ -2608,6 +2615,8 @@
 		38FCF3EE25E9028E0078B0D1 /* TrioTests */ = {
 			isa = PBXGroup;
 			children = (
+				DDDD0FFD2F4E231600F9C645 /* Mocks */,
+				DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */,
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
@@ -3577,9 +3586,19 @@
 			path = View;
 			sourceTree = "<group>";
 		};
+		DDDD0FFD2F4E231600F9C645 /* Mocks */ = {
+			isa = PBXGroup;
+			children = (
+				DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */,
+			);
+			path = Mocks;
+			sourceTree = "<group>";
+		};
 		DDE179112C9100FA003CDDB7 /* Classes+Properties */ = {
 			isa = PBXGroup;
 			children = (
+				DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */,
+				DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */,
 				BD4D738B2D15A4080052227B /* TDDStored+CoreDataClass.swift */,
 				BD4D738C2D15A4080052227B /* TDDStored+CoreDataProperties.swift */,
 				DDE179362C910127003CDDB7 /* BolusStored+CoreDataClass.swift */,
@@ -3594,8 +3613,6 @@
 				DDE179432C910127003CDDB7 /* Forecast+CoreDataProperties.swift */,
 				DDE179382C910127003CDDB7 /* ForecastValue+CoreDataClass.swift */,
 				DDE179392C910127003CDDB7 /* ForecastValue+CoreDataProperties.swift */,
-				DDE179442C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift */,
-				DDE179452C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift */,
 				DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */,
 				DDE179352C910127003CDDB7 /* LoopStatRecord+CoreDataProperties.swift */,
 				DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */,
@@ -4147,6 +4164,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				DDA40BBA2F4DB18800257798 /* AlgorithmGlucose.swift in Sources */,
 				DD5DC9F12CF3D97C00AB8703 /* AdjustmentsStateModel+Overrides.swift in Sources */,
 				3811DE2325C9D48300A708ED /* MainDataFlow.swift in Sources */,
 				C2A0A42F2CE03131003B98E8 /* ConstantValues.swift in Sources */,
@@ -4175,7 +4193,6 @@
 				DD98ACC02D71013200C0778F /* StatChartUtils.swift in Sources */,
 				BD47FDD92D8B657D0043966B /* InsulinSensitivityStepView.swift in Sources */,
 				3862CC2E2743F9F700BF832C /* CalendarManager.swift in Sources */,
-				CEA4F62329BE10F70011ADF7 /* SavitzkyGolayFilter.swift in Sources */,
 				38B4F3C325E2A20B00E76A18 /* PumpSetupView.swift in Sources */,
 				38E4453C274E411700EC9A94 /* Disk+Codable.swift in Sources */,
 				58D08B322C8DF88900AA37D3 /* DummyCharts.swift in Sources */,
@@ -4360,6 +4377,7 @@
 				CEE9A6592BBB418300EB5194 /* CalibrationsDataFlow.swift in Sources */,
 				3811DE3525C9D49500A708ED /* HomeRootView.swift in Sources */,
 				38E98A2925F52C9300C0CED0 /* Error+Extensions.swift in Sources */,
+				3E62C7822F54CC1B00433237 /* BolusDisplayThreshold.swift in Sources */,
 				38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */,
 				3811DE6125C9D4D500A708ED /* ViewModifiers.swift in Sources */,
 				3811DEAC25C9D88300A708ED /* NightscoutManager.swift in Sources */,
@@ -4516,6 +4534,8 @@
 				FA630397F76B582C8D8681A7 /* BasalProfileEditorProvider.swift in Sources */,
 				DD1745172C54389F00211FAC /* FeatureSettingsView.swift in Sources */,
 				DD3D60312F0377350021A33B /* ExportSetting.swift in Sources */,
+				DDD7C8C12F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift in Sources */,
+				DDD7C8C22F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift in Sources */,
 				DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */,
 				63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */,
 				38FEF3FA2737E42000574A46 /* BaseStateModel.swift in Sources */,
@@ -4711,8 +4731,6 @@
 				DDE179612C910127003CDDB7 /* StatsData+CoreDataProperties.swift in Sources */,
 				DDE179622C910127003CDDB7 /* Forecast+CoreDataClass.swift in Sources */,
 				DDE179632C910127003CDDB7 /* Forecast+CoreDataProperties.swift in Sources */,
-				DDE179642C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift in Sources */,
-				DDE179652C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift in Sources */,
 				BDC531142D10611D00088832 /* AddContactImageSheet.swift in Sources */,
 				DDE179662C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift in Sources */,
 				DDE179672C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift in Sources */,
@@ -4747,12 +4765,14 @@
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
+				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,
 				BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */,
 				DDC6CA6D2DD90A2A0060EE25 /* LocalizationTests.swift in Sources */,
 				3B997DCF2DC00A3A006B6BB2 /* JSONImporterTests.swift in Sources */,
 				BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */,
+				DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */,
 				BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */,
 				3BAAE60C2DE7766C0049589B /* DynamicISFEnableTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,

+ 0 - 1
Trio/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -21,7 +21,6 @@
   "showCarbsRequiredBadge" : true,
   "useFPUconversion" : true,
   "individualAdjustmentFactor" : 0.5,
-  "timeCap" : 8,
   "minuteInterval" : 30,
   "delay" : 60,
   "useAppleHealth" : false,

+ 7 - 2
Trio/Sources/APS/APSManager.swift

@@ -405,7 +405,7 @@ final class BaseAPSManager: APSManager, Injectable {
         guard let autosense = await storage.retrieveAsync(OpenAPS.Settings.autosense, as: Autosens.self),
               (autosense.timestamp ?? .distantPast).addingTimeInterval(30.minutes.timeInterval) > Date()
         else {
-            let result = try await openAPS.autosense()
+            let result = try await openAPS.autosense(shouldSmoothGlucose: settingsManager.settings.smoothGlucose)
             return result != nil
         }
 
@@ -476,7 +476,11 @@ final class BaseAPSManager: APSManager, Injectable {
 
             _ = try await autosenseResult
             try await openAPS.createProfiles()
-            let determination = try await openAPS.determineBasal(currentTemp: await currentTemp, clock: now)
+            let determination = try await openAPS.determineBasal(
+                currentTemp: await currentTemp,
+                shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
+                clock: now
+            )
             iobFileDidUpdate.send(())
 
             guard isValidGlucoseData else {
@@ -520,6 +524,7 @@ final class BaseAPSManager: APSManager, Injectable {
             let temp = try await fetchCurrentTempBasal(date: Date.now)
             return try await openAPS.determineBasal(
                 currentTemp: temp,
+                shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
                 clock: Date(),
                 simulatedCarbsAmount: simulatedCarbsAmount,
                 simulatedBolusAmount: simulatedBolusAmount,

+ 212 - 40
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -1,4 +1,5 @@
 import Combine
+import CoreData
 import Foundation
 import HealthKit
 import LoopKit
@@ -29,6 +30,7 @@ extension FetchGlucoseManager {
 final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     private let processQueue = DispatchQueue(label: "BaseGlucoseManager.processQueue")
 
+    @Injected() var broadcaster: Broadcaster!
     @Injected() var glucoseStorage: GlucoseStorage!
     @Injected() var nightscoutManager: NightscoutManager!
     @Injected() var tidepoolService: TidepoolManager!
@@ -66,6 +68,8 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         return cgmManager.shouldSyncToRemoteService
     }
 
+    var shouldSmoothGlucose: Bool = false
+
     init(resolver: Resolver) {
         injectServices(resolver)
         // init at the start of the app
@@ -76,6 +80,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             cgmGlucoseSourceType: settingsManager.settings.cgm,
             cgmGlucosePluginId: settingsManager.settings.cgmPluginIdentifier
         )
+        shouldSmoothGlucose = settingsManager.settings.smoothGlucose
         subscribe()
     }
 
@@ -117,6 +122,8 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             .store(in: &lifetime)
         timer.fire()
         timer.resume()
+
+        broadcaster.register(SettingsObserver.self, observer: self)
     }
 
     /// Store new glucose readings from the CGM manager
@@ -197,7 +204,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         if let manager = newManager {
             cgmManager = manager
             removeCalibrations()
-//            glucoseSource = nil
         } else if self.cgmGlucoseSourceType == .plugin, cgmManager == nil, let rawCGMManager = rawCGMManager {
             cgmManager = cgmManagerFromRawValue(rawCGMManager)
             updateManagerUnits(cgmManager)
@@ -234,38 +240,35 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         return Manager.init(rawState: rawState)
     }
 
-    private func fetchGlucose() async throws -> [GlucoseStored]? {
-        try await CoreDataStack.shared.fetchEntitiesAsync(
+    func fetchGlucose(context: NSManagedObjectContext) async throws -> [NSManagedObjectID] {
+        // Compound predicate: time window + non-manual + valid date
+        let timePredicate = NSPredicate.predicateForOneDayAgoInMinutes
+        let manualPredicate = NSPredicate(format: "isManual == NO")
+        let datePredicate = NSPredicate(format: "date != nil")
+
+        let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
+            timePredicate,
+            manualPredicate,
+            datePredicate
+        ])
+
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
-            predicate: NSPredicate.predicateFor30MinAgo,
+            // 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),
+            // this fetch window must be expanded accordingly.
+            predicate: compoundPredicate,
             key: "date",
-            ascending: false,
-            fetchLimit: 6
-        ) as? [GlucoseStored]
-    }
-
-    private func processGlucose() async throws -> [BloodGlucose] {
-        let results = try await fetchGlucose()
+            ascending: true, // the first element is the oldest
+            fetchLimit: 350
+        )
 
-        return try await context.perform {
-            guard let results else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return results.map { result in
-                BloodGlucose(
-                    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"
-                )
-            }
+        guard let glucoseArray = results as? [GlucoseStored] else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
         }
+
+        return glucoseArray.map(\.objectID)
     }
 
     private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose]) async throws {
@@ -303,21 +306,12 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         }
         debug(.deviceManager, "New glucose found")
 
-        // filter the data if it is the case
-        if settingsManager.settings.smoothGlucose {
-            // limited to 30 min of old glucose data
-            let oldGlucoseValues = try await processGlucose()
+        try await glucoseStorage.storeGlucose(filtered)
 
-            var smoothedValues = oldGlucoseValues + filtered
-            // smooth with 3 repeats
-            for _ in 1 ... 3 {
-                smoothedValues.smoothSavitzkyGolayQuaDratic(withFilterWidth: 3)
-            }
-            // find the new values only
-            filtered = smoothedValues.filter { $0.dateString > syncDate }
+        if settingsManager.settings.smoothGlucose {
+            await exponentialSmoothingGlucose(context: context)
         }
 
-        try await glucoseStorage.storeGlucose(filtered)
         deviceDataManager.heartbeat(date: Date())
 
         endBackgroundTaskSafely(&backgroundTaskID, taskName: "Glucose Store and Heartbeat Decision")
@@ -377,3 +371,181 @@ extension CGMManager {
         ]
     }
 }
+
+extension BaseFetchGlucoseManager: SettingsObserver {
+    /// Smooth glucose data when smoothing is turned on.
+    func settingsDidChange(_: TrioSettings) {
+        let smoothingWasEnabled = shouldSmoothGlucose
+        let smoothingIsEnabled = settingsManager.settings.smoothGlucose
+        shouldSmoothGlucose = smoothingIsEnabled
+
+        guard smoothingIsEnabled, !smoothingWasEnabled else { return }
+
+        processQueue.async { [weak self] in
+            guard let self else { return }
+
+            self.glucoseStoreAndHeartLock.wait()
+            Task {
+                await self.exponentialSmoothingGlucose(context: self.context)
+                self.glucoseStoreAndHeartLock.signal()
+            }
+        }
+    }
+}
+
+extension BaseFetchGlucoseManager {
+    /// CoreData-friendly AAPS exponential smoothing + storage.
+    /// - Important: Only stores `smoothedGlucose`. UI/alerts should still use `glucose`.
+    ///
+    func exponentialSmoothingGlucose(context: NSManagedObjectContext) async {
+        let startTime = Date()
+
+        do {
+            // get objectIDs
+            let objectIDs = try await fetchGlucose(context: context)
+
+            try await context.perform {
+                // Load managed objects from object IDs
+                // Filtering (isManual, date) already done at DB level in fetchGlucose
+                let glucoseReadings = objectIDs.compactMap {
+                    context.object(with: $0) as? GlucoseStored
+                }
+
+                guard !glucoseReadings.isEmpty else { return }
+
+                // Static method call to avoid self-capture
+                Self.applyExponentialSmoothingAndStore(
+                    glucoseReadings: glucoseReadings,
+                    minimumWindowSize: 4,
+                    maximumAllowedGapMinutes: 12,
+                    xDripErrorGlucose: 38,
+                    minimumSmoothedGlucose: 39,
+                    firstOrderWeight: 0.4,
+                    firstOrderAlpha: 0.5,
+                    secondOrderAlpha: 0.4,
+                    secondOrderBeta: 1.0
+                )
+
+                try context.save()
+            }
+
+            let duration = Date().timeIntervalSince(startTime)
+            debugPrint(String(format: "Exponential smoothing duration: %0.04fs", duration))
+        } catch {
+            debug(.deviceManager, "Failed to smooth glucose: \(error)")
+        }
+    }
+
+    private static func applyExponentialSmoothingAndStore(
+        glucoseReadings data: [GlucoseStored],
+        minimumWindowSize: Int,
+        maximumAllowedGapMinutes: Int,
+        xDripErrorGlucose: Int,
+        minimumSmoothedGlucose: Decimal,
+        firstOrderWeight: Decimal,
+        firstOrderAlpha: Decimal,
+        secondOrderAlpha: Decimal,
+        secondOrderBeta: Decimal
+    ) {
+        guard !data.isEmpty else { return }
+
+        // Determine the size of the valid most-recent smoothing window.
+        // We walk adjacent pairs from newest -> oldest to preserve the same window semantics
+        // as the original implementation, but avoid manual reverse indexing.
+        var validWindowCount = max(data.count - 1, 0)
+
+        for (recentOffset, pair) in zip(data.dropFirst().reversed(), data.dropLast().reversed()).enumerated() {
+            let (newer, older) = pair
+
+            guard let newerDate = newer.date, let olderDate = older.date else { continue }
+
+            let gapSeconds = newerDate.timeIntervalSince(olderDate)
+            let gapMinutesRounded = Int((gapSeconds / 60.0).rounded())
+
+            if gapMinutesRounded >= maximumAllowedGapMinutes {
+                validWindowCount = recentOffset + 1 // include the more recent reading
+                break
+            }
+
+            // Ported from AAPS: 38 mg/dL may represent an xDrip error state.
+            if Int(newer.glucose) == xDripErrorGlucose {
+                validWindowCount = recentOffset // exclude this 38 value
+                break
+            }
+        }
+
+        // If insufficient valid readings: copy raw into smoothed (clamped) for all passed entries.
+        guard validWindowCount >= minimumWindowSize else {
+            for object in data {
+                let raw = Decimal(Int(object.glucose))
+                object.smoothedGlucose = max(raw, minimumSmoothedGlucose) as NSDecimalNumber
+            }
+            return
+        }
+
+        // Restrict smoothing to the valid most-recent window, still in chronological order.
+        let validWindow = data.suffix(validWindowCount)
+
+        guard let oldest = validWindow.first else { return }
+
+        // ---- 1st order smoothing ----
+        var firstOrderSmoothed: [Decimal] = []
+        firstOrderSmoothed.reserveCapacity(validWindow.count)
+
+        var firstOrderCurrent = Decimal(Int(oldest.glucose))
+        firstOrderSmoothed.append(firstOrderCurrent)
+
+        for sample in validWindow.dropFirst() {
+            let raw = Decimal(Int(sample.glucose))
+            firstOrderCurrent = firstOrderCurrent + firstOrderAlpha * (raw - firstOrderCurrent)
+            firstOrderSmoothed.append(firstOrderCurrent)
+        }
+
+        // ---- 2nd order smoothing ----
+        let secondOrderInput = Array(validWindow)
+        guard secondOrderInput.count >= 2 else { return }
+
+        var secondOrderSmoothed: [Decimal] = []
+        secondOrderSmoothed.reserveCapacity(secondOrderInput.count)
+
+        var secondOrderDeltas: [Decimal] = []
+        secondOrderDeltas.reserveCapacity(secondOrderInput.count)
+
+        var previousSecondOrderSmoothed = Decimal(Int(secondOrderInput[0].glucose))
+        var previousSecondOrderDelta =
+            Decimal(Int(secondOrderInput[1].glucose) - Int(secondOrderInput[0].glucose))
+
+        secondOrderSmoothed.append(previousSecondOrderSmoothed)
+        secondOrderDeltas.append(previousSecondOrderDelta)
+
+        for sample in secondOrderInput.dropFirst() {
+            let raw = Decimal(Int(sample.glucose))
+
+            let nextSmoothed =
+                secondOrderAlpha * raw
+                    + (1 - secondOrderAlpha) * (previousSecondOrderSmoothed + previousSecondOrderDelta)
+
+            let nextDelta =
+                secondOrderBeta * (nextSmoothed - previousSecondOrderSmoothed)
+                    + (1 - secondOrderBeta) * previousSecondOrderDelta
+
+            previousSecondOrderSmoothed = nextSmoothed
+            previousSecondOrderDelta = nextDelta
+
+            secondOrderSmoothed.append(nextSmoothed)
+            secondOrderDeltas.append(nextDelta)
+        }
+
+        // ---- Weighted blend ----
+        let blended = zip(firstOrderSmoothed, secondOrderSmoothed).map { firstOrder, secondOrder in
+            firstOrderWeight * firstOrder + (1 - firstOrderWeight) * secondOrder
+        }
+
+        // Apply to the most recent valid-window readings.
+        for (object, blendedValue) in zip(validWindow, blended) {
+            let rounded = blendedValue.rounded(toPlaces: 0) // nearest integer, ties away from zero
+            let clamped = max(rounded, minimumSmoothedGlucose)
+            object.smoothedGlucose = clamped as NSDecimalNumber
+        }
+    }
+}

+ 108 - 12
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -98,7 +98,12 @@ final class OpenAPS {
     }
 
     // fetch glucose to pass it to the meal function and to determine basal
-    private func fetchAndProcessGlucose(fetchLimit: Int?) async throws -> String {
+    func fetchAndProcessGlucose(
+        context: NSManagedObjectContext,
+        shouldSmoothGlucose: Bool,
+        fetchLimit: Int?
+    ) async throws -> String {
+        // make it async and await it
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -109,14 +114,40 @@ final class OpenAPS {
             batchSize: 48
         )
 
-        return try await context.perform {
+        // mapping within the context closure, JSON conversion outside
+        let algorithmGlucose = try await context.perform {
             guard let glucoseResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
 
-            // convert to JSON
-            return self.jsonConverter.convertToJSON(glucoseResults)
+            // extracting handler to only create it 1x
+            let roundingBehavior = NSDecimalNumberHandler(
+                roundingMode: .plain,
+                scale: 0,
+                raiseOnExactness: false,
+                raiseOnOverflow: false,
+                raiseOnUnderflow: false,
+                raiseOnDivideByZero: false
+            )
+
+            return glucoseResults.map { glucose -> AlgorithmGlucose in
+                let glucoseValue: Int16
+                if shouldSmoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose {
+                    glucoseValue = smoothedGlucose.rounding(accordingToBehavior: roundingBehavior).int16Value
+                } else {
+                    glucoseValue = glucose.glucose
+                }
+                return AlgorithmGlucose(
+                    date: glucose.date,
+                    direction: glucose.direction,
+                    glucose: glucoseValue,
+                    id: glucose.id,
+                    isManual: glucose.isManual
+                )
+            }
         }
+
+        return jsonConverter.convertToJSON(algorithmGlucose)
     }
 
     private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> String {
@@ -193,14 +224,22 @@ final class OpenAPS {
     private func parsePumpHistory(
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
         simulatedBolusAmount: Decimal? = nil
-    ) async -> String {
+    ) async throws -> String {
         // Return an empty JSON object if the list of object IDs is empty
         guard !pumpHistoryObjectIDs.isEmpty else { return "{}" }
 
+        // Addresses https://github.com/nightscout/Trio/issues/898
+        //
+        // On a cold start (new user, fresh onboarding, or pump disconnected > 24h),
+        // the oldest event in pump history can be a resume with no preceding pump
+        // activity. oref interprets this as the end of a suspend that never started,
+        // which drives negative IOB and can cause excessive insulin delivery.
+        let orphanedResumes = try await fetchOrphanedResumes()
+
         // Execute all operations on the background context
         return await context.perform {
             // Load and map pump events to DTOs
-            var dtos = self.loadAndMapPumpEvents(pumpHistoryObjectIDs)
+            var dtos = self.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes)
 
             // Optionally add the IOB as a DTO
             if let simulatedBolusAmount = simulatedBolusAmount {
@@ -213,17 +252,23 @@ final class OpenAPS {
         }
     }
 
-    private func loadAndMapPumpEvents(_ pumpHistoryObjectIDs: [NSManagedObjectID]) -> [PumpEventDTO] {
-        OpenAPS.loadAndMapPumpEvents(pumpHistoryObjectIDs, from: context)
+    private func loadAndMapPumpEvents(
+        _ pumpHistoryObjectIDs: [NSManagedObjectID],
+        orphanedResumes: [NSManagedObjectID]
+    ) -> [PumpEventDTO] {
+        OpenAPS.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes, from: context)
     }
 
     /// Fetches and parses pump events, expose this as static and not private for testing
     static func loadAndMapPumpEvents(
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
+        orphanedResumes: [NSManagedObjectID],
         from context: NSManagedObjectContext
     ) -> [PumpEventDTO] {
+        let orphanedSet = Set(orphanedResumes)
+        let filteredObjectIds = pumpHistoryObjectIDs.filter { !orphanedSet.contains($0) }
         // Load the pump events from the object IDs
-        let pumpHistory: [PumpEventStored] = pumpHistoryObjectIDs
+        let pumpHistory: [PumpEventStored] = filteredObjectIds
             .compactMap { context.object(with: $0) as? PumpEventStored }
 
         // Create the DTOs
@@ -276,8 +321,59 @@ final class OpenAPS {
         return .bolus(bolusDTO)
     }
 
+    /// Detects a cold-start orphaned resume: returns the resume's object ID if it's an orphaned resume
+    private func fetchOrphanedResumes() async throws -> [NSManagedObjectID] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: context,
+            predicate: NSPredicate.pumpHistoryLast48h,
+            key: "timestamp",
+            ascending: true,
+            batchSize: 250
+        )
+
+        return try await context.perform {
+            guard let pumpEventResultsFull = results as? [PumpEventStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
+
+            let pumpEventResults = pumpEventResultsFull
+                .filter { $0.type == EventType.pumpSuspend.rawValue || $0.type == EventType.pumpResume.rawValue }
+
+            // we define an orphaned resume as one without a paired suspend within
+            // the most recent 24 hours.
+            // **Important**: we pick 48 hours because the standard pump history
+            // is 24 hours + 24 hours of inspection for resumes.
+            let orphanedResumes = zip(pumpEventResults, pumpEventResults.dropFirst())
+                .compactMap { (prev, curr) -> PumpEventStored? in
+                    guard let prevTimestamp = prev.timestamp, let currTimestamp = curr.timestamp else {
+                        return nil
+                    }
+                    let interval = currTimestamp.timeIntervalSince(prevTimestamp)
+
+                    // check if the current event is an orphaned resume
+                    //  - previous event not a suspend
+                    //  - previous event is a suspend but it's more than 24 hours ago
+                    if curr.type == EventType.pumpResume.rawValue,
+                       prev.type != EventType.pumpSuspend.rawValue || interval > TimeInterval(hours: 24)
+                    {
+                        return curr
+                    }
+                    return nil
+                }
+            // check the first event to see if it's an orphaned resume
+            let firstResumeOrphaned = pumpEventResults.first.flatMap({ event -> [PumpEventStored]? in
+                guard event.type == EventType.pumpResume.rawValue else { return nil }
+                return [event]
+            }) ?? []
+
+            return (firstResumeOrphaned + orphanedResumes).map(\.objectID)
+        }
+    }
+
     func determineBasal(
         currentTemp: TempBasal,
+        shouldSmoothGlucose: Bool,
         clock: Date = Date(),
         simulatedCarbsAmount: Decimal? = nil,
         simulatedBolusAmount: Decimal? = nil,
@@ -292,7 +388,7 @@ final class OpenAPS {
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
         async let carbs = fetchAndProcessCarbs(additionalCarbs: simulatedCarbsAmount ?? 0, carbsDate: simulatedCarbsDate)
-        async let glucose = fetchAndProcessGlucose(fetchLimit: 72)
+        async let glucose = fetchAndProcessGlucose(context: context, shouldSmoothGlucose: shouldSmoothGlucose, fetchLimit: 72)
         async let prepareTrioCustomOrefVariables = prepareTrioCustomOrefVariables()
         async let profileAsync = loadFileFromStorageAsync(name: Settings.profile)
         async let basalAsync = loadFileFromStorageAsync(name: Settings.basalProfile)
@@ -461,13 +557,13 @@ final class OpenAPS {
         }
     }
 
-    func autosense() async throws -> Autosens? {
+    func autosense(shouldSmoothGlucose: Bool) async throws -> Autosens? {
         debug(.openAPS, "Start autosens")
 
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
         async let carbs = fetchAndProcessCarbs()
-        async let glucose = fetchAndProcessGlucose(fetchLimit: nil)
+        async let glucose = fetchAndProcessGlucose(context: context, shouldSmoothGlucose: shouldSmoothGlucose, fetchLimit: nil)
         async let getProfile = loadFileFromStorageAsync(name: Settings.profile)
         async let getBasalProfile = loadFileFromStorageAsync(name: Settings.basalProfile)
         async let getTempTargets = loadFileFromStorageAsync(name: Settings.tempTargets)

+ 114 - 59
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -102,38 +102,40 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     /**
-     Calculates the duration for processing FPUs (fat and protein units) based on the FPUs and the time cap.
+     Converts fat and protein into delayed carb-equivalent entries (FPU handling).
 
-     - The function uses predefined rules to determine the duration based on the number of FPUs.
-     - Ensures that the duration does not exceed the time cap.
+     Behavior:
 
-     - Parameters:
-       - fpus: The number of FPUs calculated from fat and protein.
-       - timeCap: The maximum allowed duration.
+     - Calculates carb equivalents from fat and protein
+       ((fat × 9 + protein × 4) / 10 × adjustment factor).
+     - Rounds down to whole grams.
+     - Drops values below 10 g.
+     - Caps total equivalents at 99 g.
+     - Splits into up to 3 entries.
+     - Caps each entry at 33 g.
+     - Distributes grams as evenly as possible.
 
-     - Returns: The computed duration in hours.
-     */
-    private func calculateComputedDuration(fpus: Decimal, timeCap: Decimal) -> Decimal {
-        switch fpus {
-        case ..<2:
-            return 3
-        case 2 ..< 3:
-            return 4
-        case 3 ..< 4:
-            return 5
-        default:
-            return timeCap
-        }
-    }
+     Timing:
 
-    /**
-     Processes fat and protein entries to generate future carb equivalents, ensuring each equivalent is at least 1.0 grams.
+     - First entry is scheduled after the configured delay
+       (default: 60 minutes) from the carb entry timestamp.
+     - Additional entries are spaced 30 minutes apart.
+
+     Example (default):
+
+     - Carb entry at T
+     - 1st equivalent at T + 60 min
+     - 2nd equivalent at T + 90 min
+     - 3rd equivalent at T + 120 min
 
-     - The function calculates the equivalent carb dosage size and adjusts the interval to ensure each equivalent is at least 1.0 grams.
-     - Creates future carb entries based on the adjusted carb equivalent size and interval.
+     Generated entries:
+
+     - Are marked with `isFPU = true`
+     - Contain only carbs (fat and protein set to 0)
+     - Share the same `fpuID` as the original carb entry
 
      - Parameters:
-       - entries: An array of `CarbsEntry` objects representing the carbohydrate entries to be processed.
+       - entries: An array of `CarbsEntry` objects representing the carb equivalent entries to be processed.
        - fat: The amount of fat in the last entry.
        - protein: The amount of protein in the last entry.
        - createdAt: The creation date of the last entry.
@@ -150,46 +152,48 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         let trioSettings = settings.settings
         let providerSettings = settingsProvider.settings
 
-        let interval = trioSettings.minuteInterval.clamp(to: providerSettings.minuteInterval)
-        let timeCap = trioSettings.timeCap.clamp(to: providerSettings.timeCap)
-        let adjustment = trioSettings.individualAdjustmentFactor.clamp(to: providerSettings.individualAdjustmentFactor)
-        let delay = trioSettings.delay.clamp(to: providerSettings.delay)
+        let adjustment = trioSettings.individualAdjustmentFactor
+            .clamp(to: providerSettings.individualAdjustmentFactor)
 
-        let kcal = protein * 4 + fat * 9
-        let carbEquivalents = (kcal / 10) * adjustment
-        let fpus = carbEquivalents / 10
-        var computedDuration = calculateComputedDuration(fpus: fpus, timeCap: timeCap)
+        let delayMinutes = trioSettings.delay
+            .clamp(to: providerSettings.delay)
 
-        var carbEquivalentSize: Decimal = carbEquivalents / computedDuration
-        carbEquivalentSize /= Decimal(60) / interval
+        let spreadInterval = trioSettings.minuteInterval
+            .clamp(to: providerSettings.minuteInterval)
 
-        if carbEquivalentSize < 1.0 {
-            carbEquivalentSize = 1.0
-            computedDuration = min(carbEquivalents / carbEquivalentSize, timeCap)
+        // Constraints
+        let maxTotalGrams = 99
+        let maxEntries = 3
+        let maxPerEntry = 33
+        let minPerEntry = 10
+        let spacing = TimeInterval(spreadInterval * 60)
+
+        // kcal -> carb equivalents (kcal/10 * adjustment), rounded down to whole grams
+        let kcal = protein * 4 + fat * 9
+        let rawEquivalents = Int((kcal / 10) * adjustment)
+        let totalGrams = min(maxTotalGrams, max(0, rawEquivalents))
+
+        guard totalGrams >= minPerEntry else {
+            return ([], Decimal(totalGrams))
         }
 
-        let roundedEquivalent: Double = round(Double(carbEquivalentSize * 10)) / 10
-        carbEquivalentSize = Decimal(roundedEquivalent)
-        var numberOfEquivalents = carbEquivalents / carbEquivalentSize
+        let amounts = splitIntoCarbEquivalents(
+            total: totalGrams,
+            maxEntries: maxEntries,
+            maxPerEntry: maxPerEntry,
+            minPerEntry: minPerEntry
+        )
 
-        var useDate = actualDate ?? createdAt
+        let baseDate = actualDate ?? createdAt
+        let start = baseDate.addingTimeInterval(TimeInterval(delayMinutes * 60))
         let fpuID = entries.first?.fpuID ?? UUID().uuidString
-        var futureCarbArray = [CarbsEntry]()
-        var firstIndex = true
-
-        // convert Decimal minutes to TimeInterval in seconds
-        let delayTimeInterval = TimeInterval(delay * 60)
-        let intervalTimeInterval = TimeInterval(interval * 60)
-        while carbEquivalents > 0, numberOfEquivalents > 0 {
-            useDate = firstIndex ? useDate.addingTimeInterval(delayTimeInterval) : useDate
-                .addingTimeInterval(intervalTimeInterval)
-            firstIndex = false
-
-            let eachCarbEntry = CarbsEntry(
+
+        let futureEntries: [CarbsEntry] = amounts.enumerated().map { idx, grams in
+            CarbsEntry(
                 id: UUID().uuidString,
                 createdAt: createdAt,
-                actualDate: useDate,
-                carbs: carbEquivalentSize,
+                actualDate: start.addingTimeInterval(TimeInterval(idx) * spacing),
+                carbs: Decimal(grams),
                 fat: 0,
                 protein: 0,
                 note: nil,
@@ -197,11 +201,62 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                 isFPU: true,
                 fpuID: fpuID
             )
-            futureCarbArray.append(eachCarbEntry)
-            numberOfEquivalents -= 1
         }
 
-        return (futureCarbArray, carbEquivalents)
+        let totalScheduled = futureEntries.reduce(into: Decimal(0)) { $0 += $1.carbs }
+        return (futureEntries, totalScheduled)
+    }
+
+    /**
+     Splits a total carb-equivalent value into multiple integer entries.
+
+     - Returns no entries if `total` is below `minPerEntry`.
+     - Limits output to `maxEntries`.
+     - Caps each entry at `maxPerEntry`.
+     - Distributes grams evenly (difference ≤ 1 g).
+     - Merges or removes entries below `minPerEntry`.
+
+     - Returns:
+       Integer gram values representing the split carb equivalents.
+     */
+    private func splitIntoCarbEquivalents(
+        total: Int,
+        maxEntries: Int,
+        maxPerEntry: Int,
+        minPerEntry: Int
+    ) -> [Int] {
+        guard total >= minPerEntry else { return [] }
+
+        // Choose an entry count that *guarantees* each entry can be <= maxPerEntry
+        let needed = (total + maxPerEntry - 1) / maxPerEntry
+        let count = min(maxEntries, max(1, needed))
+
+        // Even split (difference between buckets is at most 1)
+        func evenSplit(_ total: Int, count: Int) -> [Int] {
+            let base = total / count
+            let rem = total % count
+            return (0 ..< count).map { base + ($0 < rem ? 1 : 0) }
+        }
+
+        var buckets = evenSplit(total, count: count)
+
+        // Enforce minPerEntry by merging any too-small tail bucket into the previous one
+        // This should be rare, but it keeps the invariant
+        if buckets.count > 1 {
+            for i in stride(from: buckets.count - 1, through: 1, by: -1) {
+                let v = buckets[i]
+                guard v > 0, v < minPerEntry else { continue }
+                buckets[i - 1] += v
+                buckets[i] = 0
+            }
+            buckets = buckets.filter { $0 > 0 }
+        }
+
+        // Guarantee not to exceed maxPerEntry if merging a reduced count
+        // Clamp as final guard here
+        buckets = buckets.map { min(maxPerEntry, $0) }.filter { $0 >= minPerEntry }
+
+        return buckets
     }
 
     private func saveCarbEquivalents(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {

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

@@ -23,4 +23,7 @@ final class PropertyPersistentFlags {
     @PersistedProperty(key: "diagnosticsSharing") var diagnosticsSharingEnabled: Bool?
 
     @PersistedProperty(key: "lastCleanupDate") var lastCleanupDate: Date?
+
+    // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup.
+    @PersistedProperty(key: "hasSeenFatProteinOrderChange") var hasSeenFatProteinOrderChange: Bool?
 }

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

@@ -122,7 +122,8 @@ enum FileProtectionFixer {
         let flagFiles = [
             "onboardingCompleted.plist",
             "diagnosticsSharing.plist",
-            "lastCleanupDate.plist"
+            "lastCleanupDate.plist",
+            "hasSeenFatProteinOrderChange.plist"
         ]
 
         let fileManager = FileManager.default

+ 0 - 172
Trio/Sources/Helpers/SavitzkyGolayFilter.swift

@@ -1,172 +0,0 @@
-import Foundation
-
-/// allowed values are 0, 1, 2 or 3. It's the index in coefficients
-private var coefficientsRowToUse = 3
-
-/// Savitzky Golay coefficients
-private let coefficients = [
-    [-3.0, 12.0, 17.0, 12.0, -3.0],
-    [-2.0, 3.0, 6.0, 7.0, 6.0, 3.0, -2.0],
-    [-21.0, 14.0, 39.0, 54.0, 59.0, 54.0, 39.0, 14.0, -21.0],
-    [-36.0, 9.0, 44.0, 69.0, 84.0, 89.0, 84.0, 69.0, 44.0, 9.0, -36.0]
-]
-
-/// an array with elements of a type that conforms to Smoothable, can be filtered using  the Savitzky Golay algorithm
-protocol SavitzkyGolaySmoothable {
-    /// value to be smoothed
-    var value: Double { get set }
-}
-
-/// local help class
-private class IsSmoothable: SavitzkyGolaySmoothable {
-    var value: Double = 0.0
-
-    init(withValue value: Double = 0.0) {
-        self.value = value
-    }
-}
-
-extension Array where Element: SavitzkyGolaySmoothable {
-    /// - apply Savitzky Golay filter
-    /// - before applying the filter, the array will be prepended and append with a number of elements equal to the filterwidth, filterWidth default 5. Allowed values are 5, 4, 3, 2. If any other value is assigned, then 5 will be used
-    /// - ...continue with 5 here in the explanation ...
-    /// - for the 5 last elements and 5 first elements, a regression is done. This regression is done used to give values to the 5 prepended and appended values. Which means it's as if we draw a line through the first 5 and 5 last original values, and use this line to give values to the 5 prepended and appended values
-    /// - the 5 prepended and appended values are then used in the filter algorithm, which means we can also filter the original 5 first and last elements
-    /// see also example https://github.com/JohanDegraeve/xdripswift/wiki/Libre-value-smoothing
-    mutating func smoothSavitzkyGolayQuaDratic(withFilterWidth filterWidth: Int = 5) {
-        // filterWidthToUse is the value of filterWidth to use in the algorithm. By default filterWidthToUse = parameter value filterWidth
-        var filterWidthToUse = filterWidth
-
-        // calculate coefficientsRowToUse based on filterWdith
-        switch filterWidth {
-        case 5:
-            coefficientsRowToUse = 3
-
-        case 4:
-            coefficientsRowToUse = 2
-
-        case 3:
-            coefficientsRowToUse = 1
-
-        case 2:
-            coefficientsRowToUse = 0
-
-        default:
-            // invalid filterWidth was given in parameterList, use default value
-            coefficientsRowToUse = 3
-
-            filterWidthToUse = 5
-        }
-
-        // using 5 here in the comments as value for filterWidthToUse
-
-        // the amount of elements must be at least 5. If that's not the case then don't apply any smoothing
-        guard count >= filterWidthToUse else { return }
-
-        // create a new array, to which we will prepend and append 5 elements so that we can do also smoothing for the 5 last and 5 first values of the input array (which is self)
-        // the 5 elements will be estimated by doing linear regression of the first 5 and last 5 elements of the original input array respectively
-        // this is only a temporary array, but it will hold the elements of the original array, those elements will get a new value when doing the smoothing
-        var tempArray = [SavitzkyGolaySmoothable]()
-        for element in self {
-            tempArray.append(element)
-        }
-
-        // now prepend and append with 5 elements, each with a default value 0.0
-        for _ in 0 ..< filterWidthToUse {
-            tempArray.insert(IsSmoothable(), at: 0)
-            tempArray.append(IsSmoothable())
-        }
-
-        // so now we have tempArray, of length size of original array + 2 * 5
-        // the first 5 and the last 5 elements are of type IsSmoothable with value 0
-
-        // - indicesArray is a help array needed for the function linearRegressionCreator
-        // - this will be the first parameter in the call to the linearRegression function, in fact it's an array of IsSmoothable with length = length of tempArray
-        // - we give each IsSmoothable the value of the index, meaning from 0 up to (length of tempArray) - 1
-        // - in fact it's not really smoothable, it's just because we use isSmoothable in function linearRegressionCreator
-        var indicesArray = [SavitzkyGolaySmoothable]()
-        for index in 0 ..< (count + (filterWidthToUse * 2)) {
-            indicesArray.append(IsSmoothable(withValue: Double(index)))
-        }
-
-        /// - this is a piece of code that we will execute two times, once for the firs 5 elements, then for the last 5, so we put it in a closure variable
-        /// - it calculates the regression function (which is nothing else but doing y = intercept + slope*x) for range defined by predictorRange in tempArray. It will be used for the 5 first and 5 last real values, ie the 5 first and 5 last real glucose values
-        /// - then executes the regression for every element in the range defined by targetRange, again in tempArray
-        let doRegression = { (predictorRange: Range<Int>, targetRange: Range<Int>) in
-
-            // calculate the linearRegression function
-            let linearRegression = linearRegressionCreator(indicesArray[predictorRange], tempArray[predictorRange])
-
-            // ready to do the linear regression for the targetRange in tempArray
-            for index in targetRange {
-                tempArray[index].value = linearRegression(indicesArray[index].value)
-            }
-        }
-
-        // now do the regression for the 5 first elements
-        doRegression(filterWidthToUse ..< (filterWidthToUse * 2), 0 ..< filterWidthToUse)
-
-        // now do the regression for the 5 last elements
-        doRegression(
-            (tempArray.count - filterWidthToUse * 2) ..< (tempArray.count - filterWidthToUse),
-            (tempArray.count - filterWidthToUse) ..< tempArray.count
-        )
-
-        // now start filtering
-
-        // initialize array that will hold the resulting filtered values
-        var filteredValues = [Double]()
-
-        // calculate divider
-        let divider = coefficients[coefficientsRowToUse].reduce(0, { x, y in
-            x + y
-        })
-
-        // filter each original value
-        for _ in 0 ..< count {
-            // add a new element to filteredValues, start value is 0.0
-            // this new value will be the last element, so we access it with index filteredValues.count - 1
-            filteredValues.append(0.0)
-
-            // iterate through the coefficients
-            for (index, coefficient) in coefficients[coefficientsRowToUse].enumerated() {
-                filteredValues[filteredValues.count - 1] = filteredValues[filteredValues.count - 1] + coefficient *
-                    tempArray[index + filteredValues.count - 1].value
-            }
-
-            filteredValues[filteredValues.count - 1] = filteredValues[filteredValues.count - 1] / divider
-        }
-
-        // now assign the new values to the original objects
-        for (index, _) in enumerated() {
-            self[index].value = filteredValues[index]
-        }
-    }
-}
-
-/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
-private func multiply(
-    _ a: ArraySlice<SavitzkyGolaySmoothable>,
-    _ b: ArraySlice<SavitzkyGolaySmoothable>
-) -> ArraySlice<SavitzkyGolaySmoothable> {
-    zip(a, b).map({ IsSmoothable(withValue: $0.value * $1.value) })[0 ..< a.count]
-}
-
-/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
-private func average(_ input: ArraySlice<SavitzkyGolaySmoothable>) -> Double {
-    (input.reduce(IsSmoothable(), { (x: SavitzkyGolaySmoothable, y: SavitzkyGolaySmoothable) in
-        IsSmoothable(withValue: x.value + y.value) })).value / Double(input.count)
-}
-
-/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
-private func linearRegressionCreator(
-    _ xs: ArraySlice<SavitzkyGolaySmoothable>,
-    _ ys: ArraySlice<SavitzkyGolaySmoothable>
-) -> (Double) -> Double {
-    let sum1 = average(multiply(ys, xs)) - average(xs) * average(ys)
-    let sum2 = average(multiply(xs, xs)) - pow(average(xs), 2)
-    let slope = sum1 / sum2
-    let intercept = average(ys) - slope * average(xs)
-
-    return { x in intercept + slope * x }
-}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 7618 - 1061
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 86 - 0
Trio/Sources/Models/AlgorithmGlucose.swift

@@ -0,0 +1,86 @@
+import Foundation
+
+/// Helper class so that we can have a plain Swift object to serialize GlucoseStorage
+struct AlgorithmGlucose: Codable {
+    var date: Date?
+    var direction: String?
+    var glucose: Int16
+    var id: UUID?
+    var isManual: Bool
+
+    enum CodingKeys: String, CodingKey {
+        case date
+        case dateString
+        case sgv
+        case glucose
+        case direction
+        case id
+        case type
+    }
+
+    init(date: Date?, direction: String?, glucose: Int16, id: UUID?, isManual: Bool) {
+        self.date = date
+        self.direction = direction
+        self.glucose = glucose
+        self.id = id
+        self.isManual = isManual
+    }
+
+    // this constructor is just for testing
+    public init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+
+        if let dateString = try container.decodeIfPresent(String.self, forKey: .dateString) {
+            let dateFormatter = ISO8601DateFormatter()
+            dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+            date = dateFormatter.date(from: dateString)
+        } else if let dateStringTimestamp = try container.decodeIfPresent(String.self, forKey: .date),
+                  let dateTimestamp = TimeInterval(dateStringTimestamp)
+        {
+            date = Date(timeIntervalSince1970: dateTimestamp / 1000)
+        } else {
+            date = nil
+        }
+
+        direction = try container.decodeIfPresent(String.self, forKey: .direction)
+        id = try container.decodeIfPresent(UUID.self, forKey: .id)
+
+        if let glucoseValue = try container.decodeIfPresent(Int16.self, forKey: .glucose) {
+            glucose = glucoseValue
+            isManual = true
+        } else if let sgvValue = try container.decodeIfPresent(Int16.self, forKey: .sgv) {
+            glucose = sgvValue
+            isManual = false
+        } else {
+            throw DecodingError.dataCorruptedError(
+                forKey: .sgv,
+                in: container,
+                debugDescription: "Neither 'glucose' nor 'sgv' key found or value is not Int16"
+            )
+        }
+    }
+
+    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)
+        }
+    }
+}

+ 0 - 12
Trio/Sources/Models/BloodGlucose.swift

@@ -249,18 +249,6 @@ extension NumberFormatter {
     }()
 }
 
-extension BloodGlucose: SavitzkyGolaySmoothable {
-    var value: Double {
-        get {
-            Double(glucose ?? 0)
-        }
-        set {
-            glucose = Int(newValue)
-            sgv = Int(newValue)
-        }
-    }
-}
-
 extension BloodGlucose {
     func convertStoredGlucoseSample(isManualGlucose: Bool) -> StoredGlucoseSample {
         StoredGlucoseSample(

+ 22 - 0
Trio/Sources/Models/BolusDisplayThreshold.swift

@@ -0,0 +1,22 @@
+import Foundation
+
+enum BolusDisplayThreshold: Decimal, CaseIterable, Encodable, Identifiable {
+    public var id: Decimal { rawValue }
+    case oneUnit = 1
+    case halfUnit = 0.5
+    case pointOneUnit = 0.1
+    case allUnits = 0.01
+
+    var displayName: String {
+        switch self {
+        case .oneUnit:
+            return String(localized: "1 U and over")
+        case .halfUnit:
+            return String(localized: "0.5 U and over")
+        case .pointOneUnit:
+            return String(localized: "0.1 U and over")
+        case .allUnits:
+            return String(localized: "Show All")
+        }
+    }
+}

+ 6 - 7
Trio/Sources/Models/DecimalPickerSettings.swift

@@ -43,7 +43,7 @@ struct DecimalPickerSettings {
         max: 1.2,
         type: PickerSetting.PickerSettingType.factor
     )
-    var high = PickerSetting(value: 180, step: 1, min: 100, max: 500, type: PickerSetting.PickerSettingType.glucose)
+    var high = PickerSetting(value: 180, step: 1, min: 100, max: 400, type: PickerSetting.PickerSettingType.glucose)
     var low = PickerSetting(value: 70, step: 1, min: 40, max: 100, type: PickerSetting.PickerSettingType.glucose)
     var maxCarbs = PickerSetting(value: 250, step: 5, min: 0, max: 300, type: PickerSetting.PickerSettingType.gram)
     var maxFat = PickerSetting(value: 250, step: 5, min: 0, max: 300, type: PickerSetting.PickerSettingType.gram)
@@ -51,7 +51,7 @@ struct DecimalPickerSettings {
     var overrideFactor = PickerSetting(value: 0.8, step: 0.05, min: 0.05, max: 1.5, type: PickerSetting.PickerSettingType.factor)
     var fattyMealFactor = PickerSetting(value: 0.7, step: 0.05, min: 0.05, max: 1, type: PickerSetting.PickerSettingType.factor)
     var sweetMealFactor = PickerSetting(value: 1, step: 0.05, min: 0.05, max: 2, type: PickerSetting.PickerSettingType.factor)
-    var maxIOB = PickerSetting(value: 0, step: 1, min: 0, max: 20, type: PickerSetting.PickerSettingType.insulinUnit)
+    var maxIOB = PickerSetting(value: 0, step: 1, min: 0, max: 30, type: PickerSetting.PickerSettingType.insulinUnit)
     var maxDailySafetyMultiplier = PickerSetting(
         value: 3,
         step: 0.1,
@@ -87,8 +87,8 @@ struct DecimalPickerSettings {
         type: PickerSetting.PickerSettingType.factor
     )
     var remainingCarbsCap = PickerSetting(value: 90, step: 5, min: 0, max: 200, type: PickerSetting.PickerSettingType.gram)
-    var maxSMBBasalMinutes = PickerSetting(value: 30, step: 5, min: 30, max: 180, type: PickerSetting.PickerSettingType.minute)
-    var maxUAMSMBBasalMinutes = PickerSetting(value: 30, step: 5, min: 30, max: 180, type: PickerSetting.PickerSettingType.minute)
+    var maxSMBBasalMinutes = PickerSetting(value: 30, step: 5, min: 15, max: 180, type: PickerSetting.PickerSettingType.minute)
+    var maxUAMSMBBasalMinutes = PickerSetting(value: 30, step: 5, min: 15, max: 180, type: PickerSetting.PickerSettingType.minute)
     var smbInterval = PickerSetting(value: 3, step: 1, min: 1, max: 10, type: PickerSetting.PickerSettingType.minute)
     var bolusIncrement = PickerSetting(
         value: 0.1,
@@ -113,7 +113,7 @@ struct DecimalPickerSettings {
         max: 0.4,
         type: PickerSetting.PickerSettingType.factor
     )
-    var adjustmentFactor = PickerSetting(value: 0.8, step: 0.05, min: 0.3, max: 1.5, type: PickerSetting.PickerSettingType.factor)
+    var adjustmentFactor = PickerSetting(value: 0.8, step: 0.05, min: 0.3, max: 3.0, type: PickerSetting.PickerSettingType.factor)
     var adjustmentFactorSigmoid = PickerSetting(
         value: 0.5,
         step: 0.05,
@@ -132,8 +132,7 @@ struct DecimalPickerSettings {
     var threshold_setting = PickerSetting(value: 60, step: 1, min: 60, max: 120, type: PickerSetting.PickerSettingType.glucose)
     var updateInterval = PickerSetting(value: 20, step: 5, min: 1, max: 60, type: PickerSetting.PickerSettingType.minute)
     var delay = PickerSetting(value: 60, step: 5, min: 15, max: 120, type: PickerSetting.PickerSettingType.minute)
-    var minuteInterval = PickerSetting(value: 30, step: 5, min: 10, max: 60, type: PickerSetting.PickerSettingType.minute)
-    var timeCap = PickerSetting(value: 8, step: 1, min: 5, max: 12, type: PickerSetting.PickerSettingType.hour)
+    var minuteInterval = PickerSetting(value: 30, step: 5, min: 30, max: 60, type: PickerSetting.PickerSettingType.minute)
     var hours = PickerSetting(value: 6, step: 0.5, min: 2, max: 24, type: PickerSetting.PickerSettingType.hour)
     var dia = PickerSetting(value: 10, step: 0.5, min: 5, max: 10, type: PickerSetting.PickerSettingType.hour)
     var maxBolus = PickerSetting(value: 10, step: 0.5, min: 0.5, max: 30, type: PickerSetting.PickerSettingType.insulinUnit)

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

@@ -42,7 +42,6 @@ struct TrioSettings: JSON, Equatable {
     var showCarbsRequiredBadge: Bool = true
     var useFPUconversion: Bool = true
     var individualAdjustmentFactor: Decimal = 0.5
-    var timeCap: Decimal = 8
     var minuteInterval: Decimal = 30
     var delay: Decimal = 60
     var useAppleHealth: Bool = false
@@ -54,6 +53,7 @@ struct TrioSettings: JSON, Equatable {
     var xGridLines: Bool = true
     var yGridLines: Bool = true
     var rulerMarks: Bool = true
+    var bolusDisplayThreshold: BolusDisplayThreshold = .allUnits
     var forecastDisplayType: ForecastDisplayType = .cone
     var maxCarbs: Decimal = 250
     var maxFat: Decimal = 250
@@ -167,10 +167,6 @@ extension TrioSettings: Decodable {
             settings.overrideFactor = overrideFactor
         }
 
-        if let timeCap = try? container.decode(Decimal.self, forKey: .timeCap) {
-            settings.timeCap = timeCap
-        }
-
         if let minuteInterval = try? container.decode(Decimal.self, forKey: .minuteInterval) {
             settings.minuteInterval = minuteInterval
         }

+ 24 - 5
Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift

@@ -113,17 +113,36 @@ extension CGMSettings {
                         units: state.units,
                         type: .boolean,
                         label: String(localized: "Smooth Glucose Value"),
-                        miniHint: String(localized: "Smooth CGM readings using Savitzky-Golay filtering."),
+                        miniHint: String(localized: "Smooth CGM readings using exponential smoothing."),
                         verboseHint: VStack(alignment: .leading, spacing: 10) {
                             Text("Default: OFF").bold()
+
                             Text(
-                                "This filter looks at small groups of nearby readings and fits them to a simple mathematical curve. This process doesn't change the overall pattern of your glucose data but helps smooth out the \"noise\" or irregular fluctuations that could lead to false highs or lows."
+                                "This feature smooths your CGM readings to reduce noise and make them easier to read. It is based on a method used in AndroidAPS (AAPS). It uses two approaches: one that reacts quickly to recent changes, and one that looks at longer trends. These are combined to give a balanced result."
                             )
+
                             Text(
-                                "It's designed to keep the important trends in your data while minimizing those small, misleading variations, giving you and Trio a clearer sense of where your blood sugar is really headed. This type of filtering is useful in Trio, as it can help prevent over-corrections based on inaccurate glucose readings. This can help reduce the impact of sudden spikes or dips that might not reflect your true blood glucose levels."
+                                "Trio will always display values based on your actual (raw) CGM readings. Smoothing does not change your real values or alerts."
                             )
+
+                            Text("When this feature is enabled:")
+
+                            VStack(alignment: .leading) {
+                                Text(
+                                    "• The main chart and treatment chart show a light gray trend line for the smoothed values. The glucose dots always show your original CGM readings."
+                                )
+
+                                Text("• In Trio history, you will see the smoothed value next to the original reading.")
+
+                                Text("• When you long-press a chart, the pop-up will show both the original and smoothed values.")
+                            }
+
+                            Text(
+                                "It can handle small gaps in data and ignores sensor error values. It needs at least 4 readings within 12 minutes to work properly. Only CGM readings are smoothed—manual entries are not changed."
+                            )
+
                             Text(
-                                "Note: If enabled, the smoothed values you see in Trio may differ from what is shown in your CGM app."
+                                "This helps Trio make more stable dosing decisions by avoiding over-reactions to small or short-term changes. Important trends are kept, while unreliable fluctuations are filtered out."
                             )
                         }
                     )
@@ -181,7 +200,7 @@ extension CGMSettings {
                         hintDetent: $hintDetent,
                         shouldDisplayHint: $shouldDisplayHint,
                         hintLabel: hintLabel ?? "",
-                        hintText: AnyView(
+                        hintText: selectedVerboseHint ?? AnyView(
                             VStack(alignment: .leading, spacing: 10) {
                                 Text(
                                     "Current CGM Models Supported:"

+ 4 - 4
Trio/Sources/Modules/History/View/CarbEntryEditorView.swift

@@ -145,9 +145,9 @@ struct CarbEntryEditorView: View {
 
                     if state.settingsManager.settings.useFPUconversion {
                         HStack {
-                            Text("Protein")
+                            Text("Fat")
                             TextFieldWithToolBar(
-                                text: $editedProtein,
+                                text: $editedFat,
                                 placeholder: "0",
                                 keyboardType: .numberPad,
                                 numberFormatter: mealFormatter,
@@ -156,9 +156,9 @@ struct CarbEntryEditorView: View {
                         }
 
                         HStack {
-                            Text("Fat")
+                            Text("Protein")
                             TextFieldWithToolBar(
-                                text: $editedFat,
+                                text: $editedProtein,
                                 placeholder: "0",
                                 keyboardType: .numberPad,
                                 numberFormatter: mealFormatter,

+ 21 - 3
Trio/Sources/Modules/History/View/HistoryRootView.swift

@@ -469,10 +469,11 @@ extension History {
         private var glucoseList: some View {
             List {
                 HStack {
-                    Text("Values").foregroundStyle(.secondary)
+                    Text("Values")
                     Spacer()
-                    Text("Time").foregroundStyle(.secondary)
-                }
+                    Text("Time")
+                }.foregroundStyle(.secondary)
+
                 if !glucoseStored.isEmpty {
                     ForEach(glucoseStored) { glucose in
                         HStack {
@@ -485,6 +486,23 @@ extension History {
                                 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()))

+ 3 - 0
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -76,6 +76,7 @@ extension Home {
         var displayXgridLines: Bool = false
         var displayYgridLines: Bool = false
         var thresholdLines: Bool = false
+        var bolusDisplayThreshold: BolusDisplayThreshold = .allUnits
         var hours: Int16 = 6
         var totalBolus: Decimal = 0
         var isLoopStatusPresented: Bool = false
@@ -404,6 +405,7 @@ extension Home {
             eA1cDisplayUnit = settingsManager.settings.eA1cDisplayUnit
             displayXgridLines = settingsManager.settings.xGridLines
             displayYgridLines = settingsManager.settings.yGridLines
+            bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
             thresholdLines = settingsManager.settings.rulerMarks
             showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
             forecastDisplayType = settingsManager.settings.forecastDisplayType
@@ -668,6 +670,7 @@ extension Home.StateModel:
         displayXgridLines = settingsManager.settings.xGridLines
         displayYgridLines = settingsManager.settings.yGridLines
         thresholdLines = settingsManager.settings.rulerMarks
+        bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
         showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
         forecastDisplayType = settingsManager.settings.forecastDisplayType
         cgmAvailable = (fetchGlucoseManager.cgmGlucoseSourceType != CGMType.none)

+ 27 - 37
Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift

@@ -32,46 +32,36 @@ struct GlucoseChartView: ChartContent {
                 glucoseColorScheme: glucoseColorScheme
             )
 
-            if !isSmoothingEnabled {
-                PointMark(
-                    x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
-                )
-                .foregroundStyle(pointMarkColor)
-                .symbolSize(20)
-                .symbol {
-                    if item.isManual {
-                        Image(systemName: "drop.fill")
-                            .font(.caption2)
-                            .symbolRenderingMode(.monochrome)
-                            .bold()
-                            .foregroundStyle(.red)
-                    } else {
-                        Image(systemName: "circle.fill")
-                            .font(.system(size: 5))
-                            .bold()
-                            .foregroundStyle(pointMarkColor)
-                    }
+            PointMark(
+                x: .value("Time", item.date ?? Date(), unit: .second),
+                y: .value("Value", glucoseToDisplay)
+            )
+            .foregroundStyle(pointMarkColor)
+            .symbolSize(20)
+            .symbol {
+                if item.isManual {
+                    Image(systemName: "drop.fill")
+                        .font(.caption2)
+                        .symbolRenderingMode(.monochrome)
+                        .bold()
+                        .foregroundStyle(.red)
+                } else {
+                    Image(systemName: "circle.fill")
+                        .font(.system(size: 5))
+                        .bold()
+                        .foregroundStyle(pointMarkColor)
                 }
-            } else {
-                PointMark(
+            }
+
+            if isSmoothingEnabled, let smoothedGlucose = item.smoothedGlucose, smoothedGlucose != 0 {
+                let smoothedGlucoseForDisplay: Decimal = units == .mgdL ? smoothedGlucose.decimalValue : smoothedGlucose
+                    .decimalValue.asMmolL
+                LineMark(
                     x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
+                    y: .value("Value", smoothedGlucoseForDisplay),
+                    series: .value("Type", "Smoothed")
                 )
-                .symbol {
-                    if item.isManual {
-                        Image(systemName: "drop.fill")
-                            .font(.caption2)
-                            .symbolRenderingMode(.monochrome)
-                            .bold()
-                            .foregroundStyle(.red)
-                    } else {
-                        Image(systemName: "record.circle.fill")
-                            .font(.system(size: 8))
-                            .bold()
-                            .foregroundStyle(pointMarkColor)
-                    }
-                }
+                .foregroundStyle(Color.secondary)
             }
         }
     }

+ 6 - 3
Trio/Sources/Modules/Home/View/Chart/ChartElements/InsulinView.swift

@@ -6,6 +6,7 @@ struct InsulinView: ChartContent {
     let glucoseData: [GlucoseStored]
     let insulinData: [PumpEventStored]
     let units: GlucoseUnits
+    let bolusDisplayThreshold: BolusDisplayThreshold
 
     var body: some ChartContent {
         drawBoluses()
@@ -32,9 +33,11 @@ struct InsulinView: ChartContent {
                     Image(systemName: "arrowtriangle.down.fill").font(.system(size: size)).foregroundStyle(Color.insulin)
                 }
                 .annotation(position: .top) {
-                    Text(Formatter.bolusFormatter.string(from: amount) ?? "")
-                        .font(.caption2)
-                        .foregroundStyle(Color.primary)
+                    if amount as Decimal >= bolusDisplayThreshold.rawValue {
+                        Text(Formatter.bolusFormatter.string(from: amount) ?? "")
+                            .font(.caption2)
+                            .foregroundStyle(Color.primary)
+                    }
                 }
             }
         }

+ 14 - 3
Trio/Sources/Modules/Home/View/Chart/ChartElements/SelectionPopoverView.swift

@@ -11,6 +11,7 @@ struct SelectionPopoverView: ChartContent {
     let lowGlucose: Decimal
     let currentGlucoseTarget: Decimal
     let glucoseColorScheme: GlucoseColorScheme
+    let isSmoothingEnabled: Bool
 
     private var glucoseToDisplay: Decimal {
         units == .mgdL ? Decimal(selectedGlucose.glucose) : Decimal(selectedGlucose.glucose).asMmolL
@@ -33,7 +34,7 @@ struct SelectionPopoverView: ChartContent {
     var body: some ChartContent {
         RuleMark(x: .value("Selection", selectedGlucose.date ?? Date.now, unit: .minute))
             .foregroundStyle(Color.tabBar)
-            .offset(yStart: 70)
+            .offset(yStart: isSmoothingEnabled ? 90 : 70)
             .lineStyle(.init(lineWidth: 2))
             .annotation(
                 position: .top,
@@ -70,15 +71,25 @@ struct SelectionPopoverView: ChartContent {
             .font(.body).padding(.bottom, 2)
 
             HStack {
-                Text(glucoseToDisplay.description).bold() + Text(" \(units.rawValue)")
+                Text("CGM: ") + Text(glucoseToDisplay.description).bold() + Text(" \(units.rawValue)")
             }
             .foregroundStyle(pointMarkColor)
             .font(.body)
 
+            if isSmoothingEnabled, let smoothedGlucose = selectedGlucose.smoothedGlucose {
+                var smoothedGlucoseToDisplay: Decimal {
+                    units == .mgdL ? smoothedGlucose.decimalValue : smoothedGlucose.decimalValue.asMmolL
+                }
+                HStack {
+                    Image(systemName: "sparkles")
+                    Text(smoothedGlucoseToDisplay.description) + Text(" \(units.rawValue)")
+                }.font(.body)
+            }
+
             if let selectedIOBValue, let iob = selectedIOBValue.iob {
                 HStack {
                     Image(systemName: "syringe.fill").frame(width: 15)
-                    Text(Formatter.bolusFormatter.string(from: iob) ?? "")
+                    Text(Formatter.decimalFormatterWithTwoFractionDigits.string(from: iob) ?? "")
                         .bold()
                         + Text(String(localized: " U", comment: "Insulin unit"))
                 }

+ 4 - 2
Trio/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -149,7 +149,8 @@ extension MainChartView {
                 InsulinView(
                     glucoseData: state.glucoseFromPersistence,
                     insulinData: state.insulinFromPersistence,
-                    units: state.units
+                    units: state.units,
+                    bolusDisplayThreshold: state.bolusDisplayThreshold
                 )
 
                 CarbView(
@@ -181,7 +182,8 @@ extension MainChartView {
                         highGlucose: highGlucose,
                         lowGlucose: lowGlucose,
                         currentGlucoseTarget: currentGlucoseTarget,
-                        glucoseColorScheme: glucoseColorScheme
+                        glucoseColorScheme: glucoseColorScheme,
+                        isSmoothingEnabled: state.settingsManager.settings.smoothGlucose
                     )
                 }
             }

+ 7 - 4
Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift

@@ -137,11 +137,14 @@ struct CurrentGlucoseView: View {
             return "--"
         }
 
-        let lastGlucose = glucose.last?.glucose ?? 0
-        let secondLastGlucose = glucose.first?.glucose ?? 0
+        var lastGlucose = Decimal(glucose.last?.glucose ?? 0)
+        var secondLastGlucose = Decimal(glucose.first?.glucose ?? 0)
+        if units == .mmolL {
+            lastGlucose = lastGlucose.asMmolL
+            secondLastGlucose = secondLastGlucose.asMmolL
+        }
         let delta = lastGlucose - secondLastGlucose
-        let deltaAsDecimal = units == .mmolL ? Decimal(delta).asMmolL : Decimal(delta)
-        return deltaFormatter.string(from: deltaAsDecimal as NSNumber) ?? "--"
+        return deltaFormatter.string(from: delta as NSNumber) ?? "--"
     }
 }
 

+ 0 - 4
Trio/Sources/Modules/MealSettings/MealSettingsStateModel.swift

@@ -8,7 +8,6 @@ extension MealSettings {
         @Published var maxFat: Decimal = 250
         @Published var maxProtein: Decimal = 250
         @Published var individualAdjustmentFactor: Decimal = 0.5
-        @Published var timeCap: Decimal = 8
         @Published var minuteInterval: Decimal = 30
         @Published var delay: Decimal = 60
         @Published var maxMealAbsorptionTime: Decimal = 6
@@ -27,9 +26,6 @@ extension MealSettings {
             // "Fat and Protein Delay"
             subscribeSetting(\.delay, on: $delay) { delay = $0 }
 
-            // "Maximum Duration"
-            subscribeSetting(\.timeCap, on: $timeCap) { timeCap = $0 }
-
             // "Spread Interval"
             subscribeSetting(\.minuteInterval, on: $minuteInterval) { minuteInterval = $0 }
 

+ 14 - 43
Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift

@@ -81,26 +81,26 @@ extension MealSettings {
                             if state.useFPUconversion {
                                 VStack {
                                     HStack {
-                                        Text("Max Protein")
+                                        Text("Max Fat")
 
                                         Spacer()
 
                                         Group {
-                                            Text(state.maxProtein.description)
-                                                .foregroundColor(!displayPickerMaxProtein ? .primary : .accentColor)
+                                            Text(state.maxFat.description)
+                                                .foregroundColor(!displayPickerMaxFat ? .primary : .accentColor)
 
                                             Text(" g").foregroundColor(.secondary)
                                         }
                                     }
                                     .onTapGesture {
-                                        displayPickerMaxProtein.toggle()
+                                        displayPickerMaxFat.toggle()
                                     }
                                 }
                                 .padding(.top)
 
-                                if displayPickerMaxProtein {
-                                    let setting = PickerSettingsProvider.shared.settings.maxProtein
-                                    Picker(selection: $state.maxProtein, label: Text("")) {
+                                if displayPickerMaxFat {
+                                    let setting = PickerSettingsProvider.shared.settings.maxFat
+                                    Picker(selection: $state.maxFat, label: Text("")) {
                                         ForEach(
                                             PickerSettingsProvider.shared.generatePickerValues(from: setting, units: state.units),
                                             id: \.self
@@ -114,26 +114,26 @@ extension MealSettings {
 
                                 VStack {
                                     HStack {
-                                        Text("Max Fat")
+                                        Text("Max Protein")
 
                                         Spacer()
 
                                         Group {
-                                            Text(state.maxFat.description)
-                                                .foregroundColor(!displayPickerMaxFat ? .primary : .accentColor)
+                                            Text(state.maxProtein.description)
+                                                .foregroundColor(!displayPickerMaxProtein ? .primary : .accentColor)
 
                                             Text(" g").foregroundColor(.secondary)
                                         }
                                     }
                                     .onTapGesture {
-                                        displayPickerMaxFat.toggle()
+                                        displayPickerMaxProtein.toggle()
                                     }
                                 }
                                 .padding(.top)
 
-                                if displayPickerMaxFat {
-                                    let setting = PickerSettingsProvider.shared.settings.maxFat
-                                    Picker(selection: $state.maxFat, label: Text("")) {
+                                if displayPickerMaxProtein {
+                                    let setting = PickerSettingsProvider.shared.settings.maxProtein
+                                    Picker(selection: $state.maxProtein, label: Text("")) {
                                         ForEach(
                                             PickerSettingsProvider.shared.generatePickerValues(from: setting, units: state.units),
                                             id: \.self
@@ -295,35 +295,6 @@ extension MealSettings {
                     )
 
                     SettingInputSection(
-                        decimalValue: $state.timeCap,
-                        booleanValue: $booleanPlaceholder,
-                        shouldDisplayHint: $shouldDisplayHint,
-                        selectedVerboseHint: Binding(
-                            get: { selectedVerboseHint },
-                            set: {
-                                selectedVerboseHint = $0.map { AnyView($0) }
-                                hintLabel = String(localized: "Maximum Duration")
-                            }
-                        ),
-                        units: state.units,
-                        type: .decimal("timeCap"),
-                        label: String(localized: "Maximum Duration"),
-                        miniHint: String(localized: "Set the maximum timeframe to extend FPUs."),
-                        verboseHint:
-                        VStack(alignment: .leading, spacing: 10) {
-                            Text("Default: 8 hours").bold()
-                            Text(
-                                "This sets the maximum length of time that Fat and Protein Carb Equivalents (FPUs) will be extended over from a single Fat and/or Protein bolus calcultor entry."
-                            )
-                            Text(
-                                "It is one factor used in combination with the Fat and Protein Delay, Spread Interval, and Fat and Protein Factor to create the FPU entries."
-                            )
-                            Text("Increasing this setting may result in more FPU entries with smaller carb values.")
-                            Text("Decreasing this setting may result in fewer FPU entries with larger carb values.")
-                        }
-                    )
-
-                    SettingInputSection(
                         decimalValue: $state.minuteInterval,
                         booleanValue: $booleanPlaceholder,
                         shouldDisplayHint: $shouldDisplayHint,

+ 1 - 2
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

@@ -44,7 +44,7 @@ extension Onboarding {
 
         // MARK: - Determine Initial Build State
 
-        /// Determines whether the app is in a fresh install state for Trio v0.3.0.
+        /// Determines whether the app is in a fresh install state for Trio (new vs. returning/updating user).
         ///
         /// This check is based on the assumption that a truly clean install will only contain
         /// the `logs/` directory and the `preferences.json` file in the app's Documents directory.
@@ -717,7 +717,6 @@ extension Onboarding {
                     .clamp(to: providedSettings.carbsRequiredThreshold)
                 settingsCopy.individualAdjustmentFactor = settingsCopy.individualAdjustmentFactor
                     .clamp(to: providedSettings.individualAdjustmentFactor)
-                settingsCopy.timeCap = settingsCopy.timeCap.clamp(to: providedSettings.timeCap)
                 settingsCopy.minuteInterval = settingsCopy.minuteInterval.clamp(to: providedSettings.minuteInterval)
                 settingsCopy.delay = settingsCopy.delay.clamp(to: providedSettings.delay)
                 settingsCopy.high = settingsCopy.high.clamp(to: providedSettings.high)

+ 2 - 2
Trio/Sources/Modules/Onboarding/View/OnboardingView+AlgorithmUtil.swift

@@ -121,8 +121,8 @@ enum AlgorithmSettingsSubstep: Int, CaseIterable, Identifiable {
         case .maxSMBMinutes: return String(localized: "Max SMB Basal Minutes", comment: "Max SMB Basal Minutes")
         case .maxUAMMinutes: return String(localized: "Max UAM Basal Minutes", comment: "Max UAM Basal Minutes")
         case .maxDeltaGlucoseThreshold: return String(
-                localized: "Max. Allowed Glucose Rise for SMB",
-                comment: "Max. Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
+                localized: "Max Allowed Glucose Rise for SMB",
+                comment: "Max Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
             )
         case .highTempTargetRaisesSensitivity: return String(
                 localized: "High Temp Target Raises Sensitivity",

+ 4 - 4
Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift

@@ -331,16 +331,16 @@ extension SMBSettings {
                         set: {
                             selectedVerboseHint = $0.map { AnyView($0) }
                             hintLabel = String(
-                                localized: "Max. Allowed Glucose Rise for SMB",
-                                comment: "Max. Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
+                                localized: "Max Allowed Glucose Rise for SMB",
+                                comment: "Max Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
                             )
                         }
                     ),
                     units: state.units,
                     type: .decimal("maxDeltaBGthreshold"),
                     label: String(
-                        localized: "Max. Allowed Glucose Rise for SMB",
-                        comment: "Max. Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
+                        localized: "Max Allowed Glucose Rise for SMB",
+                        comment: "Max Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
                     ),
                     miniHint: String(localized: "Disables SMBs if last two glucose values differ by more than this percent."),
                     verboseHint:

+ 7 - 7
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -114,13 +114,13 @@ enum SettingItems {
                 "Enable SMB With COB",
                 "Enable SMB With Temporary Target",
                 "Enable SMB After Carbs",
-                "Enable SMB With High BG",
-                "High BG Target",
+                "Enable SMB With High Glucose",
+                "High Glucose Target",
                 "Allow SMB With High Temporary Target",
                 "Enable UAM",
                 "Max SMB Basal Minutes",
                 "Max UAM SMB Basal Minutes",
-                "Max Delta-BG Threshold SMB"
+                "Max Allowed Glucose Rise for SMB"
             ],
             path: ["Algorithm", "Super Micro Bolus (SMB)"]
         ),
@@ -195,11 +195,10 @@ enum SettingItems {
                 "Max Meal Absorption Time",
                 "Max Fat",
                 "Max Protein",
-                "Display and Allow Fat and Protein Entries",
+                "Enable Fat and Protein Entries",
                 "Fat and Protein Delay",
-                "Maximum Duration (hours)",
-                "Spread Interval (minutes)",
-                "Fat and Protein Factor",
+                "Spread Interval",
+                "Fat and Protein Percentage",
                 "FPU"
             ],
             path: ["Features", "Meal Settings"]
@@ -230,6 +229,7 @@ enum SettingItems {
                 "Show Carbs Required Badge",
                 "Carbs Required Threshold",
                 "Forecast Display Type",
+                "Bolus Display Threshold",
                 "Cone",
                 "Lines",
                 "Dark Mode",

+ 1 - 8
Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift

@@ -396,7 +396,7 @@ extension SettingsExport {
                 addSetting(
                     category: algorithmCategory,
                     subcategory: smbSubcategory,
-                    name: String(localized: "Max. Allowed Glucose Rise for SMB"),
+                    name: String(localized: "Max Allowed Glucose Rise for SMB"),
                     value: String(format: "%.0f", (preferences.maxDeltaBGthreshold as NSDecimalNumber).doubleValue * 100),
                     unit: "%"
                 )
@@ -704,13 +704,6 @@ extension SettingsExport {
                 addSetting(
                     category: featuresCategory,
                     subcategory: mealSettingsSubcategory,
-                    name: String(localized: "Maximum Duration"),
-                    value: String(describing: trioSettings.timeCap),
-                    unit: String(localized: "hours")
-                )
-                addSetting(
-                    category: featuresCategory,
-                    subcategory: mealSettingsSubcategory,
                     name: String(localized: "Spread Interval"),
                     value: String(describing: trioSettings.minuteInterval),
                     unit: String(localized: "minutes")

+ 4 - 4
Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift

@@ -205,14 +205,14 @@ struct MealStatsView: View {
         }
         .chartForegroundStyleScale([
             "Carbs": Color.orange,
-            "Protein": Color.blue,
-            "Fat": Color.purple
+            "Fat": Color.purple,
+            "Protein": Color.blue
         ])
         .chartLegend(position: .bottom, alignment: .leading, spacing: 12) {
             let legendItems: [(String, Color)] = state.useFPUconversion ? [
                 (String(localized: "Carbs"), Color.orange),
-                (String(localized: "Protein"), Color.blue),
-                (String(localized: "Fat"), Color.purple)
+                (String(localized: "Fat"), Color.purple),
+                (String(localized: "Protein"), Color.blue)
             ] : [(String(localized: "Carbs"), Color.orange)]
 
             let columns = [GridItem(.adaptive(minimum: 65), spacing: 4)]

+ 39 - 17
Trio/Sources/Modules/Treatments/View/ForecastChart.swift

@@ -135,7 +135,7 @@ struct ForecastChart: View {
                     .lineStyle(.init(lineWidth: 2))
                     .annotation(
                         position: .top,
-                        overflowResolution: .init(x: .fit(to: .chart), y: .disabled)
+                        overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
                     ) {
                         selectionPopover
                     }
@@ -221,12 +221,22 @@ struct ForecastChart: View {
                     glucoseColorScheme: state.glucoseColorScheme
                 )
                 HStack {
-                    Text(state.units == .mgdL ? Decimal(sgv).description : Decimal(sgv).formattedAsMmolL)
+                    Text("CGM: ") + Text(state.units == .mgdL ? Decimal(sgv).description : Decimal(sgv).formattedAsMmolL)
                         .bold()
                         + Text(" \(state.units.rawValue)")
                 }.foregroundStyle(
                     Color(glucoseColor)
                 ).font(.footnote)
+
+                if state.isSmoothingEnabled, let smoothedGlucose = selectedGlucose?.smoothedGlucose {
+                    let smoothedGlucoseToDisplay: Decimal = state.units == .mgdL
+                        ? smoothedGlucose.decimalValue
+                        : smoothedGlucose.decimalValue.asMmolL
+                    HStack {
+                        Image(systemName: "sparkles")
+                        Text(smoothedGlucoseToDisplay.description) + Text(" \(state.units.rawValue)")
+                    }.font(.footnote)
+                }
             }
             .padding(7)
             .background {
@@ -259,25 +269,37 @@ struct ForecastChart: View {
                 glucoseColorScheme: state.glucoseColorScheme
             )
 
-            if !state.isSmoothingEnabled {
-                PointMark(
-                    x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
-                )
-                .foregroundStyle(pointMarkColor)
-                .symbolSize(18)
-            } else {
-                PointMark(
-                    x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
-                )
-                .symbol {
-                    Image(systemName: "record.circle.fill")
-                        .font(.system(size: 6))
+            PointMark(
+                x: .value("Time", item.date ?? Date(), unit: .second),
+                y: .value("Value", glucoseToDisplay)
+            )
+            .foregroundStyle(pointMarkColor)
+            .symbol {
+                if item.isManual {
+                    Image(systemName: "drop.fill")
+                        .font(.caption2)
+                        .symbolRenderingMode(.monochrome)
+                        .bold()
+                        .foregroundStyle(.red)
+                } else {
+                    Image(systemName: "circle.fill")
+                        .font(.system(size: 4))
                         .bold()
                         .foregroundStyle(pointMarkColor)
                 }
             }
+
+            if state.isSmoothingEnabled, let smoothedGlucose = item.smoothedGlucose, smoothedGlucose != 0 {
+                let smoothedGlucoseForDisplay: Decimal = state.units == .mgdL
+                    ? smoothedGlucose.decimalValue
+                    : smoothedGlucose.decimalValue.asMmolL
+                LineMark(
+                    x: .value("Time", item.date ?? Date(), unit: .second),
+                    y: .value("Value", smoothedGlucoseForDisplay),
+                    series: .value("Type", "Smoothed")
+                )
+                .foregroundStyle(Color.secondary)
+            }
         }
     }
 

+ 4 - 4
Trio/Sources/Modules/Treatments/View/MealPreset/AddMealPresetView.swift

@@ -83,10 +83,10 @@ struct AddMealPresetView: View {
 
     @ViewBuilder private func proteinAndFat() -> some View {
         HStack {
-            Text("Protein").foregroundColor(.red)
+            Text("Fat").foregroundColor(.orange)
             Spacer()
             TextFieldWithToolBar(
-                text: $presetProtein,
+                text: $presetFat,
                 placeholder: "0",
                 keyboardType: .numberPad,
                 numberFormatter: mealFormatter,
@@ -94,10 +94,10 @@ struct AddMealPresetView: View {
             )
         }
         HStack {
-            Text("Fat").foregroundColor(.orange)
+            Text("Protein").foregroundColor(.red)
             Spacer()
             TextFieldWithToolBar(
-                text: $presetFat,
+                text: $presetProtein,
                 placeholder: "0",
                 keyboardType: .numberPad,
                 numberFormatter: mealFormatter,

+ 32 - 10
Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift

@@ -24,6 +24,7 @@ extension Treatments {
         @State private var calculatorDetent = PresentationDetent.large
         @State private var pushed: Bool = false
         @State private var debounce: DispatchWorkItem?
+        @State private var showFatProteinOrderBanner = false
 
         private enum Config {
             static let dividerHeight: CGFloat = 2
@@ -85,35 +86,35 @@ extension Treatments {
         @ViewBuilder private func proteinAndFat() -> some View {
             HStack {
                 HStack {
-                    Text("Protein")
+                    Text("Fat")
                     TextFieldWithToolBar(
-                        text: $state.protein,
+                        text: $state.fat,
                         placeholder: "0",
                         keyboardType: .numberPad,
                         numberFormatter: mealFormatter,
                         showArrows: true,
-                        previousTextField: { focusedField = previousField(from: .protein) },
-                        nextTextField: { focusedField = nextField(from: .protein) },
+                        previousTextField: { focusedField = previousField(from: .fat) },
+                        nextTextField: { focusedField = nextField(from: .fat) },
                         unitsText: String(localized: "g", comment: "Units for carbs")
                     )
-                    .focused($focusedField, equals: .protein)
+                    .focused($focusedField, equals: .fat)
                 }
 
                 Divider().foregroundStyle(.primary).fontWeight(.bold).frame(width: 10)
 
                 HStack {
-                    Text("Fat")
+                    Text("Protein")
                     TextFieldWithToolBar(
-                        text: $state.fat,
+                        text: $state.protein,
                         placeholder: "0",
                         keyboardType: .numberPad,
                         numberFormatter: mealFormatter,
                         showArrows: true,
-                        previousTextField: { focusedField = previousField(from: .fat) },
-                        nextTextField: { focusedField = nextField(from: .fat) },
+                        previousTextField: { focusedField = previousField(from: .protein) },
+                        nextTextField: { focusedField = nextField(from: .protein) },
                         unitsText: String(localized: "g", comment: "Units for carbs")
                     )
-                    .focused($focusedField, equals: .fat)
+                    .focused($focusedField, equals: .protein)
                 }
             }
         }
@@ -198,6 +199,23 @@ extension Treatments {
 
                             if state.useFPUconversion {
                                 proteinAndFat()
+
+                                if showFatProteinOrderBanner {
+                                    HStack {
+                                        Image(systemName: "arrow.left.arrow.right")
+                                        Text("The order of Fat and Protein inputs has changed.").font(.callout)
+                                        Spacer()
+                                        Button {
+                                            PropertyPersistentFlags.shared.hasSeenFatProteinOrderChange = true
+                                            withAnimation { showFatProteinOrderBanner = false }
+                                        } label: {
+                                            Image(systemName: "xmark.circle.fill")
+                                        }
+                                        .buttonStyle(.plain)
+                                    }
+                                    .listRowBackground(Color.orange.opacity(0.75))
+                                    .transition(.opacity)
+                                }
                             }
 
                             // Time
@@ -391,6 +409,10 @@ extension Treatments {
                     Task { @MainActor in
                         state.insulinCalculated = await state.calculateInsulin()
                     }
+
+                    if PropertyPersistentFlags.shared.hasSeenFatProteinOrderChange != true {
+                        showFatProteinOrderBanner = true
+                    }
                 }
             }
             .onDisappear {

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

@@ -7,6 +7,7 @@ extension UserInterfaceSettings {
         @Published var xGridLines = false
         @Published var yGridLines: Bool = false
         @Published var rulerMarks: Bool = true
+        @Published var bolusDisplayThreshold: BolusDisplayThreshold = .allUnits
         @Published var forecastDisplayType: ForecastDisplayType = .cone
         @Published var showCarbsRequiredBadge: Bool = true
         @Published var carbsRequiredThreshold: Decimal = 0
@@ -23,6 +24,7 @@ extension UserInterfaceSettings {
             subscribeSetting(\.xGridLines, on: $xGridLines) { xGridLines = $0 }
             subscribeSetting(\.yGridLines, on: $yGridLines) { yGridLines = $0 }
             subscribeSetting(\.rulerMarks, on: $rulerMarks) { rulerMarks = $0 }
+            subscribeSetting(\.bolusDisplayThreshold, on: $bolusDisplayThreshold) { bolusDisplayThreshold = $0 }
 
             subscribeSetting(\.forecastDisplayType, on: $forecastDisplayType) { forecastDisplayType = $0 }
 

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

@@ -376,6 +376,48 @@ extension UserInterfaceSettings {
                     }.padding(.bottom)
                 }.listRowBackground(Color.chart)
 
+                Section {
+                    VStack {
+                        Picker(
+                            selection: $state.bolusDisplayThreshold,
+                            label: Text("Bolus Display Threshold")
+                        ) {
+                            ForEach(BolusDisplayThreshold.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }.padding(.top)
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose to hide small bolus amounts. See hint for more details."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    hintLabel = String(localized: "Bolus Display Threshold")
+                                    selectedVerboseHint =
+                                        AnyView(
+                                            VStack(alignment: .leading) {
+                                                Text(
+                                                    "This setting controls which bolus amount labels are shown on Trio’s main chart. Boluses appear as blue upside-down triangles, with a number showing the amount. Depending on the option you choose, only boluses at or above that amount will show a label. For example, if you choose ‘0.5 U and over’, only boluses of 0.5 U or more will show a label."
+                                                )
+                                            }
+                                        )
+                                    shouldDisplayHint.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }.listRowBackground(Color.chart)
+
                 Section(
                     header: Text("Trio Statistics"),
                     content: {

+ 12 - 7
Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift

@@ -39,20 +39,25 @@ extension LiveActivityAttributes.ContentState {
 
     static func calculateChange(chart: [GlucoseData], units: GlucoseUnits) -> String {
         guard chart.count > 2 else { return "" }
-        let lastGlucose = chart.first?.glucose ?? 0
-        let secondLastGlucose = chart.dropFirst().first?.glucose ?? 0
-        let delta = lastGlucose - secondLastGlucose
-        let deltaAsDecimal = units == .mmolL ? Decimal(delta).asMmolL : Decimal(delta)
+
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
         formatter.maximumFractionDigits = 1
+        formatter.positivePrefix = "  +"
+        formatter.negativePrefix = "  -"
+
+        var lastGlucose = Decimal(chart.first?.glucose ?? 0)
+        var secondLastGlucose = Decimal(chart.dropFirst().first?.glucose ?? 0)
         if units == .mmolL {
+            lastGlucose = lastGlucose.asMmolL
+            secondLastGlucose = secondLastGlucose.asMmolL
+
             formatter.minimumFractionDigits = 1
             formatter.maximumFractionDigits = 1
         }
-        formatter.positivePrefix = "  +"
-        formatter.negativePrefix = "  -"
-        return formatter.string(from: deltaAsDecimal as NSNumber) ?? "--"
+
+        let delta = lastGlucose - secondLastGlucose
+        return formatter.string(from: delta as NSNumber) ?? "--"
     }
 
     init(

+ 1 - 0
Trio/Sources/Services/OnboardingManager/OnboardingManager.swift

@@ -24,6 +24,7 @@ import Swinject
     /// Marks onboarding as completed and updates the shouldShowOnboarding flag.
     func completeOnboarding() {
         PropertyPersistentFlags.shared.onboardingCompleted = true
+        PropertyPersistentFlags.shared.hasSeenFatProteinOrderChange = true
         shouldShowOnboarding = false
     }
 

+ 9 - 3
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -321,12 +321,14 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
                 // Calculate delta if we have at least 2 readings
                 if glucoseObjects.count >= 2 {
-                    var deltaValue = Decimal(glucoseObjects[0].glucose - glucoseObjects[1].glucose)
-
+                    var glucoseLast = Decimal(glucoseObjects[0].glucose)
+                    var glucoseSecondLast = Decimal(glucoseObjects[1].glucose)
                     if self.units == .mmolL {
-                        deltaValue = Double(truncating: deltaValue as NSNumber).asMmolL
+                        glucoseLast = glucoseLast.asMmolL
+                        glucoseSecondLast = glucoseSecondLast.asMmolL
                     }
 
+                    let deltaValue = glucoseLast - glucoseSecondLast
                     let formattedDelta = Formatter.glucoseFormatter(for: self.units)
                         .string(from: deltaValue as NSNumber) ?? "0"
                     watchState.delta = deltaValue < 0 ? "\(formattedDelta)" : "+\(formattedDelta)"
@@ -755,6 +757,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 carbEntry.note = String(localized: "Via Watch", comment: "Note added to carb entry when entered via watch")
                 carbEntry.isFPU = false // set this to false to ensure watch-entered carbs are displayed in main chart
                 carbEntry.isUploadedToNS = false
+                carbEntry.isUploadedToHealth = false
+                carbEntry.isUploadedToTidepool = false
 
                 do {
                     guard context.hasChanges else {
@@ -817,6 +821,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     carbEntry.note = String(localized: "Via Watch", comment: "Note added to carb entry when entered via watch")
                     carbEntry.isFPU = false // set this to false to ensure watch-entered carbs are displayed in main chart
                     carbEntry.isUploadedToNS = false
+                    carbEntry.isUploadedToHealth = false
+                    carbEntry.isUploadedToTidepool = false
 
                     guard context.hasChanges else {
                         // Acknowledge failure

+ 1 - 1
Trio/Sources/Shortcuts/Bolus/BolusIntent.swift

@@ -3,7 +3,7 @@ import Foundation
 import Intents
 import Swinject
 
-@available(iOS 16.0,*) struct BolusIntent: AppIntent {
+struct BolusIntent: AppIntent {
     // Title of the action in the Shortcuts app
     static var title = LocalizedStringResource("Enact Bolus")
 

+ 1 - 1
Trio/Sources/Shortcuts/Bolus/BolusIntentRequest.swift

@@ -2,7 +2,7 @@ import Combine
 import CoreData
 import Foundation
 
-@available(iOS 16.0,*) final class BolusIntentRequest: BaseIntentsRequest {
+final class BolusIntentRequest: BaseIntentsRequest {
     func bolus(_ bolusAmount: Double) async throws -> String {
         var bolusQuantity: Decimal = 0
         switch settingsManager.settings.bolusShortcut {

+ 59 - 17
Trio/Sources/Shortcuts/Carbs/AddCarbPresetIntent.swift

@@ -3,7 +3,7 @@ import Foundation
 import Intents
 import Swinject
 
-@available(iOS 16.0,*) struct AddCarbPresetIntent: AppIntent {
+struct AddCarbPresetIntent: AppIntent {
     // Title of the action in the Shortcuts app
     static var title: LocalizedStringResource = "Add carbs"
 
@@ -14,25 +14,25 @@ import Swinject
         title: "Quantity Carbs",
         description: "Quantity of carbs in g",
         controlStyle: .field,
-        inclusiveRange: (lowerBound: 0, upperBound: 200),
-        requestValueDialog: IntentDialog(stringLiteral: String(localized: "How many grams of carbs did you eat?"))
-    ) var carbQuantity: Double?
+        inclusiveRange: (lowerBound: 0, upperBound: 300),
+        requestValueDialog: IntentDialog(stringLiteral: String(localized: "How many grams of carbs?"))
+    ) var carbQuantity: Int?
 
     @Parameter(
         title: "Quantity Fat",
         description: "Quantity of fat in g",
-        default: 0.0,
-        inclusiveRange: (0, 200),
-        requestValueDialog: IntentDialog(stringLiteral: String(localized: "How many grams of fat did you eat?"))
-    ) var fatQuantity: Double
+        default: 0,
+        inclusiveRange: (0, 300),
+        requestValueDialog: IntentDialog(stringLiteral: String(localized: "How many grams of fat?"))
+    ) var fatQuantity: Int
 
     @Parameter(
         title: "Quantity Protein",
         description: "Quantity of Protein in g",
-        default: 0.0,
-        inclusiveRange: (0, 200),
-        requestValueDialog: IntentDialog(stringLiteral: String(localized: "How many grams of protein did you eat?"))
-    ) var proteinQuantity: Double
+        default: 0,
+        inclusiveRange: (0, 300),
+        requestValueDialog: IntentDialog(stringLiteral: String(localized: "How many grams of protein?"))
+    ) var proteinQuantity: Int
 
     @Parameter(
         title: "Date",
@@ -46,7 +46,7 @@ import Swinject
     ) var note: String?
 
     @Parameter(
-        title: "Confirm Before logging",
+        title: "Confirm Before Logging",
         description: "If toggled, you will need to confirm before logging",
         default: true
     ) var confirmBeforeApplying: Bool
@@ -71,13 +71,46 @@ import Swinject
 
     @MainActor func perform() async throws -> some ProvidesDialog {
         do {
-            let quantityCarbs: Double
+            let quantityCarbs: Int
             if let cq = carbQuantity {
                 quantityCarbs = cq
             } else {
                 quantityCarbs = try await $carbQuantity.requestValue("How many grams of carbs?")
             }
 
+            let request = CarbPresetIntentRequest()
+            let maxCarbs = Int(truncating: request.settingsManager.settings.maxCarbs as NSDecimalNumber)
+            let maxFat = Int(truncating: request.settingsManager.settings.maxFat as NSDecimalNumber)
+            let maxProtein = Int(truncating: request.settingsManager.settings.maxProtein as NSDecimalNumber)
+
+            guard quantityCarbs <= maxCarbs else {
+                return .result(
+                    dialog: IntentDialog(
+                        stringLiteral: String(
+                            localized: "Logging Failed: Max Carbs = \(maxCarbs) g"
+                        )
+                    )
+                )
+            }
+            guard proteinQuantity <= maxProtein else {
+                return .result(
+                    dialog: IntentDialog(
+                        stringLiteral: String(
+                            localized: "Logging Failed: Max Protein = \(maxProtein) g"
+                        )
+                    )
+                )
+            }
+            guard fatQuantity <= maxFat else {
+                return .result(
+                    dialog: IntentDialog(
+                        stringLiteral: String(
+                            localized: "Logging Failed: Max Fat = \(maxFat) g"
+                        )
+                    )
+                )
+            }
+
             let dateCarbsAdded: Date
             let dateDefinedByUser: Bool
             if let da = dateAdded {
@@ -88,16 +121,25 @@ import Swinject
                 dateDefinedByUser = false
             }
 
-            let quantityCarbsName = quantityCarbs.toString()
             if confirmBeforeApplying {
+                var confirmationMessage: String
+                confirmationMessage = String(localized: "Add \(quantityCarbs) g carbs")
+                if fatQuantity > 0 {
+                    confirmationMessage = String(localized: "\(confirmationMessage) and \(fatQuantity) g fat")
+                }
+                if proteinQuantity > 0 {
+                    confirmationMessage = String(localized: "\(confirmationMessage) and \(proteinQuantity) g protein")
+                }
+                confirmationMessage = String(localized: "\(confirmationMessage)?")
+
                 try await requestConfirmation(
                     result: .result(
-                        dialog: IntentDialog(stringLiteral: String(localized: "Add \(quantityCarbsName) grams of carbs?"))
+                        dialog: IntentDialog(stringLiteral: confirmationMessage)
                     )
                 )
             }
 
-            let finalQuantityCarbsDisplay = try await CarbPresetIntentRequest().addCarbs(
+            let finalQuantityCarbsDisplay = try await request.addCarbs(
                 quantityCarbs,
                 fatQuantity,
                 proteinQuantity,

+ 12 - 14
Trio/Sources/Shortcuts/Carbs/CarbPresetIntentRequest.swift

@@ -1,27 +1,25 @@
 import CoreData
 import Foundation
 
-@available(iOS 16.0,*) final class CarbPresetIntentRequest: BaseIntentsRequest {
+final class CarbPresetIntentRequest: BaseIntentsRequest {
     func addCarbs(
-        _ quantityCarbs: Double,
-        _ quantityFat: Double,
-        _ quantityProtein: Double,
+        _ quantityCarbs: Int,
+        _ quantityFat: Int,
+        _ quantityProtein: Int,
         _ dateAdded: Date,
         _ note: String?,
         _ dateDefinedByUser: Bool
     ) async throws -> String {
-        guard quantityCarbs >= 0.0 || quantityFat >= 0.0 || quantityProtein >= 0.0 else {
-            return "not adding carbs in Trio"
+        guard quantityCarbs >= 0 || quantityFat >= 0 || quantityProtein >= 0 else {
+            return "Amount must be positive."
         }
 
-        let carbs = min(Decimal(quantityCarbs), settingsManager.settings.maxCarbs)
-
         try await carbsStorage.storeCarbs(
             [CarbsEntry(
                 id: UUID().uuidString,
                 createdAt: dateAdded,
                 actualDate: dateAdded,
-                carbs: carbs,
+                carbs: Decimal(quantityCarbs),
                 fat: Decimal(quantityFat),
                 protein: Decimal(quantityProtein),
                 note: (note?.isEmpty ?? true) ? "Via Shortcut" : note!,
@@ -31,12 +29,12 @@ import Foundation
             areFetchedFromRemote: false
         )
         var resultDisplay: String
-        resultDisplay = String(localized: "Added \(String(format: "%.0f", Double(carbs))) g carbs")
-        if quantityFat > 0.0 {
-            resultDisplay = String(localized: "\(resultDisplay) and \(String(format: "%.0f", Double(quantityFat))) g fat")
+        resultDisplay = String(localized: "Added \(quantityCarbs) g carbs")
+        if quantityFat > 0 {
+            resultDisplay = String(localized: "\(resultDisplay) and \(quantityFat) g fat")
         }
-        if quantityProtein > 0.0 {
-            resultDisplay = String(localized: "\(resultDisplay) and \(String(format: "%.0f", Double(quantityProtein))) g protein")
+        if quantityProtein > 0 {
+            resultDisplay = String(localized: "\(resultDisplay) and \(quantityProtein) g protein")
         }
         if dateDefinedByUser {
             let dateFormatter = DateFormatter()

+ 0 - 2
Trio/Sources/Views/SettingInputSection.swift

@@ -99,8 +99,6 @@ struct SettingInputSection<VerboseHint: View>: View {
             return pickerSettingsProvider.settings.individualAdjustmentFactor
         case "delay":
             return pickerSettingsProvider.settings.delay
-        case "timeCap":
-            return pickerSettingsProvider.settings.timeCap
         case "minuteInterval":
             return pickerSettingsProvider.settings.minuteInterval
         case "high":

+ 234 - 0
TrioTests/CoreDataTests/CarbsStorageTests.swift

@@ -123,6 +123,240 @@ import Testing
         #expect(remainingEntries?.isEmpty == true, "Should have no entries after deletion")
     }
 
+    @Test(
+        "Store carb entry with fat/protein creates capped, spaced FPU entries (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreFatProteinCarbEntryCreatesFPUEntries() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
+
+        // Defaults:
+        // adjustment = 0.5, delay = 60
+        //
+        // fat=50g -> 450 kcal
+        // protein=100g -> 400 kcal
+        // kcal total = 850
+        // (kcal/10) = 85
+        // 85 * 0.5 = 42.5
+        // Int(42.5) = 42 equivalents -> two FPU entries: 21g each
+        let mealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 50,
+            protein: 100,
+            note: "FPU deterministic default split test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([mealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored entries")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 50, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 100, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.count == 2, "Expected exactly one FPU entry under default settings")
+        #expect(Int(fpuEntries[0].carbs) == 21, "Expected 20g carb equivalents under default settings")
+
+        for fpuEntry in fpuEntries {
+            #expect(fpuEntry.fat == 0, "FPU fat must be 0")
+            #expect(fpuEntry.protein == 0, "FPU protein must be 0")
+            #expect(fpuEntry.carbs >= 10, "FPU carbs must be >= 10g")
+            #expect(fpuEntry.carbs <= 33, "FPU carbs must be <= 33g")
+            #expect(Double(fpuEntry.carbs).truncatingRemainder(dividingBy: 1) == 0, "FPU carbs must be whole grams")
+        }
+
+        let scheduledTotal = fpuEntries.reduce(0) { partialResult, fpuEntry in
+            partialResult + Int(fpuEntry.carbs)
+        }
+        #expect(scheduledTotal <= 99, "Scheduled FPU carbs must be capped at 99g")
+
+        // Timing: stable assertions
+        // - first FPU entry must be at least +60m after the *input* timestamp (createdAt/actualDate),
+        //   but storage may choose a different internal baseDate, so don't assert exact equality.
+        let fpuDates = fpuEntries.compactMap(\.date).sorted()
+        #expect(fpuDates.count == 2, "FPU entry should have a date")
+
+        let firstFpuDate = fpuDates[0]
+        #expect(
+            firstFpuDate >= baseDate.addingTimeInterval(60 * 60),
+            "First FPU entry should not be scheduled earlier than +60 minutes after the input timestamp"
+        )
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
+    @Test(
+        "Store very large fat/protein meal caps FPU equivalents at 99g and splits into 3×33g (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreVeryLargeFatProteinMealCapsAndSplits() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_001_000)
+
+        // Defaults:
+        // adjustment = 0.5, delay = 60
+        //
+        // fat=200g -> 1800 kcal
+        // protein=200g -> 800 kcal
+        // kcal total = 2600
+        // (kcal/10) = 260
+        // 260 * 0.5 = 130
+        // Int(130) = 130 -> capped to 99 -> split into [33, 33, 33]
+        let heftyMealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 200,
+            protein: 200,
+            note: "Hefty BBQ meal - cap test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([heftyMealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored entries")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 200, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 200, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.count == 3, "Capped large meal should create exactly 3 FPU entries")
+
+        let fpuGrams = fpuEntries.map { Int($0.carbs) }
+        #expect(fpuGrams == [33, 33, 33], "Expected capped split to be [33, 33, 33]")
+
+        let scheduledTotal = fpuEntries.reduce(0) { partialResult, fpuEntry in
+            partialResult + Int(fpuEntry.carbs)
+        }
+        #expect(scheduledTotal == 99, "Total scheduled FPU grams should be exactly 99g after cap")
+
+        for fpuEntry in fpuEntries {
+            #expect(fpuEntry.fat == 0, "FPU entry fat must be 0")
+            #expect(fpuEntry.protein == 0, "FPU entry protein must be 0")
+            #expect(fpuEntry.carbs >= 10, "FPU entry carbs must be >= 10g")
+            #expect(fpuEntry.carbs <= 33, "FPU entry carbs must be <= 33g")
+            #expect(Double(fpuEntry.carbs).truncatingRemainder(dividingBy: 1) == 0, "FPU carbs must be whole grams")
+        }
+
+        // Timing: stable assertions
+        let fpuDates = fpuEntries.compactMap(\.date).sorted()
+        #expect(fpuDates.count == 3, "All FPU entries should have a date")
+
+        let firstFpuDate = fpuDates[0]
+        #expect(
+            firstFpuDate >= baseDate.addingTimeInterval(60 * 60),
+            "First FPU entry should not be scheduled earlier than +60 minutes after the input timestamp"
+        )
+
+        for index in 1 ..< fpuDates.count {
+            let spacingSeconds = fpuDates[index].timeIntervalSince(fpuDates[index - 1])
+            #expect(Int(spacingSeconds) == 30 * 60, "FPU entries should be spaced +30 minutes apart")
+        }
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
+    @Test(
+        "Store small fat/protein meal drops FPU equivalents when total would be <10g (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreSmallFatProteinMealDropsFPUBelowMinimum() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_002_000)
+
+        // Defaults:
+        // adjustment = 0.5
+        //
+        // fat=2g -> 18 kcal
+        // protein=2g -> 8 kcal
+        // kcal total = 26
+        // (kcal/10) = 2.6
+        // 2.6 * 0.5 = 1.3
+        // Int(1.3) = 1 (<10) -> should be dropped (no FPU entries)
+        let smallMealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 2,
+            protein: 2,
+            note: "Tiny macros - min threshold test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([smallMealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored at least the original entry")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 2, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 2, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.isEmpty == true, "No FPU entries should be created when equivalents are <10g")
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
     @Test("Get carbs not yet uploaded to Nightscout") func testGetCarbsNotYetUploadedToNightscout() async throws {
         // Given
         let testEntry = CarbsEntry(

+ 315 - 0
TrioTests/GlucoseSmoothingTests.swift

@@ -0,0 +1,315 @@
+import CoreData
+import Foundation
+import LoopKitUI
+import Swinject
+import Testing
+
+@testable import Trio
+
+@Suite("Glucose Smoothing Tests", .serialized) struct GlucoseSmoothingTests: Injectable {
+    let resolver: Resolver
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
+    var fetchGlucoseManager: BaseFetchGlucoseManager!
+    var openAPS: OpenAPS!
+
+    init() async throws {
+        coreDataStack = try await CoreDataStack.createForTests()
+        testContext = coreDataStack.newTaskContext()
+
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext)
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+
+        fetchGlucoseManager = resolver.resolve(FetchGlucoseManager.self)! as? BaseFetchGlucoseManager
+
+        let fileStorage = resolver.resolve(FileStorage.self)!
+        openAPS = OpenAPS(storage: fileStorage, tddStorage: MockTDDStorage())
+    }
+
+    // MARK: - Exponential Smoothing Tests
+
+    @Test(
+        "Exponential smoothing writes smoothed glucose for CGM values when enough data exists"
+    ) func testExponentialSmoothingStoresSmoothedValues() async throws {
+        let glucoseValues: [Int16] = [100, 105, 110, 115, 120, 125]
+        await createGlucoseSequence(values: glucoseValues, interval: 5 * 60, isManual: false)
+
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        let fetchedAscending = try await fetchAndSortGlucose()
+
+        // We expect at least the most recent few values to get smoothed values written.
+        // The Kotlin/port writes to data[i] for i in 0..<limit, where data is newest-first.
+        // With 6 values:
+        // - recordCount = 6
+        // - validWindowCount starts at 5, no gap => remains 5
+        // - smoothing produces blended.count == 5
+        // - apply limit = min(5, 6) = 5 => most recent 5 entries get smoothedGlucose
+        //
+        // In ascending order, "most recent 5" are indices 1...5. Oldest (index 0) is not guaranteed to be updated.
+        #expect(fetchedAscending.count == 6)
+
+        let smoothedValues = fetchedAscending.compactMap { $0.smoothedGlucose?.decimalValue }
+        #expect(smoothedValues.count >= 5, "Expected at least 5 smoothed values to be stored.")
+
+        for (i, value) in smoothedValues.enumerated() {
+            #expect(value >= 39, "Smoothed glucose at index \(i) should be clamped to at least 39, got \(value).")
+            #expect(
+                value == value.rounded(toPlaces: 0),
+                "Smoothed glucose at index \(i) should be rounded to an integer, got \(value)."
+            )
+        }
+    }
+
+    @Test("Exponential smoothing does not smooth manual glucose entries") func testExponentialSmoothingIgnoresManual() async throws {
+        // GIVEN: Mixed manual + CGM values
+        await createGlucoseSequence(values: [100, 105, 110, 115, 120].map(Int16.init), interval: 5 * 60, isManual: false)
+        await createGlucose(glucose: 130, smoothed: nil, isManual: true, date: Date().addingTimeInterval(6 * 5 * 60))
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let allAscending = try await fetchAndSortGlucose()
+        let manual = allAscending.first(where: { $0.isManual })
+
+        #expect(manual != nil, "Expected a manual glucose entry.")
+        #expect(manual?.smoothedGlucose == nil, "Manual entries must not be smoothed/stored.")
+    }
+
+    @Test(
+        "Exponential smoothing clamps smoothed glucose to >= 39 and rounds to integer"
+    ) func testExponentialSmoothingClampAndRounding() async throws {
+        // GIVEN
+        let glucoseValues: [Int16] = [40, 39, 41, 42, 43, 44]
+        await createGlucoseSequence(values: glucoseValues, interval: 5 * 60, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let fetchedAscending = try await fetchAndSortGlucose()
+
+        let smoothedValues = fetchedAscending
+            .compactMap { $0.smoothedGlucose?.decimalValue }
+            .filter { $0 > 0 }
+
+        #expect(!smoothedValues.isEmpty, "Expected at least one smoothed glucose value to be stored.")
+
+        for (index, smoothed) in smoothedValues.enumerated() {
+            #expect(
+                smoothed >= 39,
+                "Smoothed glucose must be clamped to >= 39, got \(smoothed) at index \(index)."
+            )
+
+            #expect(
+                smoothed == smoothed.rounded(toPlaces: 0),
+                "Smoothed glucose must be an integer value, got \(smoothed) at index \(index)."
+            )
+        }
+    }
+
+    @Test(
+        "Exponential smoothing stops window at gaps >= 12 minutes; fallback fills smoothed glucose"
+    ) func testExponentialSmoothingGapStopsWindow() async throws {
+        // GIVEN:
+        let now = Date()
+        let dates: [Date] = [
+            now.addingTimeInterval(0), // oldest
+            now.addingTimeInterval(5 * 60),
+            now.addingTimeInterval(10 * 60),
+            now.addingTimeInterval(25 * 60), // gap of 15 minutes
+            now.addingTimeInterval(30 * 60),
+            now.addingTimeInterval(35 * 60) // newest
+        ]
+        let values: [Int16] = [100, 105, 110, 115, 120, 125]
+        await createGlucoseSequence(values: values, dates: dates, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let ascending = try await fetchAndSortGlucose()
+        #expect(ascending.count == 6)
+
+        let smoothedValues = ascending
+            .filter { !$0.isManual }
+            .compactMap { $0.smoothedGlucose?.decimalValue }
+            .filter { $0 > 0 }
+
+        #expect(
+            smoothedValues.count == 6,
+            "Fallback path should fill smoothedGlucose for all CGM entries when the gap reduces the window below minimum size."
+        )
+
+        for (index, smoothed) in smoothedValues.enumerated() {
+            #expect(
+                smoothed >= 39,
+                "Fallback smoothed glucose must be clamped to >= 39, got \(smoothed) at index \(index)."
+            )
+            #expect(
+                smoothed == smoothed.rounded(toPlaces: 0),
+                "Fallback smoothed glucose must be rounded to an integer, got \(smoothed) at index \(index)."
+            )
+        }
+    }
+
+    @Test(
+        "Exponential smoothing treats 38 mg/dL as xDrip error and clamps stored smoothed glucose"
+    ) func testExponentialSmoothingXDrip38StopsWindow() async throws {
+        // GIVEN
+        let values: [Int16] = [100, 105, 110, 38, 120, 125]
+        await createGlucoseSequence(values: values, interval: 5 * 60, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let ascending = try await fetchAndSortGlucose()
+        #expect(ascending.count == 6)
+
+        let smoothedValues = ascending
+            .compactMap { $0.smoothedGlucose?.decimalValue }
+            .filter { $0 > 0 }
+
+        #expect(
+            !smoothedValues.isEmpty,
+            "Expected at least one smoothed glucose value to be stored."
+        )
+
+        for (index, smoothed) in smoothedValues.enumerated() {
+            #expect(
+                smoothed >= 39,
+                "Smoothed glucose must be clamped to >= 39 even around xDrip 38, got \(smoothed) at index \(index)."
+            )
+            #expect(
+                smoothed == smoothed.rounded(toPlaces: 0),
+                "Smoothed glucose must be rounded to an integer, got \(smoothed) at index \(index)."
+            )
+        }
+    }
+
+    // MARK: - OpenAPS Glucose Selection Tests
+
+    @Test("Algorithm uses smoothed glucose when enabled") func testAlgorithmUsesSmoothedGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 140,
+            "Algorithm should have used the smoothed glucose value (140), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm uses raw glucose when smoothing is disabled") func testAlgorithmUsesRawGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: false)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have used the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm falls back to raw glucose if smoothed value is missing") func testAlgorithmFallbackToRawGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: nil, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have fallen back to the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm ignores smoothed value for manual glucose entries") func testAlgorithmIgnoresSmoothedManualGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: true, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have ignored smoothing for a manual entry and used the raw value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    // MARK: - Helpers
+
+    private func runFetchAndProcessGlucose(smoothGlucose: Bool) async throws -> [AlgorithmGlucose] {
+        let jsonString = try await openAPS.fetchAndProcessGlucose(
+            context: testContext,
+            shouldSmoothGlucose: smoothGlucose,
+            fetchLimit: 10
+        )
+
+        let data = jsonString.data(using: .utf8)!
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .custom { decoder in
+            let container = try decoder.singleValueContainer()
+            let dateDouble = try container.decode(Double.self)
+            return Date(timeIntervalSince1970: dateDouble / 1000)
+        }
+
+        return try decoder.decode([AlgorithmGlucose].self, from: data)
+    }
+
+    private func createGlucose(glucose: Int16, smoothed: Decimal?, isManual: Bool, date: Date) async {
+        await testContext.perform {
+            let object = GlucoseStored(context: self.testContext)
+            object.date = date
+            object.glucose = glucose
+            object.smoothedGlucose = smoothed as NSDecimalNumber?
+            object.isManual = isManual
+            object.id = UUID()
+            try! self.testContext.save()
+        }
+    }
+
+    private func createGlucoseSequence(values: [Int16], dates: [Date], isManual: Bool) async {
+        precondition(values.count == dates.count)
+
+        await testContext.perform {
+            for (i, value) in values.enumerated() {
+                let object = GlucoseStored(context: self.testContext)
+                object.date = dates[i]
+                object.glucose = value
+                object.smoothedGlucose = nil
+                object.isManual = isManual
+                object.id = UUID()
+            }
+            try! self.testContext.save()
+        }
+    }
+
+    private func createGlucoseSequence(values: [Int16], interval: TimeInterval, isManual: Bool) async {
+        let now = Date()
+        let dates = values.indices.map { now.addingTimeInterval(Double($0) * interval) }
+        await createGlucoseSequence(values: values, dates: dates, isManual: isManual)
+    }
+
+    private func fetchAndSortGlucose() async throws -> [GlucoseStored] {
+        try await coreDataStack.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: .all,
+            key: "date",
+            ascending: true
+        ) as? [GlucoseStored] ?? []
+    }
+}

+ 2 - 2
TrioTests/JSONImporterTests.swift

@@ -92,7 +92,7 @@ class BundleReference {}
         ) as? [PumpEventStored] ?? []
 
         let objectIds = allReadings.map(\.objectID)
-        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, from: context)
+        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, orphanedResumes: [], from: context)
 
         var bolusTotal = 0.0
         var bolusCount = 0
@@ -172,7 +172,7 @@ class BundleReference {}
         ) as? [PumpEventStored] ?? []
 
         let objectIds = allReadings.map(\.objectID)
-        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, from: context)
+        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, orphanedResumes: [], from: context)
 
         #expect(parsedHistory.count == 1)
 

+ 15 - 0
TrioTests/Mocks/MockTDDStorage.swift

@@ -0,0 +1,15 @@
+import LoopKitUI
+@testable import Trio
+
+struct MockTDDStorage: TDDStorage {
+    func calculateTDD(
+        pumpManager _: any LoopKitUI.PumpManagerUI,
+        pumpHistory _: [Trio.PumpHistoryEvent],
+        basalProfile _: [Trio.BasalProfileEntry]
+    ) async throws -> Trio.TDDResult {
+        TDDResult(total: 0, bolus: 0, tempBasal: 0, scheduledBasal: 0, weightedAverage: 0, hoursOfData: 0)
+    }
+
+    func storeTDD(_: Trio.TDDResult) async { /* skip */ }
+    func hasSufficientTDD() async throws -> Bool { true }
+}

+ 1 - 1
dexcom-share-client-swift

@@ -1 +1 @@
-Subproject commit 82a9179d444b3e79d5e9cfe99bbe7f298c4e8b40
+Subproject commit 8c4f0edfe9356463c66a2e5dff9d00794291ebfd

+ 36 - 0
scripts/define_common_trio.sh

@@ -0,0 +1,36 @@
+#!/bin/zsh
+
+# copied from Loop where this definition was used by more than one script
+# initially we probably will not need that capability, but does no harm to keep it
+# define parameters and arrays used by more than one script
+#   These are always capitalized
+#      TRIO_PROJECTS
+
+# include this file in each script using
+#   source scripts/define_commont_trio.sh
+
+# define the TRIO_PROJECTS used by Trio where for Trio the .gitmodules points
+#   to the downstream fork of loopandlearn in all cases
+#
+# The only submodule that needs a special trio branch (at this time) is LoopKit
+#    put that repository first in the list
+#
+# The scrips in LoopWorkspace are used to update translations and make
+#   sure the loopandlearn branches are up to date
+# Even though MedtrumKit and EversenseKit are not yet part of dev
+#   it does no harm to update those repositories using this script
+TRIO_PROJECTS=( \
+    loopandlearn:LoopKit:trio \
+    loopandlearn:CGMBLEKit:dev \
+    loopandlearn:dexcom-share-client-swift:dev \
+    loopandlearn:G7SensorKit:main \
+    loopandlearn:LibreTransmitter:main \
+    loopandlearn:MinimedKit:main \
+    loopandlearn:OmniBLE:dev \
+    loopandlearn:OmniKit:main \
+    loopandlearn:RileyLinkKit:dev \
+    loopandlearn:TidepoolService:dev \
+    loopandlearn:DanaKit:dev \
+    loopandlearn:EversenseKit:dev \
+    loopandlearn:MedtrumKit:dev \
+)

+ 13 - 0
scripts/update_submodules_trio.sh

@@ -0,0 +1,13 @@
+#!/bin/zsh
+
+source scripts/define_common_trio.sh
+
+for project in ${TRIO_PROJECTS}; do
+  echo "Updating to $project"
+  IFS=":" read user dir branch <<< "$project"
+  echo "Updating to $branch on $user/$project"
+  cd $dir
+  git checkout $branch
+  git pull
+  cd -
+done