Explorar el Código

Various. (#49)

* Fix tempBasalString in HomeRootView sometimes not showing up

* First try of refactoring JavaScriptWorker and JS calls; HIGHLY WIP

* Update OmniBLE dependencies

* Add @nas10 tabbar reset
Deniz Cengiz hace 1 año
padre
commit
3ab6b237a2
Se han modificado 100 ficheros con 8751 adiciones y 0 borrados
  1. 87 0
      Dependencies/LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/LoopKit Example.xcscheme
  2. 76 0
      Dependencies/LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/Shared-watchOS.xcscheme
  3. 161 0
      Dependencies/LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/Shared.xcscheme
  4. 37 0
      Dependencies/LoopKit/LoopKit/DataOutputStream.swift
  5. 25 0
      Dependencies/LoopKit/LoopKit/DeviceManager/BolusActivationType.swift
  6. 38 0
      Dependencies/LoopKit/LoopKit/FavoriteFood/FavoriteFood.swift
  7. 23 0
      Dependencies/LoopKit/LoopKit/FavoriteFood/NewFavoriteFood.swift
  8. 62 0
      Dependencies/LoopKit/LoopKit/FavoriteFood/StoredFavoriteFood.swift
  9. 83 0
      Dependencies/LoopKit/LoopKit/GlucoseKit/CgmEvent.swift
  10. 229 0
      Dependencies/LoopKit/LoopKit/GlucoseKit/CgmEventStore.swift
  11. 57 0
      Dependencies/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift
  12. 576 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/DoseMath.swift
  13. 29 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/GlucosePredictionAlgorithm.swift
  14. 190 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopAlgorithm.swift
  15. 21 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopAlgorithmInput.swift
  16. 129 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopAlgorithmSettings.swift
  17. 94 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopPredictionInput.swift
  18. 49 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopPredictionOutput.swift
  19. 22 0
      Dependencies/LoopKit/LoopKit/Pluggable.swift
  20. 226 0
      Dependencies/LoopKit/LoopKit/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift
  21. 40 0
      Dependencies/LoopKit/LoopKit/RetrospectiveCorrection/RetrospectiveCorrection.swift
  22. 71 0
      Dependencies/LoopKit/LoopKit/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift
  23. 16 0
      Dependencies/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift
  24. 57 0
      Dependencies/LoopKit/LoopKit/Service/StatefulPluggable.swift
  25. 160 0
      Dependencies/LoopKit/LoopKitTests/Charts/ChartAxisValuesStaticGeneratorTests.swift
  26. 147 0
      Dependencies/LoopKit/LoopKitTests/Charts/PredictedGlucoseChartTests.swift
  27. 1491 0
      Dependencies/LoopKit/LoopKitTests/DoseMathTests.swift
  28. 16 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/far_future_high_bg_forecast.json
  29. 24 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/far_future_high_bg_forecast_after_6_hours.json
  30. 44 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/read_selected_basal_profile.json
  31. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_correct_low_at_min.json
  32. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_dropping_then_rising.json
  33. 4 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_flat_and_high.json
  34. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_high_and_falling.json
  35. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_high_and_rising.json
  36. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_in_range_and_rising.json
  37. 4 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_no_change_glucose.json
  38. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_high_end_in_range.json
  39. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_high_end_low.json
  40. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_low_end_high.json
  41. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_low_end_in_range.json
  42. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_very_low_end_high.json
  43. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_very_low_end_in_range.json
  44. 43 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommended_temp_start_low_end_just_above_range.json
  45. 73 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/AbsorptionTimePickerRow.swift
  46. 105 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/CarbQuantityRow.swift
  47. 150 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/DatePickerRow.swift
  48. 49 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/EmojiRow.swift
  49. 135 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/FoodTypeRow.swift
  50. 68 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/RowEmojiTextField.swift
  51. 84 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/RowTextfield.swift
  52. 55 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/TextFieldRow.swift
  53. 105 0
      Dependencies/LoopKit/LoopKitUI/Charts/COBChart.swift
  54. 221 0
      Dependencies/LoopKit/LoopKitUI/Charts/CarbEffectChart.swift
  55. 26 0
      Dependencies/LoopKit/LoopKitUI/Charts/ChartConstants.swift
  56. 210 0
      Dependencies/LoopKit/LoopKitUI/Charts/DoseChart.swift
  57. 117 0
      Dependencies/LoopKit/LoopKitUI/Charts/IOBChart.swift
  58. 352 0
      Dependencies/LoopKit/LoopKitUI/Charts/PredictedGlucoseChart.swift
  59. 28 0
      Dependencies/LoopKit/LoopKitUI/Extensions/CGPoint.swift
  60. 104 0
      Dependencies/LoopKit/LoopKitUI/Extensions/ChartAxisValuesStaticGenerator.swift
  61. 144 0
      Dependencies/LoopKit/LoopKitUI/Extensions/ChartPoint.swift
  62. 73 0
      Dependencies/LoopKit/LoopKitUI/Extensions/CollectionType.swift
  63. 27 0
      Dependencies/LoopKit/LoopKitUI/Extensions/NumberFormatter+Charts.swift
  64. 56 0
      Dependencies/LoopKit/LoopKitUI/Models/ChartAxisValueDoubleLog.swift
  65. 58 0
      Dependencies/LoopKit/LoopKitUI/ViewModels/DisplayGlucosePreference.swift
  66. 122 0
      Dependencies/LoopKit/LoopKitUI/Views/ChartPointsContextFillLayer.swift
  67. 54 0
      Dependencies/LoopKit/LoopKitUI/Views/ChartPointsScatterDownTrianglesLayer.swift
  68. 117 0
      Dependencies/LoopKit/LoopKitUI/Views/ChartPointsTouchHighlightLayerViewCache.swift
  69. 52 0
      Dependencies/LoopKit/LoopKitUI/Views/DateAndDurationTableViewCell.swift
  70. 74 0
      Dependencies/LoopKit/LoopKitUI/Views/DateAndDurationTableViewCell.xib
  71. 47 0
      Dependencies/LoopKit/LoopKitUI/Views/DecimalTextFieldTableViewCell.swift
  72. 50 0
      Dependencies/LoopKit/LoopKitUI/Views/DemoPlaceHolderView.swift
  73. 122 0
      Dependencies/LoopKit/LoopKitUI/Views/FavoriteFoodListRow.swift
  74. 12 0
      Dependencies/LoopKit/LoopKitUI/Views/HUDAssets.xcassets/reservoir/generic-reservoir-mask.imageset/Contents.json
  75. BIN
      Dependencies/LoopKit/LoopKitUI/Views/HUDAssets.xcassets/reservoir/generic-reservoir-mask.imageset/generic-reservoir-fill.png
  76. 12 0
      Dependencies/LoopKit/LoopKitUI/Views/HUDAssets.xcassets/reservoir/generic-reservoir.imageset/Contents.json
  77. BIN
      Dependencies/LoopKit/LoopKitUI/Views/HUDAssets.xcassets/reservoir/generic-reservoir.imageset/generic-reservoir-outline.png
  78. 21 0
      Dependencies/LoopKit/LoopKitUI/Views/ListButtonStyle.swift
  79. 14 0
      Dependencies/LoopKit/LoopTestingKit/DeviceAction.swift
  80. 20 0
      Dependencies/LoopKit/MockKitUI/Extensions/Color.swift
  81. 60 0
      Dependencies/LoopKit/MockKitUI/Extensions/HKUnit.swift
  82. 20 0
      Dependencies/LoopKit/MockKitUI/Extensions/Image.swift
  83. 6 0
      Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Colors/Contents.json
  84. 20 0
      Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Colors/LightGrey.colorset/Contents.json
  85. 6 0
      Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Reservoir/Contents.json
  86. 12 0
      Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Reservoir/generic-reservoir-mask.imageset/Contents.json
  87. BIN
      Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Reservoir/generic-reservoir-mask.imageset/generic-reservoir-fill.png
  88. 12 0
      Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Reservoir/generic-reservoir.imageset/Contents.json
  89. BIN
      Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Reservoir/generic-reservoir.imageset/generic-reservoir-outline.png
  90. 119 0
      Dependencies/LoopKit/MockKitUI/ViewModel/MockCGMManagerSettingsViewModel.swift
  91. 175 0
      Dependencies/LoopKit/MockKitUI/ViewModel/MockPumpManagerSettingsViewModel.swift
  92. 51 0
      Dependencies/LoopKit/MockKitUI/ViewModifier/OpenMockCGMSettingsOnLongPressGesture.swift
  93. 51 0
      Dependencies/LoopKit/MockKitUI/ViewModifier/OpenMockPumpSettingsOnLongPressGesture.swift
  94. 177 0
      Dependencies/LoopKit/MockKitUI/Views/InsulinStatusView.swift
  95. 37 0
      Dependencies/LoopKit/MockKitUI/Views/MockCGMManagerControlsView.swift
  96. 274 0
      Dependencies/LoopKit/MockKitUI/Views/MockCGMManagerSettingsView.swift
  97. 37 0
      Dependencies/LoopKit/MockKitUI/Views/MockPumpManagerControlsView.swift
  98. 272 0
      Dependencies/LoopKit/MockKitUI/Views/MockPumpManagerSettingsView.swift
  99. 57 0
      Dependencies/LoopKit/MockKitUI/Views/TimeView.swift
  100. 0 0
      Dependencies/OmniBLE/Common/Bundle.swift

+ 87 - 0
Dependencies/LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/LoopKit Example.xcscheme

@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1430"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "430157F61C7EC03B00B64B63"
+               BuildableName = "LoopKit Example.app"
+               BlueprintName = "LoopKit Example"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "430157F61C7EC03B00B64B63"
+            BuildableName = "LoopKit Example.app"
+            BlueprintName = "LoopKit Example"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <Testables>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "430157F61C7EC03B00B64B63"
+            BuildableName = "LoopKit Example.app"
+            BlueprintName = "LoopKit Example"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "430157F61C7EC03B00B64B63"
+            BuildableName = "LoopKit Example.app"
+            BlueprintName = "LoopKit Example"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 76 - 0
Dependencies/LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/Shared-watchOS.xcscheme

@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1430"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "NO">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "A9E6758022713F4700E25293"
+               BuildableName = "LoopKit.framework"
+               BlueprintName = "LoopKit-watchOS"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <Testables>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "A9E6758022713F4700E25293"
+            BuildableName = "LoopKit.framework"
+            BlueprintName = "LoopKit-watchOS"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "A9E6758022713F4700E25293"
+            BuildableName = "LoopKit.framework"
+            BlueprintName = "LoopKit-watchOS"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 161 - 0
Dependencies/LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/Shared.xcscheme

@@ -0,0 +1,161 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1430"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "NO">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "43D8FDCA1C728FDF0073BE78"
+               BuildableName = "LoopKit.framework"
+               BlueprintName = "LoopKit"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "43BA7153201E484D0058961E"
+               BuildableName = "LoopKitUI.framework"
+               BlueprintName = "LoopKitUI"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "892A5D33222F03CB008961AB"
+               BuildableName = "LoopTestingKit.framework"
+               BlueprintName = "LoopTestingKit"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "89D2047121CC7BD7001238CC"
+               BuildableName = "MockKit.framework"
+               BlueprintName = "MockKit"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "89D2048E21CC7C12001238CC"
+               BuildableName = "MockKitUI.framework"
+               BlueprintName = "MockKitUI"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "43D8FDCA1C728FDF0073BE78"
+            BuildableName = "LoopKit.framework"
+            BlueprintName = "LoopKit"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "43D8FDD41C728FDF0073BE78"
+               BuildableName = "LoopKitTests.xctest"
+               BlueprintName = "LoopKitTests"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "1DEE226824A676A300693C32"
+               BuildableName = "LoopKitHostedTests.xctest"
+               BlueprintName = "LoopKitHostedTests"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "43D8FDCA1C728FDF0073BE78"
+            BuildableName = "LoopKit.framework"
+            BlueprintName = "LoopKit"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "43D8FDCA1C728FDF0073BE78"
+            BuildableName = "LoopKit.framework"
+            BlueprintName = "LoopKit"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 37 - 0
Dependencies/LoopKit/LoopKit/DataOutputStream.swift

@@ -0,0 +1,37 @@
+//
+//  DataOutputStream.swift
+//  LoopKit
+//
+//  Created by Pete Schwamb on 5/7/2023
+//  Copyright © 2020 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+enum DataOutputStreamError: Error {
+    case couldNotEncodeString
+}
+
+public protocol DataOutputStream: AnyObject {
+    // Writes data to the stream. Errors detected while
+    // processing should be thrown.
+    func write(_ data: Data) throws
+
+    // Lets the receiver know the stream is finished.
+    // If sync is true, block until data is finished processing.
+    // If no errors thrown, then data was processed successfully.
+    func finish(sync: Bool) throws
+
+    var streamError: Error? { get }
+}
+
+extension DataOutputStream {
+    // Convenience function to convert String into utf8 Data and write it.
+    public func write(_ string: String) throws {
+        if let data = string.data(using: .utf8) {
+            try write(data)
+        } else {
+            throw DataOutputStreamError.couldNotEncodeString
+        }
+    }
+}

+ 25 - 0
Dependencies/LoopKit/LoopKit/DeviceManager/BolusActivationType.swift

@@ -0,0 +1,25 @@
+//
+//  BolusActivationType.swift
+//  LoopKit
+//
+//  Created by Nathaniel Hamming on 2023-09-07.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+public enum BolusActivationType: String, Codable {
+    case automatic
+    case manualNoRecommendation
+    case manualRecommendationAccepted
+    case manualRecommendationChanged
+    case none
+
+    public var isAutomatic: Bool {
+        self == .automatic
+    }
+
+    static public func activationTypeFor(recommendedAmount: Double?, bolusAmount: Double?) -> BolusActivationType {
+        guard let bolusAmount = bolusAmount else { return recommendedAmount != nil ? .automatic : .none }
+        guard let recommendedAmount = recommendedAmount else { return .manualNoRecommendation }
+        return recommendedAmount =~ bolusAmount ? .manualRecommendationAccepted : .manualRecommendationChanged
+    }
+}

+ 38 - 0
Dependencies/LoopKit/LoopKit/FavoriteFood/FavoriteFood.swift

@@ -0,0 +1,38 @@
+//
+//  FavoriteFood.swift
+//  LoopKit
+//
+//  Created by Noah Brauner on 7/13/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import HealthKit
+
+public protocol FavoriteFood {
+    var name: String { get }
+    var carbsQuantity: HKQuantity { get }
+    var foodType: String { get }
+    var absorptionTime: TimeInterval { get }
+}
+
+extension FavoriteFood {
+    public var title: String {
+        return name + " " + foodType
+    }
+    
+    public func absorptionTimeString(formatter: DateComponentsFormatter) -> String {
+        guard let string = formatter.string(from: absorptionTime) else {
+            assertionFailure("Unable to format \(String(describing: absorptionTime))")
+            return ""
+        }
+        return string
+    }
+    
+    public func carbsString(formatter: QuantityFormatter) -> String {
+        guard let string = formatter.string(from: carbsQuantity) else {
+            assertionFailure("Unable to format \(String(describing: carbsQuantity)) into gram format")
+            return ""
+        }
+        return string
+    }
+}

+ 23 - 0
Dependencies/LoopKit/LoopKit/FavoriteFood/NewFavoriteFood.swift

@@ -0,0 +1,23 @@
+//
+//  NewFavoriteFood.swift
+//  LoopKit
+//
+//  Created by Noah Brauner on 8/9/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import HealthKit
+
+public struct NewFavoriteFood: FavoriteFood {
+    public var name: String
+    public var carbsQuantity: HKQuantity
+    public var foodType: String
+    public var absorptionTime: TimeInterval
+
+    public init(name: String, carbsQuantity: HKQuantity, foodType: String, absorptionTime: TimeInterval) {
+        self.name = name
+        self.carbsQuantity = carbsQuantity
+        self.foodType = foodType
+        self.absorptionTime = absorptionTime
+    }
+}

+ 62 - 0
Dependencies/LoopKit/LoopKit/FavoriteFood/StoredFavoriteFood.swift

@@ -0,0 +1,62 @@
+//
+//  StoredFavoriteFood.swift
+//  LoopKit
+//
+//  Created by Noah Brauner on 8/9/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import HealthKit
+
+public struct StoredFavoriteFood: FavoriteFood, Identifiable {
+    public var id: String
+    
+    public var name: String
+    public var carbsQuantity: HKQuantity
+    public var foodType: String
+    public var absorptionTime: TimeInterval
+    
+    public init(id: String = UUID().uuidString, name: String, carbsQuantity: HKQuantity, foodType: String, absorptionTime: TimeInterval) {
+        self.id = id
+        self.name = name
+        self.carbsQuantity = carbsQuantity
+        self.foodType = foodType
+        self.absorptionTime = absorptionTime
+    }
+}
+
+extension StoredFavoriteFood: Equatable {
+    public static func == (lhs: StoredFavoriteFood, rhs: StoredFavoriteFood) -> Bool {
+        return lhs.id == rhs.id
+    }
+}
+
+extension StoredFavoriteFood: Codable {
+    public init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        self.init(
+            id: try container.decode(String.self, forKey: .id),
+            name: try container.decode(String.self, forKey: .name),
+            carbsQuantity: HKQuantity(unit: .gram(), doubleValue: try container.decode(Double.self, forKey: .carbsQuantity)),
+            foodType: try container.decode(String.self, forKey: .foodType),
+            absorptionTime: try container.decode(TimeInterval.self, forKey: .absorptionTime)
+        )
+    }
+    
+    public func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(id, forKey: .id)
+        try container.encode(name, forKey: .name)
+        try container.encode(carbsQuantity.doubleValue(for: .gram()), forKey: .carbsQuantity)
+        try container.encode(foodType, forKey: .foodType)
+        try container.encode(absorptionTime, forKey: .absorptionTime)
+    }
+    
+    private enum CodingKeys: String, CodingKey {
+        case id
+        case name
+        case carbsQuantity
+        case foodType
+        case absorptionTime
+    }
+}

+ 83 - 0
Dependencies/LoopKit/LoopKit/GlucoseKit/CgmEvent.swift

@@ -0,0 +1,83 @@
+//
+//  CachedCgmEvent.swift
+//  LoopKit
+//
+//  Created by Pete Schwamb on 9/9/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import CoreData
+
+class CgmEvent: NSManagedObject {
+    @NSManaged var date: Date!
+    @NSManaged var storedAt: Date!
+    @NSManaged var primitiveType: String!
+    @NSManaged var deviceIdentifier: String!
+    @NSManaged var primitiveExpectedLifetime: NSNumber?
+    @NSManaged var primitiveWarmupPeriod: NSNumber?
+    @NSManaged var failureMessage: String?
+    @NSManaged var modificationCounter: Int64
+
+    var type: CgmEventType? {
+        get {
+            willAccessValue(forKey: "type")
+            defer { didAccessValue(forKey: "type") }
+            return CgmEventType(rawValue: primitiveType)
+        }
+        set {
+            willChangeValue(forKey: "type")
+            defer { didChangeValue(forKey: "type") }
+            primitiveType = newValue?.rawValue
+        }
+    }
+
+    var expectedLifetime: TimeInterval? {
+        get {
+            willAccessValue(forKey: "expectedLifetime")
+            defer { didAccessValue(forKey: "expectedLifetime") }
+            return primitiveExpectedLifetime?.doubleValue
+        }
+        set {
+            willChangeValue(forKey: "expectedLifetime")
+            defer { didChangeValue(forKey: "expectedLifetime") }
+            primitiveExpectedLifetime = newValue.flatMap { NSNumber(floatLiteral: $0) }
+        }
+    }
+
+    var warmupPeriod: TimeInterval? {
+        get {
+            willAccessValue(forKey: "warmupPeriod")
+            defer { didAccessValue(forKey: "warmupPeriod") }
+            return primitiveWarmupPeriod?.doubleValue
+        }
+        set {
+            willChangeValue(forKey: "warmupPeriod")
+            defer { didChangeValue(forKey: "warmupPeriod") }
+            primitiveWarmupPeriod = newValue.flatMap { NSNumber(floatLiteral: $0) }
+        }
+    }
+
+
+    @nonobjc public class func fetchRequest() -> NSFetchRequest<CgmEvent> {
+        return NSFetchRequest<CgmEvent>(entityName: "CgmEvent")
+    }
+
+    var hasUpdatedModificationCounter: Bool { changedValues().keys.contains("modificationCounter") }
+
+    func updateModificationCounter() { setPrimitiveValue(managedObjectContext!.modificationCounter!, forKey: "modificationCounter") }
+
+    override func awakeFromInsert() {
+        super.awakeFromInsert()
+        updateModificationCounter()
+    }
+
+    override func willSave() {
+        if isUpdated && !hasUpdatedModificationCounter {
+            updateModificationCounter()
+        }
+        super.willSave()
+    }
+}
+
+

+ 229 - 0
Dependencies/LoopKit/LoopKit/GlucoseKit/CgmEventStore.swift

@@ -0,0 +1,229 @@
+//
+//  CgmEventStore.swift
+//  LoopKit
+//
+//  Created by Pete Schwamb on 9/9/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import CoreData
+import HealthKit
+import os.log
+
+public protocol CgmEventStoreDelegate: AnyObject {
+
+    /**
+     Informs the delegate that the cgm event store has updated event data.
+
+     - Parameter cgmEventStore: The cgm event store that has updated event data.
+     */
+    func cgmEventStoreHasUpdatedData(_ cgmEventStore: CgmEventStore)
+
+}
+
+/**
+ Manages storage and retrieval of cgm events
+ */
+public final class CgmEventStore {
+
+    public weak var delegate: CgmEventStoreDelegate?
+
+    /// The interval of cgm event data to keep in cache
+    public let cacheLength: TimeInterval
+
+    private let log = OSLog(category: "CgmEventStore")
+
+    private let cacheStore: PersistenceController
+
+    private let queue = DispatchQueue(label: "com.loopkit.CgmEventStore.queue", qos: .utility)
+
+    // MARK: - ReadyState
+    private enum ReadyState {
+        case waiting
+        case ready
+        case error(Error)
+    }
+
+    public typealias ReadyCallback = (_ error: Error?) -> Void
+
+    private var readyCallbacks: [ReadyCallback] = []
+
+    private var readyState: ReadyState = .waiting
+
+    public func onReady(_ callback: @escaping ReadyCallback) {
+        queue.async {
+            switch self.readyState {
+            case .waiting:
+                self.readyCallbacks.append(callback)
+            case .ready:
+                callback(nil)
+            case .error(let error):
+                callback(error)
+            }
+        }
+    }
+
+    /// The maximum length of time to keep data around.
+    public var cacheStartDate: Date {
+        return Date().addingTimeInterval(-cacheLength)
+    }
+
+    public init(
+        cacheStore: PersistenceController,
+        cacheLength: TimeInterval = 60 /* minutes */ * 60 /* seconds */
+    ) {
+        self.cacheStore = cacheStore
+        self.cacheLength = cacheLength
+
+        cacheStore.onReady { (error) in
+            guard error == nil else {
+                self.queue.async {
+                    self.readyState = .error(error!)
+                    for callback in self.readyCallbacks {
+                        callback(error)
+                    }
+                    self.readyCallbacks = []
+                }
+                return
+            }
+
+            cacheStore.fetchAnchor(key: GlucoseStore.healthKitQueryAnchorMetadataKey) { (anchor) in
+                self.queue.async {
+                    self.readyState = .ready
+                    for callback in self.readyCallbacks {
+                        callback(error)
+                    }
+                    self.readyCallbacks = []
+
+                }
+            }
+        }
+    }
+}
+
+// MARK: - Fetching
+
+extension CgmEventStore {
+
+    public struct QueryAnchor: Equatable, RawRepresentable {
+
+        public typealias RawValue = [String: Any]
+
+        internal var modificationCounter: Int64
+
+        public init() {
+            self.modificationCounter = 0
+        }
+
+        public init?(rawValue: RawValue) {
+            guard let modificationCounter = rawValue["modificationCounter"] as? Int64 else {
+                return nil
+            }
+            self.modificationCounter = modificationCounter
+        }
+
+        public var rawValue: RawValue {
+            var rawValue: RawValue = [:]
+            rawValue["modificationCounter"] = modificationCounter
+            return rawValue
+        }
+    }
+
+    /**
+     Adds and persists a new cgm event
+
+     - parameter unitVolume: The reservoir volume, in units
+     - parameter date:       The date of the volume reading
+     - parameter completion: A closure called after the value was saved. This closure takes three arguments:
+        - value:                    The new reservoir value, if it was saved
+        - previousValue:            The last new reservoir value
+        - areStoredValuesContinous: Whether the current recent state of the stored reservoir data is considered continuous and reliable for deriving insulin effects after addition of this new value.
+        - error:                    An error object explaining why the value could not be saved
+     */
+    public func add(events: [PersistedCgmEvent]) async throws {
+        try await cacheStore.managedObjectContext.perform {
+
+            for event in events {
+                let cgmEvent = CgmEvent(context: self.cacheStore.managedObjectContext)
+                cgmEvent.date = event.date
+                cgmEvent.type = event.type
+                cgmEvent.deviceIdentifier = event.deviceIdentifier
+                cgmEvent.expectedLifetime = event.expectedLifetime
+                cgmEvent.warmupPeriod = event.warmupPeriod
+                cgmEvent.failureMessage = event.failureMessage
+                cgmEvent.storedAt = Date()
+            }
+
+            if let error = self.cacheStore.save() {
+                self.log.error("Error saving CGM event: %{public}@", error.localizedDescription)
+                throw error
+            }
+
+            try self.purgeOldCgmEvents()
+
+            self.delegate?.cgmEventStoreHasUpdatedData(self)
+        }
+    }
+
+
+    public enum CgmEventQueryResult {
+        case success(QueryAnchor, [PersistedCgmEvent])
+        case failure(Error)
+    }
+
+    public func executeCgmEventQuery(fromQueryAnchor queryAnchor: QueryAnchor?, completion: @escaping (CgmEventQueryResult) -> Void) {
+        var queryAnchor = queryAnchor ?? QueryAnchor()
+        var queryResult = [PersistedCgmEvent]()
+        var queryError: Error?
+
+        cacheStore.managedObjectContext.performAndWait {
+            let storedRequest: NSFetchRequest<CgmEvent> = CgmEvent.fetchRequest()
+
+            storedRequest.predicate = NSPredicate(format: "modificationCounter > %d", queryAnchor.modificationCounter)
+            storedRequest.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: true)]
+
+            do {
+                let stored = try self.cacheStore.managedObjectContext.fetch(storedRequest)
+                if let modificationCounter = stored.max(by: { $0.modificationCounter < $1.modificationCounter })?.modificationCounter {
+                    queryAnchor.modificationCounter = modificationCounter
+                }
+                queryResult.append(contentsOf: stored.compactMap { $0.persistedCgmEvent })
+            } catch let error {
+                queryError = error
+            }
+        }
+
+        if let queryError = queryError {
+            completion(.failure(queryError))
+            return
+        }
+
+        completion(.success(queryAnchor, queryResult))
+    }
+
+    private func purgeOldCgmEvents() throws {
+
+        let predicate = NSPredicate(format: "storedAt < %@", cacheStartDate as NSDate)
+
+        let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: CgmEvent.entity().name!)
+        fetchRequest.predicate = predicate
+
+        let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
+        deleteRequest.resultType = .resultTypeObjectIDs
+
+        do {
+            if let result = try cacheStore.managedObjectContext.execute(deleteRequest) as? NSBatchDeleteResult,
+                let objectIDs = result.result as? [NSManagedObjectID],
+                objectIDs.count > 0
+            {
+                let changes = [NSDeletedObjectsKey: objectIDs]
+                NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [cacheStore.managedObjectContext])
+            }
+        } catch let error as NSError {
+            throw PersistenceController.PersistenceControllerError.coreDataError(error)
+        }
+    }
+
+}
+

+ 57 - 0
Dependencies/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift

@@ -0,0 +1,57 @@
+//
+//  CgmEvent.swift
+//  LoopKit
+//
+//  Created by Pete Schwamb on 9/9/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+public enum CgmEventType: String {
+    case sensorStart
+    case sensorEnd
+    case transmitterStart
+    case transmitterEnd
+}
+
+public struct PersistedCgmEvent {
+    public var date: Date
+    public var type: CgmEventType
+    public var deviceIdentifier: String
+    public var expectedLifetime: TimeInterval?
+    public var warmupPeriod: TimeInterval?
+    public var failureMessage: String?
+
+    public init(date: Date, type: CgmEventType, deviceIdentifier: String, expectedLifetime: TimeInterval? = nil, warmupPeriod: TimeInterval? = nil, failureMessage: String? = nil) {
+        self.date = date
+        self.type = type
+        self.deviceIdentifier = deviceIdentifier
+        self.expectedLifetime = expectedLifetime
+        self.warmupPeriod = warmupPeriod
+        self.failureMessage = failureMessage
+    }
+
+}
+
+extension PersistedCgmEvent {
+    init?(managedObject: CgmEvent) {
+        guard let type = managedObject.type else {
+            return nil
+        }
+        self.init(
+            date: managedObject.date,
+            type: type,
+            deviceIdentifier: managedObject.deviceIdentifier,
+            expectedLifetime: managedObject.expectedLifetime,
+            warmupPeriod: managedObject.warmupPeriod,
+            failureMessage: managedObject.failureMessage
+        )
+    }
+}
+
+extension CgmEvent {
+    var persistedCgmEvent: PersistedCgmEvent? {
+        return PersistedCgmEvent(managedObject: self)
+    }
+}

+ 576 - 0
Dependencies/LoopKit/LoopKit/LoopAlgorithm/DoseMath.swift

@@ -0,0 +1,576 @@
+//
+//  DoseMath.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 3/8/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+private enum InsulinCorrection {
+    case inRange
+    case aboveRange(min: GlucoseValue, correcting: GlucoseValue, minTarget: HKQuantity, units: Double)
+    case entirelyBelowRange(min: GlucoseValue, minTarget: HKQuantity, units: Double)
+    case suspend(min: GlucoseValue)
+}
+
+extension InsulinCorrection {
+    /// The delivery units for the correction
+    private var units: Double {
+        switch self {
+        case .aboveRange(min: _, correcting: _, minTarget: _, units: let units):
+            return units
+        case .entirelyBelowRange(min: _, minTarget: _, units: let units):
+            return units
+        case .inRange, .suspend:
+            return 0
+        }
+    }
+
+
+
+    /// Determines the temp basal over `duration` needed to perform the correction.
+    ///
+    /// - Parameters:
+    ///   - scheduledBasalRate: The scheduled basal rate at the time the correction is delivered
+    ///   - maxBasalRate: The maximum allowed basal rate
+    ///   - duration: The duration of the temporary basal
+    ///   - rateRounder: The smallest fraction of a unit supported in basal delivery
+    /// - Returns: A temp basal recommendation
+    fileprivate func asTempBasal(
+        scheduledBasalRate: Double,
+        maxBasalRate: Double,
+        duration: TimeInterval,
+        rateRounder: ((Double) -> Double)?
+    ) -> TempBasalRecommendation {
+        var rate = units / (duration / TimeInterval(hours: 1))  // units/hour
+        switch self {
+        case .aboveRange, .inRange, .entirelyBelowRange:
+            rate += scheduledBasalRate
+        case .suspend:
+            break
+        }
+
+        rate = Swift.min(maxBasalRate, Swift.max(0, rate))
+
+        rate = rateRounder?(rate) ?? rate
+
+        return TempBasalRecommendation(
+            unitsPerHour: rate,
+            duration: duration
+        )
+    }
+
+    private var bolusRecommendationNotice: BolusRecommendationNotice? {
+        switch self {
+        case .suspend(min: let minimum):
+            return .glucoseBelowSuspendThreshold(minGlucose: minimum)
+        case .inRange:
+            return .predictedGlucoseInRange
+        case .entirelyBelowRange(min: let min, minTarget: _, units: _):
+            return .allGlucoseBelowTarget(minGlucose: min)
+        case .aboveRange(min: let min, correcting: _, minTarget: let target, units: let units):
+            if units > 0 && min.quantity < target {
+                return .predictedGlucoseBelowTarget(minGlucose: min)
+            } else {
+                return nil
+            }
+        }
+    }
+
+    /// Determines the bolus needed to perform the correction, subtracting any insulin already scheduled for
+    ///  delivery, such as the remaining portion of an ongoing temp basal.
+    ///
+    /// - Parameters:
+    ///   - pendingInsulin: The number of units expected to be delivered, but not yet reflected in the correction
+    ///   - maxBolus: The maximum allowable bolus value in units
+    ///   - volumeRounder: Method to round computed dose to deliverable volume
+    /// - Returns: A bolus recommendation
+    fileprivate func asManualBolus(
+        pendingInsulin: Double,
+        maxBolus: Double,
+        volumeRounder: ((Double) -> Double)?
+    ) -> ManualBolusRecommendation {
+        var units = self.units - pendingInsulin
+        units = Swift.min(maxBolus, Swift.max(0, units))
+        units = volumeRounder?(units) ?? units
+
+        return ManualBolusRecommendation(
+            amount: units,
+            pendingInsulin: pendingInsulin,
+            notice: bolusRecommendationNotice
+        )
+    }
+
+    /// Determines the bolus amount to perform a partial application correction
+    ///
+    /// - Parameters:
+    ///   - partialApplicationFactor: The fraction of needed insulin to deliver now
+    ///   - maxBolus: The maximum allowable bolus value in units
+    ///   - volumeRounder: Method to round computed dose to deliverable volume
+    /// - Returns: A bolus recommendation
+    fileprivate func asPartialBolus(
+        partialApplicationFactor: Double,
+        maxBolusUnits: Double,
+        volumeRounder: ((Double) -> Double)?
+    ) -> Double {
+
+        let partialDose = units * partialApplicationFactor
+
+        return Swift.min(Swift.max(0, volumeRounder?(partialDose) ?? partialDose),volumeRounder?(maxBolusUnits) ?? maxBolusUnits)
+    }
+}
+
+
+extension TempBasalRecommendation {
+    /// Equates the recommended rate with another rate
+    ///
+    /// - Parameter unitsPerHour: The rate to compare
+    /// - Returns: Whether the rates are equal within Double precision
+    private func matchesRate(_ unitsPerHour: Double) -> Bool {
+        return abs(self.unitsPerHour - unitsPerHour) < .ulpOfOne
+    }
+
+    /// Determines whether the recommendation is necessary given the current state of the pump
+    ///
+    /// - Parameters:
+    ///   - date: The date the recommendation would be delivered
+    ///   - scheduledBasalRate: The scheduled basal rate at `date`
+    ///   - lastTempBasal: The previously set temp basal
+    ///   - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command
+    ///   - scheduledBasalRateMatchesPump: A flag describing whether `scheduledBasalRate` matches the scheduled basal rate of the pump.
+    ///                                    If `false` and the recommendation matches `scheduledBasalRate`, the temp will be recommended
+    ///                                    at the scheduled basal rate rather than recommending no temp.
+    /// - Returns: A temp basal recommendation
+    func ifNecessary(
+        at date: Date,
+        scheduledBasalRate: Double,
+        lastTempBasal: DoseEntry?,
+        continuationInterval: TimeInterval,
+        scheduledBasalRateMatchesPump: Bool
+    ) -> TempBasalRecommendation? {
+        // Adjust behavior for the currently active temp basal
+        if let lastTempBasal = lastTempBasal,
+            lastTempBasal.type == .tempBasal,
+            lastTempBasal.endDate > date
+        {
+            /// If the last temp basal has the same rate, and has more than `continuationInterval` of time remaining, don't set a new temp
+            if matchesRate(lastTempBasal.unitsPerHour),
+                lastTempBasal.endDate.timeIntervalSince(date) > continuationInterval {
+                return nil
+            } else if matchesRate(scheduledBasalRate), scheduledBasalRateMatchesPump {
+                // If our new temp matches the scheduled rate of the pump, cancel the current temp
+                return .cancel
+            }
+        } else if matchesRate(scheduledBasalRate), scheduledBasalRateMatchesPump {
+            // If we recommend the in-progress scheduled basal rate of the pump, do nothing
+            return nil
+        }
+
+        return self
+    }
+}
+
+/// Computes a total insulin amount necessary to correct a glucose differential at a given sensitivity
+///
+/// - Parameters:
+///   - fromValue: The starting glucose value
+///   - toValue: The desired glucose value
+///   - effectedSensitivity: The sensitivity, in glucose-per-insulin-unit
+/// - Returns: The insulin correction in units
+private func insulinCorrectionUnits(fromValue: Double, toValue: Double, effectedSensitivity: Double) -> Double? {
+    guard effectedSensitivity > 0 else {
+        return nil
+    }
+
+    let glucoseCorrection = fromValue - toValue
+
+    return glucoseCorrection / effectedSensitivity
+}
+
+/// Computes a target glucose value for a correction, at a given time during the insulin effect duration
+///
+/// - Parameters:
+///   - percentEffectDuration: The percent of time elapsed of the insulin effect duration
+///   - minValue: The minimum (starting) target value
+///   - maxValue: The maximum (eventual) target value
+/// - Returns: A target value somewhere between the minimum and maximum
+private func targetGlucoseValue(percentEffectDuration: Double, minValue: Double, maxValue: Double) -> Double {
+    // The inflection point in time: before it we use minValue, after it we linearly blend from minValue to maxValue
+    let useMinValueUntilPercent = 0.5
+
+    guard percentEffectDuration > useMinValueUntilPercent else {
+        return minValue
+    }
+
+    guard percentEffectDuration < 1 else {
+        return maxValue
+    }
+
+    let slope = (maxValue - minValue) / (1 - useMinValueUntilPercent)
+    return minValue + slope * (percentEffectDuration - useMinValueUntilPercent)
+}
+
+
+extension Collection where Element: GlucoseValue {
+
+    /// For a collection of glucose prediction, determine the least amount of insulin delivered at
+    /// `date` to correct the predicted glucose to the middle of `correctionRange` at the time of prediction.
+    ///
+    /// - Parameters:
+    ///   - correctionRange: The schedule of glucose values used for correction
+    ///   - date: The date the insulin correction is delivered
+    ///   - suspendThreshold: The glucose value below which only suspension is returned
+    ///   - sensitivity: The insulin sensitivity at the time of delivery
+    ///   - model: The insulin effect model
+    /// - Returns: A correction value in units, or nil if no correction needed
+    private func insulinCorrection(
+        to correctionRange: GlucoseRangeSchedule,
+        at date: Date,
+        suspendThreshold: HKQuantity,
+        sensitivity: HKQuantity,
+        model: InsulinModel
+    ) -> InsulinCorrection? {
+        let effectDuration = model.effectDuration
+        let timeline = [AbsoluteScheduleValue(startDate: date, endDate: date.addingTimeInterval(effectDuration), value: sensitivity)]
+        return insulinCorrection(
+            to: correctionRange,
+            at: date,
+            suspendThreshold: suspendThreshold,
+            insulinSensitivityTimeline: timeline,
+            model: model)
+    }
+
+    /// For a collection of glucose prediction, determine the least amount of insulin delivered at
+    /// `date` to correct the predicted glucose to the middle of `correctionRange` at the time of prediction.
+    ///
+    /// - Parameters:
+    ///   - correctionRange: The schedule of glucose values used for correction
+    ///   - date: The date the insulin correction is delivered
+    ///   - suspendThreshold: The glucose value below which only suspension is returned
+    ///   - insulinSensitivityTimeline: The timeline of expected insulin sensitivity over the period of dose absorption
+    ///   - model: The insulin effect model
+    /// - Returns: A correction value in units, or nil if no correction needed
+    private func insulinCorrection(
+        to correctionRange: GlucoseRangeSchedule,
+        at date: Date,
+        suspendThreshold: HKQuantity,
+        insulinSensitivityTimeline: [AbsoluteScheduleValue<HKQuantity>],
+        model: InsulinModel
+    ) -> InsulinCorrection? {
+        var minGlucose: GlucoseValue?
+        var eventualGlucose: GlucoseValue?
+        var correctingGlucose: GlucoseValue?
+        var minCorrectionUnits: Double?
+        var effectedSensitivityAtMinGlucose: Double?
+
+        // Only consider predictions within the model's effect duration
+        let validDateRange = DateInterval(start: date, duration: model.effectDuration)
+
+        let unit = correctionRange.unit
+        let suspendThresholdValue = suspendThreshold.doubleValue(for: unit)
+
+        // For each prediction above target, determine the amount of insulin necessary to correct glucose based on the modeled effectiveness of the insulin at that time
+        for prediction in self {
+            guard validDateRange.contains(prediction.startDate) else {
+                continue
+            }
+
+            // If any predicted value is below the suspend threshold, return immediately
+            guard prediction.quantity >= suspendThreshold else {
+                print("Suspend!")
+                return .suspend(min: prediction)
+            }
+
+            eventualGlucose = prediction
+
+            let predictedGlucoseValue = prediction.quantity.doubleValue(for: unit)
+            let time = prediction.startDate.timeIntervalSince(date)
+
+            // Compute the target value as a function of time since the dose started
+            let targetValue = targetGlucoseValue(
+                percentEffectDuration: time / model.effectDuration,
+                minValue: suspendThresholdValue,
+                maxValue: correctionRange.quantityRange(at: prediction.startDate).averageValue(for: unit)
+            )
+
+            // Compute the dose required to bring this prediction to target:
+            // dose = (Glucose Δ) / (% effect × sensitivity)
+
+            let isfSegments = insulinSensitivityTimeline.filterDateRange(date, prediction.startDate)
+
+            let effectedSensitivity = isfSegments.reduce(0) { partialResult, segment in
+                let start = Swift.max(date, segment.startDate).timeIntervalSince(date)
+                let end = Swift.min(prediction.startDate, segment.endDate).timeIntervalSince(date)
+                let percentEffected = model.percentEffectRemaining(at: start) - model.percentEffectRemaining(at: end)
+                return percentEffected * segment.value.doubleValue(for: unit)
+            }
+
+            // Update range statistics
+            if minGlucose == nil || prediction.quantity < minGlucose!.quantity {
+                minGlucose = prediction
+                effectedSensitivityAtMinGlucose = effectedSensitivity
+            }
+
+            guard let correctionUnits = insulinCorrectionUnits(
+                fromValue: predictedGlucoseValue,
+                toValue: targetValue,
+                effectedSensitivity: Swift.max(.ulpOfOne, effectedSensitivity)
+            ), correctionUnits > 0 else {
+                continue
+            }
+
+            // Update the correction only if we've found a new minimum
+            guard minCorrectionUnits == nil || correctionUnits < minCorrectionUnits! else {
+                continue
+            }
+
+            correctingGlucose = prediction
+            minCorrectionUnits = correctionUnits
+        }
+
+        guard let eventualGlucose, let minGlucose else {
+            return nil
+        }
+
+        // Choose either the minimum glucose or eventual glucose as the correction delta
+        let minGlucoseTargets = correctionRange.quantityRange(at: minGlucose.startDate)
+        let eventualGlucoseTargets = correctionRange.quantityRange(at: eventualGlucose.startDate)
+
+        // Treat the mininum glucose when both are below range
+        if minGlucose.quantity < minGlucoseTargets.lowerBound &&
+            eventualGlucose.quantity < eventualGlucoseTargets.lowerBound
+        {
+            guard let units = insulinCorrectionUnits(
+                fromValue: minGlucose.quantity.doubleValue(for: unit),
+                toValue: minGlucoseTargets.averageValue(for: unit),
+                effectedSensitivity: Swift.max(.ulpOfOne, effectedSensitivityAtMinGlucose!)
+            ) else {
+                return nil
+            }
+
+            return .entirelyBelowRange(
+                min: minGlucose,
+                minTarget: minGlucoseTargets.lowerBound,
+                units: units
+            )
+        } else if eventualGlucose.quantity > eventualGlucoseTargets.upperBound,
+            let minCorrectionUnits = minCorrectionUnits, let correctingGlucose = correctingGlucose
+        {
+            return .aboveRange(
+                min: minGlucose,
+                correcting: correctingGlucose,
+                minTarget: eventualGlucoseTargets.lowerBound,
+                units: minCorrectionUnits
+            )
+        } else {
+            return .inRange
+        }
+    }
+
+    /// Recommends a temporary basal rate to conform a glucose prediction timeline to a correction range
+    ///
+    /// Returns nil if the normal scheduled basal, or active temporary basal, is sufficient.
+    ///
+    /// - Parameters:
+    ///   - correctionRange: The schedule of correction ranges
+    ///   - date: The date at which the temp basal would be scheduled, defaults to now
+    ///   - suspendThreshold: A glucose value causing a recommendation of no insulin if any prediction falls below
+    ///   - sensitivity: The schedule of insulin sensitivities
+    ///   - model: The insulin absorption model
+    ///   - basalRates: The schedule of basal rates
+    ///   - additionalActiveInsulinClamp: Max amount of additional insulin above scheduled basal rate allowed to be scheduled
+    ///   - maxBasalRate: The maximum allowed basal rate
+    ///   - lastTempBasal: The previously set temp basal
+    ///   - rateRounder: Closure that rounds recommendation to nearest supported rate. If nil, no rounding is performed
+    ///   - isBasalRateScheduleOverrideActive: A flag describing whether a basal rate schedule override is in progress
+    ///   - duration: The duration of the temporary basal
+    ///   - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command
+    /// - Returns: The recommended temporary basal rate and duration
+    public func recommendedTempBasal(
+        to correctionRange: GlucoseRangeSchedule,
+        at date: Date = Date(),
+        suspendThreshold: HKQuantity?,
+        sensitivity: InsulinSensitivitySchedule,
+        model: InsulinModel,
+        basalRates: BasalRateSchedule,
+        maxBasalRate: Double,
+        additionalActiveInsulinClamp: Double? = nil,
+        lastTempBasal: DoseEntry?,
+        rateRounder: ((Double) -> Double)? = nil,
+        isBasalRateScheduleOverrideActive: Bool = false,
+        duration: TimeInterval = TimeInterval(30 * 60),
+        continuationInterval: TimeInterval = TimeInterval(60 * 11)
+    ) -> TempBasalRecommendation? {
+        let correction = self.insulinCorrection(
+            to: correctionRange,
+            at: date,
+            suspendThreshold: suspendThreshold ?? correctionRange.quantityRange(at: date).lowerBound,
+            sensitivity: sensitivity.quantity(at: date),
+            model: model
+        )
+
+        let scheduledBasalRate = basalRates.value(at: date)
+        var maxBasalRate = maxBasalRate
+
+        // TODO: Allow `highBasalThreshold` to be a configurable setting
+        if case .aboveRange(min: let min, correcting: _, minTarget: let highBasalThreshold, units: _)? = correction,
+            min.quantity < highBasalThreshold
+        {
+            maxBasalRate = scheduledBasalRate
+        }
+
+        if let additionalActiveInsulinClamp {
+            let maxThirtyMinuteRateToKeepIOBBelowLimit = additionalActiveInsulinClamp * 2.0 + scheduledBasalRate  // 30 minutes of a U/hr rate
+            maxBasalRate = Swift.min(maxThirtyMinuteRateToKeepIOBBelowLimit, maxBasalRate)
+        }
+
+        let temp = correction?.asTempBasal(
+            scheduledBasalRate: scheduledBasalRate,
+            maxBasalRate: maxBasalRate,
+            duration: duration,
+            rateRounder: rateRounder
+        )
+
+        return temp?.ifNecessary(
+            at: date,
+            scheduledBasalRate: scheduledBasalRate,
+            lastTempBasal: lastTempBasal,
+            continuationInterval: continuationInterval,
+            scheduledBasalRateMatchesPump: !isBasalRateScheduleOverrideActive
+        )
+    }
+
+    /// Recommends a dose suitable for automatic enactment. Uses boluses for high corrections, and temp basals for low corrections.
+    ///
+    /// Returns nil if the normal scheduled basal, or active temporary basal, is sufficient.
+    ///
+    /// - Parameters:
+    ///   - correctionRange: The schedule of correction ranges
+    ///   - date: The date at which the temp basal would be scheduled, defaults to now
+    ///   - suspendThreshold: A glucose value causing a recommendation of no insulin if any prediction falls below
+    ///   - sensitivity: The schedule of insulin sensitivities
+    ///   - model: The insulin absorption model
+    ///   - basalRates: The schedule of basal rates
+    ///   - maxBasalRate: The maximum allowed basal rate
+    ///   - lastTempBasal: The previously set temp basal
+    ///   - rateRounder: Closure that rounds recommendation to nearest supported rate. If nil, no rounding is performed
+    ///   - isBasalRateScheduleOverrideActive: A flag describing whether a basal rate schedule override is in progress
+    ///   - duration: The duration of the temporary basal
+    ///   - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command
+    /// - Returns: The recommended dosing, or nil if no dose adjustment recommended
+    public func recommendedAutomaticDose(
+        to correctionRange: GlucoseRangeSchedule,
+        at date: Date = Date(),
+        suspendThreshold: HKQuantity?,
+        sensitivity: InsulinSensitivitySchedule,
+        model: InsulinModel,
+        basalRates: BasalRateSchedule,
+        maxAutomaticBolus: Double,
+        partialApplicationFactor: Double,
+        lastTempBasal: DoseEntry?,
+        volumeRounder: ((Double) -> Double)? = nil,
+        rateRounder: ((Double) -> Double)? = nil,
+        isBasalRateScheduleOverrideActive: Bool = false,
+        duration: TimeInterval = TimeInterval(30 * 60),
+        continuationInterval: TimeInterval = TimeInterval(11 * 60)
+    ) -> AutomaticDoseRecommendation? {
+        guard let correction = self.insulinCorrection(
+            to: correctionRange,
+            at: date,
+            suspendThreshold: suspendThreshold ?? correctionRange.quantityRange(at: date).lowerBound,
+            sensitivity: sensitivity.quantity(at: date),
+            model: model
+        ) else {
+            return nil
+        }
+
+        let scheduledBasalRate = basalRates.value(at: date)
+        var maxAutomaticBolus = maxAutomaticBolus
+
+        if case .aboveRange(min: let min, correcting: _, minTarget: let doseThreshold, units: _) = correction,
+            min.quantity < doseThreshold
+        {
+            maxAutomaticBolus = 0
+        }
+
+        var temp: TempBasalRecommendation? = correction.asTempBasal(
+            scheduledBasalRate: scheduledBasalRate,
+            maxBasalRate: scheduledBasalRate,
+            duration: duration,
+            rateRounder: rateRounder
+        )
+
+        temp = temp?.ifNecessary(
+            at: date,
+            scheduledBasalRate: scheduledBasalRate,
+            lastTempBasal: lastTempBasal,
+            continuationInterval: continuationInterval,
+            scheduledBasalRateMatchesPump: !isBasalRateScheduleOverrideActive
+        )
+
+        let bolusUnits = correction.asPartialBolus(
+            partialApplicationFactor: partialApplicationFactor,
+            maxBolusUnits: maxAutomaticBolus,
+            volumeRounder: volumeRounder
+        )
+
+        if temp != nil || bolusUnits > 0 {
+            return AutomaticDoseRecommendation(basalAdjustment: temp, bolusUnits: bolusUnits)
+        }
+
+        return nil
+    }
+
+
+    /// Recommends a bolus to conform a glucose prediction timeline to a correction range
+    ///
+    /// - Parameters:
+    ///   - correctionRange: The schedule of correction ranges
+    ///   - date: The date at which the bolus would apply, defaults to now
+    ///   - suspendThreshold: A glucose value causing a recommendation of no insulin if any prediction falls below
+    ///   - sensitivity: The schedule of insulin sensitivities
+    ///   - model: The insulin absorption model
+    ///   - pendingInsulin: The number of units expected to be delivered, but not yet reflected in the correction
+    ///   - maxBolus: The maximum bolus to return
+    ///   - volumeRounder: Closure that rounds recommendation to nearest supported bolus volume. If nil, no rounding is performed
+    /// - Returns: A bolus recommendation
+    public func recommendedManualBolus(
+        to correctionRange: GlucoseRangeSchedule,
+        at date: Date = Date(),
+        suspendThreshold: HKQuantity?,
+        sensitivity: InsulinSensitivitySchedule,
+        model: InsulinModel,
+        pendingInsulin: Double,
+        maxBolus: Double,
+        volumeRounder: ((Double) -> Double)? = nil
+    ) -> ManualBolusRecommendation {
+        guard let correction = self.insulinCorrection(
+            to: correctionRange,
+            at: date,
+            suspendThreshold: suspendThreshold ?? correctionRange.quantityRange(at: date).lowerBound,
+            sensitivity: sensitivity.quantity(at: date),
+            model: model
+        ) else {
+            return ManualBolusRecommendation(amount: 0, pendingInsulin: pendingInsulin)
+        }
+
+        var bolus = correction.asManualBolus(
+            pendingInsulin: pendingInsulin,
+            maxBolus: maxBolus,
+            volumeRounder: volumeRounder
+        )
+
+        // Handle the "current BG below target" notice here
+        // TODO: Don't assume in the future that the first item in the array is current BG
+        if case .predictedGlucoseBelowTarget? = bolus.notice,
+            let first = first, first.quantity < correctionRange.quantityRange(at: first.startDate).lowerBound
+        {
+            bolus.notice = .currentGlucoseBelowTarget(glucose: first)
+        }
+
+        return bolus
+    }
+}

+ 29 - 0
Dependencies/LoopKit/LoopKit/LoopAlgorithm/GlucosePredictionAlgorithm.swift

@@ -0,0 +1,29 @@
+//
+//  GlucosePredictionAlgorithm.swift
+//  Learn
+//
+//  Created by Pete Schwamb on 7/22/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+public protocol GlucosePredictionInput {
+    var glucoseHistory: [StoredGlucoseSample] { get }
+    var doses: [DoseEntry] { get }
+    var carbEntries: [StoredCarbEntry] { get }
+}
+
+public protocol GlucosePrediction {
+    var glucose: [PredictedGlucoseValue] { get }
+}
+
+public protocol GlucosePredictionAlgorithm {
+    associatedtype InputType: GlucosePredictionInput
+    associatedtype OutputType: GlucosePrediction
+
+    static func generatePrediction(input: InputType, startDate: Date?) throws -> OutputType
+}
+
+
+extension LoopAlgorithm: GlucosePredictionAlgorithm {}

+ 190 - 0
Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopAlgorithm.swift

@@ -0,0 +1,190 @@
+//
+//  LoopAlgorithm.swift
+//  Learn
+//
+//  Created by Pete Schwamb on 6/30/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+public enum AlgorithmError: Error {
+    case missingGlucose
+    case incompleteSchedules
+}
+
+public struct LoopAlgorithmEffects {
+    public var insulin: [GlucoseEffect]
+    public var carbs: [GlucoseEffect]
+    public var retrospectiveCorrection: [GlucoseEffect]
+    public var momentum: [GlucoseEffect]
+    public var insulinCounteraction: [GlucoseEffectVelocity]
+}
+
+public struct AlgorithmEffectsOptions: OptionSet {
+    public let rawValue: UInt8
+
+    public static let carbs            = AlgorithmEffectsOptions(rawValue: 1 << 0)
+    public static let insulin          = AlgorithmEffectsOptions(rawValue: 1 << 1)
+    public static let momentum         = AlgorithmEffectsOptions(rawValue: 1 << 2)
+    public static let retrospection    = AlgorithmEffectsOptions(rawValue: 1 << 3)
+
+    public static let all: AlgorithmEffectsOptions = [.carbs, .insulin, .momentum, .retrospection]
+
+    public init(rawValue: UInt8) {
+        self.rawValue = rawValue
+    }
+}
+
+public struct LoopPrediction: GlucosePrediction {
+    public var glucose: [PredictedGlucoseValue]
+    public var effects: LoopAlgorithmEffects
+}
+
+public struct DoseRecommendation: Equatable {
+    public let basalAdjustment: TempBasalRecommendation?
+    public let bolusUnits: Double?
+
+    public init(basalAdjustment: TempBasalRecommendation?, bolusUnits: Double? = nil) {
+        self.basalAdjustment = basalAdjustment
+        self.bolusUnits = bolusUnits
+    }
+}
+
+public actor LoopAlgorithm {
+
+    public typealias InputType = LoopPredictionInput
+    public typealias OutputType = LoopPrediction
+
+//    public static func generateRecommendation(input: LoopAlgorithmInput) throws -> DoseRecommendation {
+//        let prediction = try generatePrediction(input: input.predictionInput, startDate: input.predictionDate)
+//
+//        switch input.doseRecommendationType {
+//        case .manualBolus:
+//            prediction.glucose.recommendedManualBolus(to: <#T##GlucoseRangeSchedule#>, suspendThreshold: <#T##HKQuantity?#>, sensitivity: <#T##InsulinSensitivitySchedule#>, model: <#T##InsulinModel#>, pendingInsulin: <#T##Double#>, maxBolus: <#T##Double#>)
+//        case .automaticBolus:
+//            <#code#>
+//        case .tempBasal:
+//            <#code#>
+//        }
+//    }
+
+    // Generates a forecast predicting glucose.
+    public static func generatePrediction(input: LoopPredictionInput, startDate: Date? = nil) throws -> LoopPrediction {
+
+        guard let latestGlucose = input.glucoseHistory.last else {
+            throw AlgorithmError.missingGlucose
+        }
+
+        let start = startDate ?? latestGlucose.startDate
+
+        let insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil)
+
+        let settings = input.settings
+
+        if let doseStart = input.doses.first?.startDate {
+            assert(!input.settings.basal.isEmpty, "Missing basal history input.")
+            let basalStart = input.settings.basal.first!.startDate
+            precondition(basalStart <= doseStart, "Basal history must cover historic dose range. First dose date: \(doseStart) < \(basalStart)")
+        }
+
+        // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal
+        let annotatedDoses = input.doses.annotated(with: input.settings.basal)
+
+        let insulinEffects = annotatedDoses.glucoseEffects(
+            insulinModelProvider: insulinModelProvider,
+            longestEffectDuration: settings.insulinActivityDuration,
+            insulinSensitivityHistory: settings.sensitivity,
+            from: start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval).dateFlooredToTimeInterval(settings.delta),
+            to: nil)
+
+        // ICE
+        let insulinCounteractionEffects = input.glucoseHistory.counteractionEffects(to: insulinEffects)
+
+        // Carb Effects
+        let carbEffects = input.carbEntries.map(
+            to: insulinCounteractionEffects,
+            carbRatio: settings.carbRatio,
+            insulinSensitivity: settings.sensitivity
+        ).dynamicGlucoseEffects(
+            from: start.addingTimeInterval(-IntegralRetrospectiveCorrection.retrospectionInterval),
+            carbRatios: settings.carbRatio,
+            insulinSensitivities: settings.sensitivity
+        )
+
+        // RC
+        let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects)
+        let retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * 1.01)
+
+        let rc: RetrospectiveCorrection
+
+        if input.settings.useIntegralRetrospectiveCorrection {
+            rc = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration)
+        } else {
+            rc = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration)
+        }
+
+        guard let curSensitivity = settings.sensitivity.closestPrior(to: start)?.value,
+              let curBasal = settings.basal.closestPrior(to: start)?.value,
+              let curTarget = settings.target.closestPrior(to: start)?.value else
+        {
+            throw AlgorithmError.incompleteSchedules
+        }
+
+        let rcEffect = rc.computeEffect(
+            startingAt: latestGlucose,
+            retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed,
+            recencyInterval: TimeInterval(minutes: 15),
+            insulinSensitivity: curSensitivity,
+            basalRate: curBasal,
+            correctionRange: curTarget,
+            retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval
+        )
+
+        var effects = [[GlucoseEffect]]()
+
+        if settings.algorithmEffectsOptions.contains(.carbs) {
+            effects.append(carbEffects)
+        }
+
+        if settings.algorithmEffectsOptions.contains(.insulin) {
+            effects.append(insulinEffects)
+        }
+
+        if settings.algorithmEffectsOptions.contains(.retrospection) {
+            effects.append(rcEffect)
+        }
+
+        // Glucose Momentum
+        let momentumEffects: [GlucoseEffect]
+        if settings.algorithmEffectsOptions.contains(.momentum) {
+            let momentumInputData = input.glucoseHistory.filterDateRange(start.addingTimeInterval(-GlucoseMath.momentumDataInterval), start)
+            momentumEffects = momentumInputData.linearMomentumEffect()
+        } else {
+            momentumEffects = []
+        }
+
+        var prediction = LoopMath.predictGlucose(startingAt: latestGlucose, momentum: momentumEffects, effects: effects)
+
+        // Dosing requires prediction entries at least as long as the insulin model duration.
+        // If our prediction is shorter than that, then extend it here.
+        let finalDate = latestGlucose.startDate.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration)
+        if let last = prediction.last, last.startDate < finalDate {
+            prediction.append(PredictedGlucoseValue(startDate: finalDate, quantity: last.quantity))
+        }
+
+        return LoopPrediction(
+            glucose: prediction,
+            effects: LoopAlgorithmEffects(
+                insulin: insulinEffects,
+                carbs: carbEffects,
+                retrospectiveCorrection: rcEffect,
+                momentum: momentumEffects,
+                insulinCounteraction: insulinCounteractionEffects
+            )
+        )
+    }
+}
+
+

+ 21 - 0
Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopAlgorithmInput.swift

@@ -0,0 +1,21 @@
+//
+//  LoopAlgorithmInput.swift
+//  LoopKit
+//
+//  Created by Pete Schwamb on 9/21/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+public enum DoseRecommendationType: String {
+    case manualBolus
+    case automaticBolus
+    case tempBasal
+}
+
+public struct LoopAlgorithmInput {
+    public var predictionInput: LoopPredictionInput
+    public var predictionDate: Date
+    public var doseRecommendationType: DoseRecommendationType
+}

+ 129 - 0
Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopAlgorithmSettings.swift

@@ -0,0 +1,129 @@
+//
+//  LoopAlgorithmSettings.swift
+//  LoopKit
+//
+//  Created by Pete Schwamb on 9/21/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+public struct LoopAlgorithmSettings {
+    // Algorithm input time range: t-16h to t
+    public var basal: [AbsoluteScheduleValue<Double>]
+
+    // Algorithm input time range: t-16h to t (eventually with mid-absorption isf changes, it will be t-10h to h)
+    public var sensitivity: [AbsoluteScheduleValue<HKQuantity>]
+
+    // Algorithm input time range: t-10h to t
+    public var carbRatio: [AbsoluteScheduleValue<Double>]
+
+    // Algorithm input time range: t to t+6
+    public var target: [AbsoluteScheduleValue<ClosedRange<HKQuantity>>]
+
+    public var delta: TimeInterval
+    public var insulinActivityDuration: TimeInterval
+    public var algorithmEffectsOptions: AlgorithmEffectsOptions
+    public var maximumBasalRatePerHour: Double? = nil
+    public var maximumBolus: Double? = nil
+    public var suspendThreshold: GlucoseThreshold? = nil
+    public var useIntegralRetrospectiveCorrection: Bool = false
+
+    public init(
+        basal: [AbsoluteScheduleValue<Double>],
+        sensitivity: [AbsoluteScheduleValue<HKQuantity>],
+        carbRatio: [AbsoluteScheduleValue<Double>],
+        target: [AbsoluteScheduleValue<ClosedRange<HKQuantity>>],
+        delta: TimeInterval = GlucoseMath.defaultDelta,
+        insulinActivityDuration: TimeInterval = InsulinMath.defaultInsulinActivityDuration,
+        algorithmEffectsOptions: AlgorithmEffectsOptions = .all,
+        maximumBasalRatePerHour: Double? = nil,
+        maximumBolus: Double? = nil,
+        suspendThreshold: GlucoseThreshold? = nil,
+        useIntegralRetrospectiveCorrection: Bool = false)
+    {
+        self.basal = basal
+        self.sensitivity = sensitivity
+        self.carbRatio = carbRatio
+        self.target = target
+        self.delta = delta
+        self.insulinActivityDuration = insulinActivityDuration
+        self.algorithmEffectsOptions = algorithmEffectsOptions
+        self.maximumBasalRatePerHour = maximumBasalRatePerHour
+        self.maximumBolus = maximumBolus
+        self.suspendThreshold = suspendThreshold
+        self.useIntegralRetrospectiveCorrection = useIntegralRetrospectiveCorrection
+    }
+}
+
+extension LoopAlgorithmSettings: Codable {
+
+    public init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+
+        self.basal = try container.decode([AbsoluteScheduleValue<Double>].self, forKey: .basal)
+        let sensitivityMgdl = try container.decode([AbsoluteScheduleValue<Double>].self, forKey: .sensitivity)
+        self.sensitivity = sensitivityMgdl.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.value))}
+        self.carbRatio = try container.decode([AbsoluteScheduleValue<Double>].self, forKey: .carbRatio)
+        let targetMgdl = try container.decode([AbsoluteScheduleValue<DoubleRange>].self, forKey: .target)
+        self.target = targetMgdl.map {
+            let min = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.value.minValue)
+            let max = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.value.minValue)
+            return AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: ClosedRange(uncheckedBounds: (lower: min, upper: max)))
+        }
+        self.delta = TimeInterval(minutes: 5)
+        self.insulinActivityDuration = InsulinMath.defaultInsulinActivityDuration
+        self.algorithmEffectsOptions = .all
+        self.maximumBasalRatePerHour = try container.decodeIfPresent(Double.self, forKey: .maximumBasalRatePerHour)
+        self.maximumBolus = try container.decodeIfPresent(Double.self, forKey: .maximumBolus)
+        self.suspendThreshold = try container.decodeIfPresent(GlucoseThreshold.self, forKey: .suspendThreshold)
+        self.useIntegralRetrospectiveCorrection = try container.decodeIfPresent(Bool.self, forKey: .useIntegralRetrospectiveCorrection) ?? false
+    }
+
+    public func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+
+        try container.encode(basal, forKey: .basal)
+        let sensitivityMgdl = sensitivity.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: $0.value.doubleValue(for: .milligramsPerDeciliter)) }
+        try container.encode(sensitivityMgdl, forKey: .sensitivity)
+        try container.encode(carbRatio, forKey: .carbRatio)
+        let targetMgdl = target.map {
+            let min = $0.value.lowerBound.doubleValue(for: .milligramsPerDeciliter)
+            let max = $0.value.upperBound.doubleValue(for: .milligramsPerDeciliter)
+            return AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: DoubleRange(minValue: min, maxValue: max))
+        }
+        try container.encode(targetMgdl, forKey: .target)
+        try container.encode(maximumBasalRatePerHour, forKey: .maximumBasalRatePerHour)
+        try container.encode(maximumBolus, forKey: .maximumBolus)
+        try container.encode(suspendThreshold, forKey: .suspendThreshold)
+        if useIntegralRetrospectiveCorrection {
+            try container.encode(useIntegralRetrospectiveCorrection, forKey: .useIntegralRetrospectiveCorrection)
+        }
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case basal
+        case sensitivity
+        case carbRatio
+        case target
+        case delta
+        case insulinActivityDuration
+        case algorithmEffectsOptions
+        case maximumBasalRatePerHour
+        case maximumBolus
+        case suspendThreshold
+        case useIntegralRetrospectiveCorrection
+    }
+}
+
+extension LoopAlgorithmSettings {
+
+    var simplifiedForFixture: LoopAlgorithmSettings {
+        return LoopAlgorithmSettings(
+            basal: basal,
+            sensitivity: sensitivity,
+            carbRatio: carbRatio,
+            target: target)
+    }
+}

+ 94 - 0
Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopPredictionInput.swift

@@ -0,0 +1,94 @@
+//
+//  LoopAlgorithmInput.swift
+//  Learn
+//
+//  Created by Pete Schwamb on 7/29/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+public struct LoopPredictionInput: GlucosePredictionInput {
+    // Algorithm input time range: t-10h to t
+    public var glucoseHistory: [StoredGlucoseSample]
+
+    // Algorithm input time range: t-16h to t
+    public var doses: [DoseEntry]
+
+    // Algorithm input time range: t-10h to t
+    public var carbEntries: [StoredCarbEntry]
+
+    public var settings: LoopAlgorithmSettings
+
+    public init(
+        glucoseHistory: [StoredGlucoseSample],
+        doses: [DoseEntry],
+        carbEntries: [StoredCarbEntry],
+        settings: LoopAlgorithmSettings)
+    {
+        self.glucoseHistory = glucoseHistory
+        self.doses = doses
+        self.carbEntries = carbEntries
+        self.settings = settings
+    }
+}
+
+
+extension LoopPredictionInput: Codable {
+    public init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+
+        self.glucoseHistory = try container.decode([StoredGlucoseSample].self, forKey: .glucoseHistory)
+        self.doses = try container.decode([DoseEntry].self, forKey: .doses)
+        self.carbEntries = try container.decode([StoredCarbEntry].self, forKey: .carbEntries)
+        self.settings = try container.decode(LoopAlgorithmSettings.self, forKey: .settings)
+    }
+
+    public func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(glucoseHistory, forKey: .glucoseHistory)
+        try container.encode(doses, forKey: .doses)
+        try container.encode(carbEntries, forKey: .carbEntries)
+        try container.encode(settings, forKey: .settings)
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case glucoseHistory
+        case doses
+        case carbEntries
+        case settings
+    }
+}
+
+extension LoopPredictionInput {
+
+    var simplifiedForFixture: LoopPredictionInput {
+        return LoopPredictionInput(
+            glucoseHistory: glucoseHistory.map {
+                return StoredGlucoseSample(
+                    startDate: $0.startDate,
+                    quantity: $0.quantity,
+                    isDisplayOnly: $0.isDisplayOnly)
+            },
+            doses: doses.map {
+                DoseEntry(type: $0.type, startDate: $0.startDate, endDate: $0.endDate, value: $0.value, unit: $0.unit)
+            },
+            carbEntries: carbEntries.map {
+                StoredCarbEntry(startDate: $0.startDate, quantity: $0.quantity, absorptionTime: $0.absorptionTime)
+            },
+            settings: settings.simplifiedForFixture
+        )
+    }
+
+    public func printFixture() {
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+        encoder.dateEncodingStrategy = .iso8601
+        if let data = try? encoder.encode(self.simplifiedForFixture),
+           let json = String(data: data, encoding: .utf8)
+        {
+            print(json)
+        }
+    }
+}

+ 49 - 0
Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopPredictionOutput.swift

@@ -0,0 +1,49 @@
+//
+//  LoopPredictionOutput.swift
+//  LoopKit
+//
+//  Created by Pete Schwamb on 9/21/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+
+public struct LoopAlgorithmOutput {
+    public var predictedGlucose: [PredictedGlucoseValue]
+    public var doseRecommendation: AutomaticDoseRecommendation
+}
+
+extension LoopAlgorithmOutput: Codable {
+    public init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+
+        self.predictedGlucose = try container.decode([PredictedGlucoseValue].self, forKey: .predictedGlucose)
+        self.doseRecommendation = try container.decode(AutomaticDoseRecommendation.self, forKey: .doseRecommendation)
+    }
+
+    public func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(predictedGlucose, forKey: .doseRecommendation)
+        try container.encode(doseRecommendation, forKey: .doseRecommendation)
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case predictedGlucose
+        case doseRecommendation
+    }
+}
+
+extension LoopAlgorithmOutput {
+
+    public func printFixture() {
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+        encoder.dateEncodingStrategy = .iso8601
+        if let data = try? encoder.encode(self),
+           let json = String(data: data, encoding: .utf8)
+        {
+            print(json)
+        }
+    }
+}

+ 22 - 0
Dependencies/LoopKit/LoopKit/Pluggable.swift

@@ -0,0 +1,22 @@
+//
+//  Pluggable.swift
+//  LoopKit
+//
+//  Created by Nathaniel Hamming on 2023-09-08.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+public protocol Pluggable: AnyObject {
+    /// The unique identifier for this plugin.
+    static var pluginIdentifier: String { get }
+    
+    /// A plugin may need a reference to another plugin. This callback allows for such a reference.
+    /// It is called once during app initialization after plugins are initialized and again as new plugins are added and initialized.
+    func initializationComplete(for pluggables: [Pluggable])
+}
+
+public extension Pluggable {
+    var pluginIdentifier: String { return type(of: self).pluginIdentifier }
+    
+    func initializationComplete(for pluggables: [Pluggable]) { } // optional
+}

+ 226 - 0
Dependencies/LoopKit/LoopKit/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift

@@ -0,0 +1,226 @@
+//
+//  IntegralRetrospectiveCorrection.swift
+//  Loop
+//
+//  Created by Dragan Maksimovic on 9/19/21.
+//  Copyright © 2021 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+/**
+    Integral Retrospective Correction (IRC) calculates a correction effect in glucose prediction based on a timeline of past discrepancies between observed glucose movement and movement expected based on insulin and carb models. Integral retrospective correction acts as a proportional-integral-differential (PID) controller aimed at reducing modeling errors in glucose prediction.
+ 
+    In the above summary, "discrepancy" is a difference between the actual glucose and the model predicted glucose over retrospective correction grouping interval (set to 30 min in LoopSettings), whereas "past discrepancies" refers to a timeline of discrepancies computed over retrospective correction integration interval (set to 180 min in Loop Settings).
+ 
+ */
+public class IntegralRetrospectiveCorrection: RetrospectiveCorrection {
+    public static let retrospectionInterval = TimeInterval(minutes: 180)
+
+    /// RetrospectiveCorrection protocol variables
+    /// Standard effect duration
+    let effectDuration: TimeInterval
+    /// Overall retrospective correction effect
+    public var totalGlucoseCorrectionEffect: HKQuantity?
+    
+    /**
+     Integral retrospective correction parameters:
+     - currentDiscrepancyGain: Standard retrospective correction gain
+     - persistentDiscrepancyGain: Gain for persistent long-term modeling errors, must be greater than or equal to currentDiscrepancyGain
+     - correctionTimeConstant: How fast integral effect accumulates in response to persistent errors
+     - differentialGain: Differential effect gain
+     - delta: Glucose sampling time interval (5 min)
+     - maximumCorrectionEffectDuration: Maximum duration of the correction effect in glucose prediction
+     - retrospectiveCorrectionIntegrationInterval: Maximum duration over which to integrate retrospective correction changes
+    */
+    static let currentDiscrepancyGain: Double = 1.0
+    static let persistentDiscrepancyGain: Double = 2.0 // was 5.0
+    static let correctionTimeConstant: TimeInterval = TimeInterval(minutes: 60.0) // was 90.0
+    static let differentialGain: Double = 2.0
+    static let delta: TimeInterval = TimeInterval(minutes: 5.0)
+    static let maximumCorrectionEffectDuration: TimeInterval = TimeInterval(minutes: 180.0) // was 240.0
+    
+    /// Initialize computed integral retrospective correction parameters
+    static let integralForget: Double = exp( -delta.minutes / correctionTimeConstant.minutes )
+    static let integralGain: Double = ((1 - integralForget) / integralForget) *
+        (persistentDiscrepancyGain - currentDiscrepancyGain)
+    static let proportionalGain: Double = currentDiscrepancyGain - integralGain
+    
+    /// All math is performed with glucose expressed in mg/dL
+    private let unit = HKUnit.milligramsPerDeciliter
+    
+    /// State variables reported in diagnostic issue report
+    var recentDiscrepancyValues: [Double] = []
+    var integralCorrectionEffectDuration: TimeInterval?
+    var proportionalCorrection: Double = 0.0
+    var integralCorrection: Double = 0.0
+    var differentialCorrection: Double = 0.0
+    var currentDate: Date = Date()
+    var ircStatus: String = "-"
+    
+    /**
+     Initialize integral retrospective correction settings based on current values of user settings
+     
+     - Parameters:
+        - settings: User settings
+        - insulinSensitivity: User insulin sensitivity schedule
+        - basalRates: User basal rate schedule
+     
+     - Returns: Integral Retrospective Correction customized with controller parameters and user settings
+    */
+    public init(effectDuration: TimeInterval) {
+        self.effectDuration = effectDuration
+    }
+    
+    /**
+     Calculates overall correction effect based on timeline of discrepancies, and updates glucoseCorrectionEffect
+     
+     - Parameters:
+     - glucose: Most recent glucose
+     - retrospectiveGlucoseDiscrepanciesSummed: Timeline of past discepancies
+     
+     - Returns:
+     - totalRetrospectiveCorrection: Overall glucose effect
+     */
+    public func computeEffect(
+        startingAt startingGlucose: GlucoseValue,
+        retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?,
+        recencyInterval: TimeInterval,
+        insulinSensitivity: HKQuantity,
+        basalRate: Double,
+        correctionRange: ClosedRange<HKQuantity>,
+        retrospectiveCorrectionGroupingInterval: TimeInterval
+        ) -> [GlucoseEffect] {
+        
+        // Loop settings relevant for calculation of effect limits
+        // let settings = UserDefaults.appGroup?.loopSettings ?? LoopSettings()
+        currentDate = Date()
+        
+        // Last discrepancy should be recent, otherwise clear the effect and return
+        let glucoseDate = startingGlucose.startDate
+        var glucoseCorrectionEffect: [GlucoseEffect] = []
+        guard let currentDiscrepancy = retrospectiveGlucoseDiscrepanciesSummed?.last,
+            glucoseDate.timeIntervalSince(currentDiscrepancy.endDate) <= recencyInterval
+            else {
+                ircStatus = "discrepancy not available, effect not computed."
+                totalGlucoseCorrectionEffect = nil
+                return( [] )
+        }
+        
+        // Default values if we are not able to calculate integral retrospective correction
+        ircStatus = "defaulted to standard RC, past discrepancies or user settings not available."
+        let currentDiscrepancyValue = currentDiscrepancy.quantity.doubleValue(for: unit)
+        var scaledCorrection = currentDiscrepancyValue
+        totalGlucoseCorrectionEffect = HKQuantity(unit: unit, doubleValue: currentDiscrepancyValue)
+        integralCorrectionEffectDuration = effectDuration
+        
+        // Calculate integral retrospective correction if past discrepancies over integration interval are available and if user settings are available
+        if  let pastDiscrepancies = retrospectiveGlucoseDiscrepanciesSummed?.filterDateRange(glucoseDate.addingTimeInterval(-Self.retrospectionInterval), glucoseDate) {
+            ircStatus = "effect computed successfully."
+            
+            // To reduce response delay, integral retrospective correction is computed over an array of recent contiguous discrepancy values having the same sign as the latest discrepancy value
+            recentDiscrepancyValues = []
+            var nextDiscrepancy = currentDiscrepancy
+            let currentDiscrepancySign = currentDiscrepancy.quantity.doubleValue(for: unit).sign
+            for pastDiscrepancy in pastDiscrepancies.reversed() {
+                let pastDiscrepancyValue = pastDiscrepancy.quantity.doubleValue(for: unit)
+                if (pastDiscrepancyValue.sign == currentDiscrepancySign &&
+                    nextDiscrepancy.endDate.timeIntervalSince(pastDiscrepancy.endDate)
+                    <= recencyInterval && abs(pastDiscrepancyValue) >= 0.1)
+                {
+                    recentDiscrepancyValues.append(pastDiscrepancyValue)
+                    nextDiscrepancy = pastDiscrepancy
+                } else {
+                    break
+                }
+            }
+            recentDiscrepancyValues = recentDiscrepancyValues.reversed()
+
+            let correctionRangeMin = correctionRange.lowerBound.doubleValue(for: unit)
+            let correctionRangeMax = correctionRange.upperBound.doubleValue(for: unit)
+
+            let latestGlucoseValue = startingGlucose.quantity.doubleValue(for: unit) // most recent glucose
+            
+            // Safety limit for (+) integral effect. The limit is set to a larger value if the current blood glucose is further away from the correction range because we have more time available for corrections
+            let glucoseError = latestGlucoseValue - correctionRangeMax
+            let zeroTempEffect = abs(insulinSensitivity.doubleValue(for: unit) * basalRate)
+            let integralEffectPositiveLimit = min(max(glucoseError, 1.0 * zeroTempEffect), 4.0 * zeroTempEffect)
+            
+            // Limit for (-) integral effect: glucose prediction reduced by no more than 10 mg/dL below the correction range minimum
+            let integralEffectNegativeLimit = -max(10.0, latestGlucoseValue - correctionRangeMin)
+            
+            // Integral effect math
+            integralCorrection = 0.0
+            var integralCorrectionEffectMinutes = effectDuration.minutes - 2.0 * IntegralRetrospectiveCorrection.delta.minutes
+            for discrepancy in recentDiscrepancyValues {
+                integralCorrection =
+                    IntegralRetrospectiveCorrection.integralForget * integralCorrection +
+                    IntegralRetrospectiveCorrection.integralGain * discrepancy
+                integralCorrectionEffectMinutes += 2.0 * IntegralRetrospectiveCorrection.delta.minutes
+            }
+            // Limits applied to integral correction effect and effect duration
+            integralCorrection = min(max(integralCorrection, integralEffectNegativeLimit), integralEffectPositiveLimit)
+            integralCorrectionEffectMinutes = min(integralCorrectionEffectMinutes, IntegralRetrospectiveCorrection.maximumCorrectionEffectDuration.minutes)
+            
+            // Differential effect math
+            var differentialDiscrepancy: Double = 0.0
+            if recentDiscrepancyValues.count > 1 {
+                let previousDiscrepancyValue = recentDiscrepancyValues[recentDiscrepancyValues.count - 2]
+                differentialDiscrepancy = currentDiscrepancyValue - previousDiscrepancyValue
+            }
+            
+            // Overall glucose effect calculated as a sum of propotional, integral and differential effects
+            proportionalCorrection = IntegralRetrospectiveCorrection.proportionalGain * currentDiscrepancyValue
+
+	    // Differential effect added only when negative, to avoid upward stacking with momentum, while still mitigating sluggishness of retrospective correction when discrepancies start decreasing
+            if differentialDiscrepancy < 0.0 {
+                differentialCorrection = IntegralRetrospectiveCorrection.differentialGain * differentialDiscrepancy
+            } else {
+                differentialCorrection = 0.0
+            }
+
+            let totalCorrection = proportionalCorrection + integralCorrection + differentialCorrection
+            totalGlucoseCorrectionEffect = HKQuantity(unit: unit, doubleValue: totalCorrection)
+            integralCorrectionEffectDuration = TimeInterval(minutes: integralCorrectionEffectMinutes)
+            
+            // correction value scaled to account for extended effect duration
+            scaledCorrection = totalCorrection * effectDuration.minutes / integralCorrectionEffectDuration!.minutes
+        }
+        
+        let retrospectionTimeInterval = currentDiscrepancy.endDate.timeIntervalSince(currentDiscrepancy.startDate)
+        let discrepancyTime = max(retrospectionTimeInterval, retrospectiveCorrectionGroupingInterval)
+        let velocity = HKQuantity(unit: unit.unitDivided(by: .second()), doubleValue: scaledCorrection / discrepancyTime)
+        
+        // Update array of glucose correction effects
+        glucoseCorrectionEffect = startingGlucose.decayEffect(atRate: velocity, for: integralCorrectionEffectDuration!)
+        
+        // Return glucose correction effects
+        return( glucoseCorrectionEffect )
+    }
+    
+    public var debugDescription: String {
+        let report: [String] = [
+            "## IntegralRetrospectiveCorrection",
+            "",
+            "Last updated: \(currentDate)",
+            "Status: \(ircStatus)",
+            "currentDiscrepancyGain: \(IntegralRetrospectiveCorrection.currentDiscrepancyGain)",
+            "persistentDiscrepancyGain: \(IntegralRetrospectiveCorrection.persistentDiscrepancyGain)",
+            "correctionTimeConstant [min]: \(IntegralRetrospectiveCorrection.correctionTimeConstant.minutes)",
+            "proportionalGain: \(IntegralRetrospectiveCorrection.proportionalGain)",
+            "integralForget: \(IntegralRetrospectiveCorrection.integralForget)",
+            "integralGain: \(IntegralRetrospectiveCorrection.integralGain)",
+            "differentialGain: \(IntegralRetrospectiveCorrection.differentialGain)",
+            "Integration performed over \(recentDiscrepancyValues.count) most recent discrepancies having the same sign as the latest discrepancy value. Earliest-to-most-recent recentDiscrepancyValues [mg/dL]: \(recentDiscrepancyValues)",
+            "proportionalCorrection [mg/dL]: \(proportionalCorrection)",
+            "integralCorrection [mg/dL]: \(integralCorrection)",
+            "differentialCorrection [mg/dL]: \(differentialCorrection)",
+            "totalGlucoseCorrectionEffect: \(String(describing: totalGlucoseCorrectionEffect))",
+            "integralCorrectionEffectDuration [min]: \(String(describing: integralCorrectionEffectDuration?.minutes))"
+        ]
+        
+        return report.joined(separator: "\n")
+    }
+    
+}

+ 40 - 0
Dependencies/LoopKit/LoopKit/RetrospectiveCorrection/RetrospectiveCorrection.swift

@@ -0,0 +1,40 @@
+//
+//  RetrospectiveCorrection.swift
+//  Loop
+//
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+/// Derives a continued glucose effect from recent prediction discrepancies
+public protocol RetrospectiveCorrection: CustomDebugStringConvertible {
+    /// The maximum interval of historical glucose discrepancies that should be provided to the computation
+    static var retrospectionInterval: TimeInterval { get }
+
+    /// Overall retrospective correction effect
+    var totalGlucoseCorrectionEffect: HKQuantity? { get }
+
+    /// Calculates overall correction effect based on timeline of discrepancies, and updates glucoseCorrectionEffect
+    ///
+    /// - Parameters:
+    ///   - startingAt: Initial glucose value
+    ///   - retrospectiveGlucoseDiscrepanciesSummed: Timeline of past discepancies
+    ///   - recencyInterval: how recent discrepancy data must be, otherwise effect will be cleared
+    ///   - insulinSensitivity: Insulin sensitivity at time of initial glucose value
+    ///   - basalRate: Basal rate at time of initial glucose value
+    ///   - correctionRange: Correction range at time of initial glucose value
+    ///   - retrospectiveCorrectionGroupingInterval: Duration of discrepancy measurements
+    /// - Returns: Glucose correction effects
+    func computeEffect(
+        startingAt startingGlucose: GlucoseValue,
+        retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?,
+        recencyInterval: TimeInterval,
+        insulinSensitivity: HKQuantity,
+        basalRate: Double,
+        correctionRange: ClosedRange<HKQuantity>,
+        retrospectiveCorrectionGroupingInterval: TimeInterval
+    ) -> [GlucoseEffect]
+}

+ 71 - 0
Dependencies/LoopKit/LoopKit/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift

@@ -0,0 +1,71 @@
+//
+//  StandardRetrospectiveCorrection.swift
+//  Loop
+//
+//  Created by Dragan Maksimovic on 10/27/18.
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+/**
+ Standard Retrospective Correction (RC) calculates a correction effect in glucose prediction based on the most recent discrepancy between observed glucose movement and movement expected based on insulin and carb models. Standard retrospective correction acts as a proportional (P) controller aimed at reducing modeling errors in glucose prediction.
+ 
+ In the above summary, "discrepancy" is a difference between the actual glucose and the model predicted glucose over retrospective correction grouping interval (set to 30 min in LoopSettings)
+ */
+public class StandardRetrospectiveCorrection: RetrospectiveCorrection {
+    public static let retrospectionInterval = TimeInterval(minutes: 30)
+
+    /// RetrospectiveCorrection protocol variables
+    /// Standard effect duration
+    let effectDuration: TimeInterval
+    /// Overall retrospective correction effect
+    public var totalGlucoseCorrectionEffect: HKQuantity?
+
+    /// All math is performed with glucose expressed in mg/dL
+    private let unit = HKUnit.milligramsPerDeciliter
+
+    public init(effectDuration: TimeInterval) {
+        self.effectDuration = effectDuration
+    }
+
+    public func computeEffect(
+        startingAt startingGlucose: GlucoseValue,
+        retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?,
+        recencyInterval: TimeInterval,
+        insulinSensitivity: HKQuantity,
+        basalRate: Double,
+        correctionRange: ClosedRange<HKQuantity>,
+        retrospectiveCorrectionGroupingInterval: TimeInterval
+    ) -> [GlucoseEffect] {
+        // Last discrepancy should be recent, otherwise clear the effect and return
+        let glucoseDate = startingGlucose.startDate
+        guard let currentDiscrepancy = retrospectiveGlucoseDiscrepanciesSummed?.last,
+            glucoseDate.timeIntervalSince(currentDiscrepancy.endDate) <= recencyInterval
+        else {
+            totalGlucoseCorrectionEffect = nil
+            return []
+        }
+        
+        // Standard retrospective correction math
+        let currentDiscrepancyValue = currentDiscrepancy.quantity.doubleValue(for: unit)
+        totalGlucoseCorrectionEffect = HKQuantity(unit: unit, doubleValue: currentDiscrepancyValue)
+        
+        let retrospectionTimeInterval = currentDiscrepancy.endDate.timeIntervalSince(currentDiscrepancy.startDate)
+        let discrepancyTime = max(retrospectionTimeInterval, retrospectiveCorrectionGroupingInterval)
+        let velocity = HKQuantity(unit: unit.unitDivided(by: .second()), doubleValue: currentDiscrepancyValue / discrepancyTime)
+        
+        // Update array of glucose correction effects
+        return startingGlucose.decayEffect(atRate: velocity, for: effectDuration)
+    }
+
+    public var debugDescription: String {
+        let report: [String] = [
+            "## StandardRetrospectiveCorrection",
+            ""
+        ]
+
+        return report.joined(separator: "\n")
+    }
+}

+ 16 - 0
Dependencies/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift

@@ -0,0 +1,16 @@
+//
+//  RemoteActionDelegate.swift
+//  LoopKit
+//
+//  Created by Bill Gestrich on 3/19/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+public protocol RemoteActionDelegate: AnyObject {
+    func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws
+    func cancelRemoteOverride() async throws
+    func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws
+    func deliverRemoteBolus(amountInUnits: Double) async throws
+}

+ 57 - 0
Dependencies/LoopKit/LoopKit/Service/StatefulPluggable.swift

@@ -0,0 +1,57 @@
+//
+//  StatefulPluggable.swift
+//  LoopKit
+//
+//  Created by Nathaniel Hamming on 2023-09-05.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+
+public protocol StatefulPlugin {
+    var pluginType: StatefulPluggable.Type? { get }
+}
+
+public protocol StatefulPluggableProvider {
+    /// The stateful plugin with the specified identifier.
+    ///
+    /// - Parameters:
+    ///     - identifier: The identifier of the stateful plugin
+    /// - Returns: Either a stateful plugin with matching identifier or nil.
+    func statefulPlugin(withIdentifier identifier: String) -> StatefulPluggable?
+}
+
+public protocol StatefulPluggableDelegate: AnyObject {
+    /// Informs the delegate that the state of the specified plugin was updated and the delegate should persist the plugin. May
+    /// be invoked prior to the plugin completing setup.
+    ///
+    /// - Parameters:
+    ///     - plugin: The plugin that updated state.
+    func pluginDidUpdateState(_ plugin: StatefulPluggable)
+
+    /// Informs the delegate that the plugin wants deletion.
+    ///
+    /// - Parameters:
+    ///     - plugin: The plugin that wants deletion.
+    func pluginWantsDeletion(_ plugin: StatefulPluggable)
+}
+
+public protocol StatefulPluggable: Pluggable {
+    typealias RawStateValue = [String: Any]
+
+    /// The delegate to notify of plugin updates.
+    var stateDelegate: StatefulPluggableDelegate? { get set }
+
+    /// Initializes the plugin with the previously-serialized state.
+    ///
+    /// - Parameters:
+    ///     - rawState: The previously-serialized state of the plugin.
+    init?(rawState: RawStateValue)
+
+    /// The current, serializable state of the plugin.
+    var rawState: RawStateValue { get }
+
+    /// Is the plugin onboarded and ready for use?
+    var isOnboarded: Bool { get }
+}

+ 160 - 0
Dependencies/LoopKit/LoopKitTests/Charts/ChartAxisValuesStaticGeneratorTests.swift

@@ -0,0 +1,160 @@
+//
+//  ChartAxisValuesStaticGeneratorTests.swift
+//  LoopTests
+//
+//  Created by Nathaniel Hamming on 2020-09-29.
+//  Copyright © 2020 LoopKit Authors. All rights reserved.
+//
+
+import XCTest
+import SwiftCharts
+@testable import LoopKitUI
+
+class ChartAxisValuesStaticGeneratorTests: XCTestCase {
+
+    private var maxSegmentCount: Double = 4
+    private let minSegmentCount: Double = 2
+    private let axisValueGenerator: ChartAxisValueStaticGenerator = { ChartAxisValueDouble($0) }
+    private let addPaddingSegmentIfEdge: Bool = false
+    private let multiple: Double = 40
+
+    func testGenerateYAxisValuesUsingLinearSegmentStep40To400() {
+        let pointsAtLimits = [
+            ChartPoint(x: ChartAxisValue(scalar: 1), y:  ChartAxisValue(scalar: 40)),
+            ChartPoint(x: ChartAxisValue(scalar: 2), y:  ChartAxisValue(scalar: 120)),
+            ChartPoint(x: ChartAxisValue(scalar: 3), y:  ChartAxisValue(scalar: 250)),
+            ChartPoint(x: ChartAxisValue(scalar: 4), y:  ChartAxisValue(scalar: 400)),
+        ]
+        var yAxisValues = generateYAxisValuesUsingLinearSegmentStep(chartPoints: pointsAtLimits)
+        XCTAssertEqual(yAxisValues[0].scalar, 40)
+        XCTAssertEqual(yAxisValues[1].scalar, 160)
+        XCTAssertEqual(yAxisValues[2].scalar, 280)
+        XCTAssertEqual(yAxisValues[3].scalar, 400)
+        
+        let pointsNearLimits = [
+            ChartPoint(x: ChartAxisValue(scalar: 1), y:  ChartAxisValue(scalar: 41)),
+            ChartPoint(x: ChartAxisValue(scalar: 2), y:  ChartAxisValue(scalar: 42)),
+            ChartPoint(x: ChartAxisValue(scalar: 3), y:  ChartAxisValue(scalar: 43)),
+            ChartPoint(x: ChartAxisValue(scalar: 4), y:  ChartAxisValue(scalar: 397)),
+            ChartPoint(x: ChartAxisValue(scalar: 5), y:  ChartAxisValue(scalar: 398)),
+            ChartPoint(x: ChartAxisValue(scalar: 6), y:  ChartAxisValue(scalar: 399)),
+        ]
+        yAxisValues = generateYAxisValuesUsingLinearSegmentStep(chartPoints: pointsNearLimits)
+        XCTAssertEqual(yAxisValues[0].scalar, 40)
+        XCTAssertEqual(yAxisValues[1].scalar, 160)
+        XCTAssertEqual(yAxisValues[2].scalar, 280)
+        XCTAssertEqual(yAxisValues[3].scalar, 400)
+    }
+    
+    func testGenerateYAxisValuesUsingLinearSegmentStep40To600() {
+        // the max expected value is 600, but the y-axis will go to 680 due to the step size
+        let pointsAtLimits = [
+            ChartPoint(x: ChartAxisValue(scalar: 1), y:  ChartAxisValue(scalar: 40)),
+            ChartPoint(x: ChartAxisValue(scalar: 2), y:  ChartAxisValue(scalar: 120)),
+            ChartPoint(x: ChartAxisValue(scalar: 3), y:  ChartAxisValue(scalar: 250)),
+            ChartPoint(x: ChartAxisValue(scalar: 4), y:  ChartAxisValue(scalar: 600)),
+        ]
+        var yAxisValues = generateYAxisValuesUsingLinearSegmentStep(chartPoints: pointsAtLimits)
+        XCTAssertEqual(yAxisValues[0].scalar, 40)
+        XCTAssertEqual(yAxisValues[1].scalar, 200)
+        XCTAssertEqual(yAxisValues[2].scalar, 360)
+        XCTAssertEqual(yAxisValues[3].scalar, 520)
+        XCTAssertEqual(yAxisValues[4].scalar, 680)
+        
+        let pointsNearLimits = [
+            ChartPoint(x: ChartAxisValue(scalar: 1), y:  ChartAxisValue(scalar: 41)),
+            ChartPoint(x: ChartAxisValue(scalar: 2), y:  ChartAxisValue(scalar: 42)),
+            ChartPoint(x: ChartAxisValue(scalar: 3), y:  ChartAxisValue(scalar: 43)),
+            ChartPoint(x: ChartAxisValue(scalar: 4), y:  ChartAxisValue(scalar: 597)),
+            ChartPoint(x: ChartAxisValue(scalar: 5), y:  ChartAxisValue(scalar: 598)),
+            ChartPoint(x: ChartAxisValue(scalar: 6), y:  ChartAxisValue(scalar: 599)),
+        ]
+        yAxisValues = generateYAxisValuesUsingLinearSegmentStep(chartPoints: pointsNearLimits)
+        XCTAssertEqual(yAxisValues[0].scalar, 40)
+        XCTAssertEqual(yAxisValues[1].scalar, 200)
+        XCTAssertEqual(yAxisValues[2].scalar, 360)
+        XCTAssertEqual(yAxisValues[3].scalar, 520)
+        XCTAssertEqual(yAxisValues[4].scalar, 680)
+    }
+
+    func testGenerateYAxisValuesUsingLinearSegmentStep0To400() {
+        // when starting at 0, the max segment size is set to 5
+        maxSegmentCount = 5
+
+        let pointsAtLimits = [
+                ChartPoint(x: ChartAxisValue(scalar: 1), y:  ChartAxisValue(scalar: 0)),
+                ChartPoint(x: ChartAxisValue(scalar: 2), y:  ChartAxisValue(scalar: 120)),
+                ChartPoint(x: ChartAxisValue(scalar: 3), y:  ChartAxisValue(scalar: 250)),
+                ChartPoint(x: ChartAxisValue(scalar: 4), y:  ChartAxisValue(scalar: 400)),
+            ]
+            var yAxisValues = generateYAxisValuesUsingLinearSegmentStep(chartPoints: pointsAtLimits)
+            XCTAssertEqual(yAxisValues[0].scalar, 0)
+            XCTAssertEqual(yAxisValues[1].scalar, 80)
+            XCTAssertEqual(yAxisValues[2].scalar, 160)
+            XCTAssertEqual(yAxisValues[3].scalar, 240)
+            XCTAssertEqual(yAxisValues[4].scalar, 320)
+            XCTAssertEqual(yAxisValues[5].scalar, 400)
+            
+            let pointsNearLimits = [
+                ChartPoint(x: ChartAxisValue(scalar: 1), y:  ChartAxisValue(scalar: 1)),
+                ChartPoint(x: ChartAxisValue(scalar: 2), y:  ChartAxisValue(scalar: 2)),
+                ChartPoint(x: ChartAxisValue(scalar: 3), y:  ChartAxisValue(scalar: 3)),
+                ChartPoint(x: ChartAxisValue(scalar: 4), y:  ChartAxisValue(scalar: 397)),
+                ChartPoint(x: ChartAxisValue(scalar: 5), y:  ChartAxisValue(scalar: 398)),
+                ChartPoint(x: ChartAxisValue(scalar: 6), y:  ChartAxisValue(scalar: 399)),
+            ]
+            yAxisValues = generateYAxisValuesUsingLinearSegmentStep(chartPoints: pointsNearLimits)
+            XCTAssertEqual(yAxisValues[0].scalar, 0)
+            XCTAssertEqual(yAxisValues[1].scalar, 80)
+            XCTAssertEqual(yAxisValues[2].scalar, 160)
+            XCTAssertEqual(yAxisValues[3].scalar, 240)
+            XCTAssertEqual(yAxisValues[4].scalar, 320)
+            XCTAssertEqual(yAxisValues[5].scalar, 400)
+    }
+    
+    func testGenerateYAxisValuesUsingLinearSegmentStep0To680() {
+        // when starting at 0, the max segment size is set to 5
+        maxSegmentCount = 5
+        
+        let pointsAtLimits = [
+            ChartPoint(x: ChartAxisValue(scalar: 1), y:  ChartAxisValue(scalar: 0)),
+            ChartPoint(x: ChartAxisValue(scalar: 2), y:  ChartAxisValue(scalar: 120)),
+            ChartPoint(x: ChartAxisValue(scalar: 3), y:  ChartAxisValue(scalar: 250)),
+            ChartPoint(x: ChartAxisValue(scalar: 4), y:  ChartAxisValue(scalar: 600)),
+        ]
+        var yAxisValues = generateYAxisValuesUsingLinearSegmentStep(chartPoints: pointsAtLimits)
+        XCTAssertEqual(yAxisValues[0].scalar, 0)
+        XCTAssertEqual(yAxisValues[1].scalar, 120)
+        XCTAssertEqual(yAxisValues[2].scalar, 240)
+        XCTAssertEqual(yAxisValues[3].scalar, 360)
+        XCTAssertEqual(yAxisValues[4].scalar, 480)
+        XCTAssertEqual(yAxisValues[5].scalar, 600)
+        
+        let pointsNearLimits = [
+            ChartPoint(x: ChartAxisValue(scalar: 1), y:  ChartAxisValue(scalar: 1)),
+            ChartPoint(x: ChartAxisValue(scalar: 2), y:  ChartAxisValue(scalar: 2)),
+            ChartPoint(x: ChartAxisValue(scalar: 3), y:  ChartAxisValue(scalar: 3)),
+            ChartPoint(x: ChartAxisValue(scalar: 4), y:  ChartAxisValue(scalar: 597)),
+            ChartPoint(x: ChartAxisValue(scalar: 5), y:  ChartAxisValue(scalar: 598)),
+            ChartPoint(x: ChartAxisValue(scalar: 6), y:  ChartAxisValue(scalar: 599)),
+        ]
+        yAxisValues = generateYAxisValuesUsingLinearSegmentStep(chartPoints: pointsNearLimits)
+        XCTAssertEqual(yAxisValues[0].scalar, 0)
+        XCTAssertEqual(yAxisValues[1].scalar, 120)
+        XCTAssertEqual(yAxisValues[2].scalar, 240)
+        XCTAssertEqual(yAxisValues[3].scalar, 360)
+        XCTAssertEqual(yAxisValues[4].scalar, 480)
+        XCTAssertEqual(yAxisValues[5].scalar, 600)
+    }
+}
+
+extension ChartAxisValuesStaticGeneratorTests {
+    func generateYAxisValuesUsingLinearSegmentStep(chartPoints: [ChartPoint]) -> [ChartAxisValue] {
+        return ChartAxisValuesStaticGenerator.generateYAxisValuesUsingLinearSegmentStep(chartPoints: chartPoints,
+                                                                                        minSegmentCount: minSegmentCount,
+                                                                                        maxSegmentCount: maxSegmentCount,
+                                                                                        multiple: multiple,
+                                                                                        axisValueGenerator: axisValueGenerator,
+                                                                                        addPaddingSegmentIfEdge: addPaddingSegmentIfEdge)
+    }
+}

+ 147 - 0
Dependencies/LoopKit/LoopKitTests/Charts/PredictedGlucoseChartTests.swift

@@ -0,0 +1,147 @@
+//
+//  PredictedGlucoseChartTests.swift
+//  LoopTests
+//
+//  Created by Nathaniel Hamming on 2020-09-29.
+//  Copyright © 2020 LoopKit Authors. All rights reserved.
+//
+
+import XCTest
+import HealthKit
+import LoopKit
+import SwiftCharts
+@testable import LoopKitUI
+
+class PredictedGlucoseChartTests: XCTestCase {
+
+    private let yAxisStepSizeMGDL: Double = 40
+    
+    func testClampingPredictedGlucoseValues40To400() {
+        let glucoseValues = [
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 250), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 400), startDate: Date())
+        ]
+        let predictedGlucoseValues =  [
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 0), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 280), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 380), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 400), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 480), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 580), startDate: Date())
+        ]
+        let predictedGlucoseChart = PredictedGlucoseChart(predictedGlucoseBounds: .default,
+                                                          yAxisStepSizeMGDLOverride: yAxisStepSizeMGDL)
+        predictedGlucoseChart.setGlucoseValues(glucoseValues)
+        predictedGlucoseChart.setPredictedGlucoseValues(predictedGlucoseValues)
+        let predictedGlucosePoints = predictedGlucoseChart.predictedGlucosePoints
+        XCTAssertEqual(predictedGlucosePoints[0].y.scalar, 40)
+        XCTAssertEqual(predictedGlucosePoints[1].y.scalar, 40)
+        XCTAssertEqual(predictedGlucosePoints[2].y.scalar, 280)
+        XCTAssertEqual(predictedGlucosePoints[3].y.scalar, 380)
+        XCTAssertEqual(predictedGlucosePoints[4].y.scalar, 400)
+        XCTAssertEqual(predictedGlucosePoints[5].y.scalar, 400)
+        XCTAssertEqual(predictedGlucosePoints[6].y.scalar, 400)
+    }
+
+    func testClampingPredictedGlucoseValues40To600() {
+        // the max expected value is 600, but the y-axis will go to 680 due to the step size
+        let glucoseValues = [
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 350), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 480), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 600), startDate: Date())
+        ]
+        let predictedGlucoseValues =  [
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 0), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 300), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 450), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 600), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 750), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 1000), startDate: Date())
+        ]
+        let predictedGlucoseChart = PredictedGlucoseChart(predictedGlucoseBounds: .default,
+                                                          yAxisStepSizeMGDLOverride: yAxisStepSizeMGDL)
+        predictedGlucoseChart.setGlucoseValues(glucoseValues)
+        predictedGlucoseChart.setPredictedGlucoseValues(predictedGlucoseValues)
+        let predictedGlucosePoints = predictedGlucoseChart.predictedGlucosePoints
+        XCTAssertEqual(predictedGlucosePoints[0].y.scalar, 40)
+        XCTAssertEqual(predictedGlucosePoints[1].y.scalar, 40)
+        XCTAssertEqual(predictedGlucosePoints[2].y.scalar, 300)
+        XCTAssertEqual(predictedGlucosePoints[3].y.scalar, 450)
+        XCTAssertEqual(predictedGlucosePoints[4].y.scalar, 600)
+        XCTAssertEqual(predictedGlucosePoints[5].y.scalar, 680)
+        XCTAssertEqual(predictedGlucosePoints[6].y.scalar, 680)
+    }
+
+    func testClampingPredictedGlucoseValues0To400() {
+        let glucoseValues = [
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 0), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 250), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 400), startDate: Date())
+        ]
+        let predictedGlucoseValues =  [
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: -100), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 0), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 380), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 400), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 480), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 580), startDate: Date())
+        ]
+        let predictedGlucoseChart = PredictedGlucoseChart(predictedGlucoseBounds: .default,
+                                                          yAxisStepSizeMGDLOverride: yAxisStepSizeMGDL)
+        predictedGlucoseChart.setGlucoseValues(glucoseValues)
+        predictedGlucoseChart.setPredictedGlucoseValues(predictedGlucoseValues)
+        let predictedGlucosePoints = predictedGlucoseChart.predictedGlucosePoints
+        XCTAssertEqual(predictedGlucosePoints[0].y.scalar, 0)
+        XCTAssertEqual(predictedGlucosePoints[1].y.scalar, 0)
+        XCTAssertEqual(predictedGlucosePoints[2].y.scalar, 100)
+        XCTAssertEqual(predictedGlucosePoints[3].y.scalar, 380)
+        XCTAssertEqual(predictedGlucosePoints[4].y.scalar, 400)
+        XCTAssertEqual(predictedGlucosePoints[5].y.scalar, 400)
+        XCTAssertEqual(predictedGlucosePoints[6].y.scalar, 400)
+    }
+    
+    func testClampingPredictedGlucoseValues0To600() {
+        let glucoseValues = [
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 0), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 350), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 480), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 600), startDate: Date())
+        ]
+        let predictedGlucoseValues =  [
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: -100), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 0), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 150), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 350), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 600), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 750), startDate: Date()),
+            GlucoseValueTestable(quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 1000), startDate: Date())
+        ]
+        let predictedGlucoseChart = PredictedGlucoseChart(predictedGlucoseBounds: .default,
+                                                          yAxisStepSizeMGDLOverride: yAxisStepSizeMGDL)
+        predictedGlucoseChart.setGlucoseValues(glucoseValues)
+        predictedGlucoseChart.setPredictedGlucoseValues(predictedGlucoseValues)
+        let predictedGlucosePoints = predictedGlucoseChart.predictedGlucosePoints
+        XCTAssertEqual(predictedGlucosePoints[0].y.scalar, 0)
+        XCTAssertEqual(predictedGlucosePoints[1].y.scalar, 0)
+        XCTAssertEqual(predictedGlucosePoints[2].y.scalar, 150)
+        XCTAssertEqual(predictedGlucosePoints[3].y.scalar, 350)
+        XCTAssertEqual(predictedGlucosePoints[4].y.scalar, 600)
+        XCTAssertEqual(predictedGlucosePoints[5].y.scalar, 600)
+        XCTAssertEqual(predictedGlucosePoints[6].y.scalar, 600)
+    }
+}
+
+struct GlucoseValueTestable: GlucoseValue {
+    var quantity: HKQuantity
+    
+    var startDate: Date
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1491 - 0
Dependencies/LoopKit/LoopKitTests/DoseMathTests.swift


+ 16 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/far_future_high_bg_forecast.json

@@ -0,0 +1,16 @@
+ [
+  {"date": "2015-07-19T16:30:00", "amount": 90},
+  {"date": "2015-07-19T17:00:00", "amount": 90},
+  {"date": "2015-07-19T17:30:00", "amount": 90},
+  {"date": "2015-07-19T18:00:00", "amount": 90},
+  {"date": "2015-07-19T18:30:00", "amount": 95},
+  {"date": "2015-07-19T19:00:00", "amount": 100},
+  {"date": "2015-07-19T19:30:00", "amount": 105},
+  {"date": "2015-07-19T20:00:00", "amount": 110},
+  {"date": "2015-07-19T20:30:00", "amount": 115},
+  {"date": "2015-07-19T21:00:00", "amount": 118},
+  {"date": "2015-07-19T21:30:00", "amount": 120},
+  {"date": "2015-07-19T21:30:00", "amount": 140},
+  {"date": "2015-07-19T21:30:00", "amount": 160},
+  {"date": "2015-07-19T21:30:00", "amount": 180}
+  ]

+ 24 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/far_future_high_bg_forecast_after_6_hours.json

@@ -0,0 +1,24 @@
+ [
+  {"date": "2015-07-19T16:30:00", "amount": 90},
+  {"date": "2015-07-19T17:00:00", "amount": 90},
+  {"date": "2015-07-19T17:30:00", "amount": 90},
+  {"date": "2015-07-19T18:00:00", "amount": 90},
+  {"date": "2015-07-19T18:30:00", "amount": 90},
+  {"date": "2015-07-19T19:00:00", "amount": 90},
+  {"date": "2015-07-19T19:30:00", "amount": 90},
+  {"date": "2015-07-19T20:00:00", "amount": 90},
+  {"date": "2015-07-19T20:30:00", "amount": 90},
+  {"date": "2015-07-19T21:00:00", "amount": 90},
+  {"date": "2015-07-19T21:30:00", "amount": 90},
+  {"date": "2015-07-19T22:00:00", "amount": 90},
+  {"date": "2015-07-19T22:30:00", "amount": 95},
+  {"date": "2015-07-19T23:00:00", "amount": 100},
+  {"date": "2015-07-19T23:30:00", "amount": 105},
+  {"date": "2015-07-20T00:00:00", "amount": 110},
+  {"date": "2015-07-20T00:30:00", "amount": 115},
+  {"date": "2015-07-20T01:00:00", "amount": 118},
+  {"date": "2015-07-20T01:30:00", "amount": 120},
+  {"date": "2015-07-20T02:30:00", "amount": 140},
+  {"date": "2015-07-20T02:30:00", "amount": 160},
+  {"date": "2015-07-20T03:30:00", "amount": 180}
+  ]

+ 44 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/read_selected_basal_profile.json

@@ -0,0 +1,44 @@
+[
+  {
+    "i": 0, 
+    "start": "00:00:00", 
+    "rate": 0.9, 
+    "minutes": 0
+  }, 
+  {
+    "i": 1, 
+    "start": "04:00:00", 
+    "rate": 0.925, 
+    "minutes": 240
+  }, 
+  {
+    "i": 2, 
+    "start": "07:00:00", 
+    "rate": 0.85,
+    "minutes": 420
+  }, 
+  {
+    "i": 3, 
+    "start": "10:00:00", 
+    "rate": 0.85,
+    "minutes": 600
+  }, 
+  {
+    "i": 4, 
+    "start": "12:00:00", 
+    "rate": 0.75, 
+    "minutes": 720
+  }, 
+  {
+    "i": 5, 
+    "start": "15:00:00", 
+    "rate": 0.8, 
+    "minutes": 900
+  }, 
+  {
+    "i": 6, 
+    "start": "22:00:00", 
+    "rate": 0.9, 
+    "minutes": 1320
+  }
+]

+ 7 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_correct_low_at_min.json

@@ -0,0 +1,7 @@
+[
+  {"date": "2015-07-19T18:00:00", "amount": 100},
+  {"date": "2015-07-19T18:30:00", "amount": 90},
+  {"date": "2015-07-19T19:00:00", "amount": 85},
+  {"date": "2015-07-19T19:30:00", "amount": 90},
+  {"date": "2015-07-19T20:00:00", "amount": 100}
+]

+ 7 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_dropping_then_rising.json

@@ -0,0 +1,7 @@
+[
+ {"date": "2015-07-19T18:00:00", "amount": 90},
+ {"date": "2015-07-19T19:00:00", "amount": 80},
+ {"date": "2015-07-19T20:00:00", "amount": 100},
+ {"date": "2015-07-19T21:00:00", "amount": 160},
+ {"date": "2015-07-19T22:00:00", "amount": 200}
+ ]

+ 4 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_flat_and_high.json

@@ -0,0 +1,4 @@
+[
+    {"date": "2015-07-19T18:00:00", "amount": 200},
+    {"date": "2015-07-19T22:00:00", "amount": 200},
+]

+ 7 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_high_and_falling.json

@@ -0,0 +1,7 @@
+[
+  {"date": "2015-07-19T18:00:00", "amount": 240},
+  {"date": "2015-07-19T19:00:00", "amount": 220},
+  {"date": "2015-07-19T20:00:00", "amount": 200},
+  {"date": "2015-07-19T21:00:00", "amount": 160},
+  {"date": "2015-07-19T22:00:00", "amount": 124}
+]

+ 7 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_high_and_rising.json

@@ -0,0 +1,7 @@
+[
+  {"date": "2015-07-19T18:00:00", "amount": 140},
+  {"date": "2015-07-19T19:00:00", "amount": 150},
+  {"date": "2015-07-19T20:00:00", "amount": 160},
+  {"date": "2015-07-19T21:00:00", "amount": 170},
+  {"date": "2015-07-19T22:00:00", "amount": 180}
+]

+ 7 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_in_range_and_rising.json

@@ -0,0 +1,7 @@
+[
+  {"date": "2015-07-19T18:00:00", "amount": 90},
+  {"date": "2015-07-19T19:00:00", "amount": 100},
+  {"date": "2015-07-19T20:00:00", "amount": 110},
+  {"date": "2015-07-19T21:00:00", "amount": 120},
+  {"date": "2015-07-19T22:00:00", "amount": 125}
+]

+ 4 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_no_change_glucose.json

@@ -0,0 +1,4 @@
+[
+    {"date": "2015-07-19T20:00:00", "amount": 100},
+    {"date": "2015-07-19T20:30:00", "amount": 100}
+]

+ 7 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_high_end_in_range.json

@@ -0,0 +1,7 @@
+[
+  {"date": "2015-07-19T18:00:00", "amount": 200},
+  {"date": "2015-07-19T18:30:00", "amount": 180},
+  {"date": "2015-07-19T19:00:00", "amount": 150},
+  {"date": "2015-07-19T19:30:00", "amount": 120},
+  {"date": "2015-07-19T20:00:00", "amount": 100}
+]

+ 7 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_high_end_low.json

@@ -0,0 +1,7 @@
+[
+  {"date": "2015-07-19T18:00:00", "amount": 200},
+  {"date": "2015-07-19T18:30:00", "amount": 160},
+  {"date": "2015-07-19T19:00:00", "amount": 120},
+  {"date": "2015-07-19T19:30:00", "amount": 80},
+  {"date": "2015-07-19T20:00:00", "amount": 60}
+]

+ 7 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_low_end_high.json

@@ -0,0 +1,7 @@
+[
+  {"date": "2015-07-19T18:00:00", "amount": 60},
+  {"date": "2015-07-19T19:00:00", "amount": 80},
+  {"date": "2015-07-19T20:00:00", "amount": 120},
+  {"date": "2015-07-19T21:00:00", "amount": 160},
+  {"date": "2015-07-19T22:00:00", "amount": 200}
+]

+ 7 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_low_end_in_range.json

@@ -0,0 +1,7 @@
+[
+  {"date": "2015-07-19T18:00:00", "amount": 60},
+  {"date": "2015-07-19T18:30:00", "amount": 70},
+  {"date": "2015-07-19T19:00:00", "amount": 80},
+  {"date": "2015-07-19T19:30:00", "amount": 90},
+  {"date": "2015-07-19T20:00:00", "amount": 100}
+]

+ 7 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_very_low_end_high.json

@@ -0,0 +1,7 @@
+ [
+  {"date": "2015-07-19T18:00:00", "amount": 40},
+  {"date": "2015-07-19T18:30:00", "amount": 50},
+  {"date": "2015-07-19T19:00:00", "amount": 80},
+  {"date": "2015-07-19T19:30:00", "amount": 160},
+  {"date": "2015-07-19T20:00:00", "amount": 200}
+  ]

+ 7 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_very_low_end_in_range.json

@@ -0,0 +1,7 @@
+ [
+  {"date": "2015-07-19T18:00:00", "amount": 60},
+  {"date": "2015-07-19T18:30:00", "amount": 50},
+  {"date": "2015-07-19T19:00:00", "amount": 60},
+  {"date": "2015-07-19T19:30:00", "amount": 70},
+  {"date": "2015-07-19T20:00:00", "amount": 100}
+]

+ 43 - 0
Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommended_temp_start_low_end_just_above_range.json

@@ -0,0 +1,43 @@
+[
+    {"date": "2017-09-17T10:38:21", "amount": 57},
+    {"date": "2017-09-17T10:40:00", "amount": 57.6448},
+    {"date": "2017-09-17T10:45:00", "amount": 59.7488},
+    {"date": "2017-09-17T10:50:00", "amount": 61.8207},
+    {"date": "2017-09-17T10:55:00", "amount": 63.8623},
+    {"date": "2017-09-17T11:00:00", "amount": 65.8754},
+    {"date": "2017-09-17T11:05:00", "amount": 67.8615},
+    {"date": "2017-09-17T11:10:00", "amount": 69.8222},
+    {"date": "2017-09-17T11:15:00", "amount": 71.759},
+    {"date": "2017-09-17T11:20:00", "amount": 73.6728},
+    {"date": "2017-09-17T11:25:00", "amount": 75.5648},
+    {"date": "2017-09-17T11:30:00", "amount": 77.436},
+    {"date": "2017-09-17T11:35:00", "amount": 79.2873},
+    {"date": "2017-09-17T11:40:00", "amount": 81.1198},
+    {"date": "2017-09-17T11:45:00", "amount": 82.9344},
+    {"date": "2017-09-17T11:50:00", "amount": 84.7321},
+    {"date": "2017-09-17T11:55:00", "amount": 86.5139},
+    {"date": "2017-09-17T12:00:00", "amount": 88.281},
+    {"date": "2017-09-17T12:05:00", "amount": 90.0348},
+    {"date": "2017-09-17T12:10:00", "amount": 91.7764},
+    {"date": "2017-09-17T12:15:00", "amount": 93.507},
+    {"date": "2017-09-17T12:20:00", "amount": 95.2275},
+    {"date": "2017-09-17T12:25:00", "amount": 96.9392},
+    {"date": "2017-09-17T12:30:00", "amount": 98.6428},
+    {"date": "2017-09-17T12:35:00", "amount": 100.339},
+    {"date": "2017-09-17T12:40:00", "amount": 102.03},
+    {"date": "2017-09-17T12:45:00", "amount": 103.715},
+    {"date": "2017-09-17T12:50:00", "amount": 105.395},
+    {"date": "2017-09-17T12:55:00", "amount": 107.072},
+    {"date": "2017-09-17T13:00:00", "amount": 108.746},
+    {"date": "2017-09-17T13:05:00", "amount": 110.417},
+    {"date": "2017-09-17T13:10:00", "amount": 112.086},
+    {"date": "2017-09-17T13:15:00", "amount": 113.753},
+    {"date": "2017-09-17T13:20:00", "amount": 115.42},
+    {"date": "2017-09-17T13:25:00", "amount": 117.087},
+    {"date": "2017-09-17T13:30:00", "amount": 118.754},
+    {"date": "2017-09-17T13:35:00", "amount": 120.42},
+    {"date": "2017-09-17T13:40:00", "amount": 121.914},
+    {"date": "2017-09-17T13:45:00", "amount": 121.914},
+    {"date": "2017-09-17T13:50:00", "amount": 121.914},
+    {"date": "2017-09-17T16:38:21", "amount": 121.914}
+]

+ 73 - 0
Dependencies/LoopKit/LoopKitUI/CarbKit/AbsorptionTimePickerRow.swift

@@ -0,0 +1,73 @@
+//
+//  AbsorptionTimePickerRow.swift
+//  LoopKitUI
+//
+//  Created by Noah Brauner on 7/19/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+
+public struct AbsorptionTimePickerRow: View {
+    @Binding private var absorptionTime: TimeInterval
+    @Binding private var isFocused: Bool
+    
+    private let validDurationRange: ClosedRange<TimeInterval>
+    private let minuteStride: Int
+    
+    private var showHowAbsorptionTimeWorks: Binding<Bool>?
+
+    private let durationFormatter: DateComponentsFormatter = {
+        let formatter = DateComponentsFormatter()
+        formatter.allowedUnits = [.hour, .minute]
+        formatter.unitsStyle = .short
+        return formatter
+    }()
+    
+    public init(absorptionTime: Binding<TimeInterval>, isFocused: Binding<Bool>, validDurationRange: ClosedRange<TimeInterval>, minuteStride: Int = 30, showHowAbsorptionTimeWorks: Binding<Bool>? = nil) {
+        self._absorptionTime = absorptionTime
+        self._isFocused = isFocused
+        self.validDurationRange = validDurationRange
+        self.minuteStride = minuteStride
+        self.showHowAbsorptionTimeWorks = showHowAbsorptionTimeWorks
+    }
+    
+    public var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            HStack {
+                Text("Absorption Time")
+                    .foregroundColor(.primary)
+                
+                if showHowAbsorptionTimeWorks != nil {
+                    Button(action: {
+                        isFocused = false
+                        showHowAbsorptionTimeWorks?.wrappedValue = true
+                    }) {
+                        Image(systemName: "info.circle")
+                            .font(.body)
+                            .foregroundColor(.accentColor)
+                    }
+                }
+                
+                Spacer()
+                
+                Text(durationString())
+                    .foregroundColor(Color(UIColor.secondaryLabel))
+            }
+            
+            if isFocused {
+                DurationPicker(duration: $absorptionTime, validDurationRange: validDurationRange, minuteInterval: minuteStride)
+                    .frame(maxWidth: .infinity)
+            }
+        }
+        .onTapGesture {
+            withAnimation {
+                isFocused.toggle()
+            }
+        }
+    }
+    
+    private func durationString() -> String {
+        return durationFormatter.string(from: absorptionTime) ?? ""
+    }
+}

+ 105 - 0
Dependencies/LoopKit/LoopKitUI/CarbKit/CarbQuantityRow.swift

@@ -0,0 +1,105 @@
+//
+//  CarbQuantityRow.swift
+//  LoopKitUI
+//
+//  Created by Noah Brauner on 7/20/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import LoopKit
+import HealthKit
+
+public struct CarbQuantityRow: View {
+    @Binding private var quantity: Double?
+    @Binding private var isFocused: Bool
+    
+    private let title: String
+    private let preferredCarbUnit: HKUnit
+    
+    @State private var carbInput: String = ""
+    
+    private let formatter: NumberFormatter = {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.maximumFractionDigits = 1
+        return formatter
+    }()
+    
+    public init(quantity: Binding<Double?>, isFocused: Binding<Bool>, title: String, preferredCarbUnit: HKUnit = .gram()) {
+        self._quantity = quantity
+        self._isFocused = isFocused
+        self.title = title
+        self.preferredCarbUnit = preferredCarbUnit
+    }
+
+    public var body: some View {
+        HStack(spacing: 2) {
+            Text(title)
+                .foregroundColor(.primary)
+                .frame(maxWidth: .infinity, alignment: .leading)
+            
+            RowTextField(text: $carbInput, isFocused: $isFocused, maxLength: 5) {
+                $0.textAlignment = .right
+                $0.keyboardType = .decimalPad
+                $0.placeholder = "0"
+                $0.font = .preferredFont(forTextStyle: .body)
+            }
+            .onTapGesture {
+                // so that row does not lose focus on cursor move
+                if !isFocused {
+                    rowTapped()
+                }
+            }
+            
+            carbUnitsLabel
+        }
+        .accessibilityElement(children: .combine)
+        .onChange(of: carbInput) { newValue in
+            updateQuantity(with: newValue)
+        }
+        .onChange(of: quantity) { newQuantity in
+            updateCarbInput(with: newQuantity)
+        }
+        .onAppear {
+            updateCarbInput(with: quantity)
+        }
+        .onTapGesture {
+            rowTapped()
+        }
+    }
+    
+    private var carbUnitsLabel: some View {
+        Text(QuantityFormatter(for: preferredCarbUnit).localizedUnitStringWithPlurality())
+            .foregroundColor(Color(.secondaryLabel))
+    }
+    
+    // Update quantity based on text field input
+    private func updateQuantity(with input: String) {
+        let filtered = input.filter { "0123456789.".contains($0) }
+        if filtered != input {
+            self.carbInput = filtered
+        }
+        
+        if let doubleValue = Double(filtered) {
+            quantity = doubleValue
+        } else {
+            quantity = nil
+        }
+    }
+    
+    // Update text field input based on quantity
+    private func updateCarbInput(with newQuantity: Double?) {
+        if let value = newQuantity {
+            carbInput = formatter.string(from: NSNumber(value: value)) ?? ""
+        } else {
+            carbInput = ""
+        }
+    }
+    
+    private func rowTapped() {
+        withAnimation {
+            isFocused.toggle()
+        }
+    }
+}

+ 150 - 0
Dependencies/LoopKit/LoopKitUI/CarbKit/DatePickerRow.swift

@@ -0,0 +1,150 @@
+//
+//  DatePickerRow.swift
+//  LoopKitUI
+//
+//  Created by Noah Brauner on 7/19/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+
+public struct DatePickerRow: View {
+    @Binding var date: Date
+    private var datePickerDate: Binding<Date> {
+        Binding<Date>(
+            get: { self.date },
+            set: { validateDate($0) }
+        )
+    }
+    
+    @Binding var isFocused: Bool
+    
+    private let maximumDate: Date
+    private let minimumDate: Date
+    
+    @State var incrementButtonEnabled = true
+    @State var decrementButtonEnabled = true
+    
+    private let dateFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.timeStyle = .short
+        formatter.dateStyle = .none
+        return formatter
+    }()
+    
+    private let relativeDateFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.doesRelativeDateFormatting = true
+        formatter.timeStyle = .short
+        formatter.dateStyle = .short
+        return formatter
+    }()
+    
+    private let timeStepSize: TimeInterval = .minutes(15)
+    
+    public init(date: Binding<Date>, isFocused: Binding<Bool>, minimumDate: Date, maximumDate: Date) {
+        self._date = date
+        self._isFocused = isFocused
+        self.minimumDate = minimumDate
+        self.maximumDate = maximumDate
+    }
+    
+    public var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            HStack {
+                Text("Time")
+                    .foregroundColor(.primary)
+                
+                Spacer()
+                
+                Button(action: decrementTime) {
+                    Image(systemName: "minus.circle.fill")
+                        .foregroundColor(.accentColor)
+                        .font(.system(size: 24))
+                        .opacity(decrementButtonEnabled ? 1 : 0.7)
+                }
+                .disabled(!decrementButtonEnabled)
+                
+                let dateTextColor: Color = isFocused ? .accentColor : Color(UIColor.secondaryLabel)
+                Text(dateString())
+                    .foregroundColor(dateTextColor)
+                
+                Button(action: incrementTime) {
+                    Image(systemName: "plus.circle.fill")
+                        .foregroundColor(.accentColor)
+                        .font(.system(size: 24))
+                        .opacity(incrementButtonEnabled ? 1 : 0.7)
+                }
+                .disabled(!incrementButtonEnabled)
+            }
+            
+            if isFocused {
+                DatePicker(selection: datePickerDate, in: minimumDate...maximumDate, label: { EmptyView() })
+                    .datePickerStyle(.wheel)
+                    .labelsHidden()
+                    .opacity(isFocused ? 1 : 0)
+            }
+        }
+        .onAppear {
+            checkButtonsEnabled()
+        }
+        .onTapGesture {
+            rowTapped()
+        }
+    }
+    
+    private func checkButtonsEnabled() {
+        let maxOrder = Calendar.current.compare(date, to: maximumDate, toGranularity: .minute)
+        incrementButtonEnabled = maxOrder == .orderedAscending
+        
+        let minOrder = Calendar.current.compare(date, to: minimumDate, toGranularity: .minute)
+        decrementButtonEnabled = minOrder == .orderedDescending
+    }
+    
+    private func decrementTime() {
+        let potentialDate = date.addingTimeInterval(-timeStepSize)
+        if Calendar.current.compare(potentialDate, to: minimumDate, toGranularity: .minute) != .orderedAscending {
+            date = potentialDate
+        } else {
+            date = minimumDate
+        }
+        checkButtonsEnabled()
+    }
+    
+    private func incrementTime() {
+        let potentialDate = date.addingTimeInterval(timeStepSize)
+        if Calendar.current.compare(potentialDate, to: maximumDate, toGranularity: .minute) != .orderedDescending {
+            date = potentialDate
+        } else {
+            date = maximumDate
+        }
+        checkButtonsEnabled()
+    }
+    
+    private func validateDate(_ date: Date) {
+        if date >= maximumDate {
+            self.date = maximumDate
+        }
+        else if date <= minimumDate {
+            self.date = minimumDate
+        }
+        else {
+            self.date = date
+        }
+        checkButtonsEnabled()
+    }
+    
+    private func dateString() -> String {
+        if Calendar.current.isDateInToday(date) {
+            return dateFormatter.string(from: date)
+        } else {
+            return relativeDateFormatter.string(from: date)
+        }
+    }
+    
+    private func rowTapped() {
+        withAnimation {
+            isFocused.toggle()
+        }
+    }
+}

+ 49 - 0
Dependencies/LoopKit/LoopKitUI/CarbKit/EmojiRow.swift

@@ -0,0 +1,49 @@
+//
+//  EmojiRow.swift
+//  LoopKitUI
+//
+//  Created by Noah Brauner on 8/1/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+
+public struct EmojiRow: View {
+    @Binding private var text: String
+    @Binding private var isFocused: Bool
+    private let emojiType: EmojiDataSourceType
+    private let title: String
+    
+    public init(text: Binding<String>, isFocused: Binding<Bool>, emojiType: EmojiDataSourceType, title: String) {
+        self._text = text
+        self._isFocused = isFocused
+        self.emojiType = emojiType
+        self.title = title
+    }
+    
+    public var body: some View {
+        HStack {
+            Text(title)
+                .foregroundColor(.primary)
+            
+            Spacer()
+            
+            RowEmojiTextField(text: $text, isFocused: $isFocused, placeholder: SettingsTableViewCell.NoValueString, emojiType: emojiType)
+                .onTapGesture {
+                    // so that row does not lose focus on cursor move
+                    if !isFocused {
+                        rowTapped()
+                    }
+                }
+        }
+        .onTapGesture {
+            rowTapped()
+        }
+    }
+    
+    private func rowTapped() {
+        withAnimation {
+            isFocused.toggle()
+        }
+    }
+}

+ 135 - 0
Dependencies/LoopKit/LoopKitUI/CarbKit/FoodTypeRow.swift

@@ -0,0 +1,135 @@
+//
+//  FoodTypeRow.swift
+//  LoopKitUI
+//
+//  Created by Noah Brauner on 7/21/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import LoopKit
+
+public struct FoodTypeRow: View {
+    @Binding private var foodType: String
+    @Binding private var absorptionTime: TimeInterval
+    @Binding private var selectedDefaultAbsorptionTimeEmoji: String
+    @Binding private var usesCustomFoodType: Bool
+    @Binding private var absorptionTimeWasEdited: Bool
+    @Binding private var isFocused: Bool
+    
+    private var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes
+    private var orderedAbsorptionTimes: [TimeInterval] {
+        [defaultAbsorptionTimes.fast, defaultAbsorptionTimes.medium, defaultAbsorptionTimes.slow]
+    }
+    
+    private let emojiShortcuts = FoodEmojiShortcut.all
+    
+    @State private var selectedEmojiIndex = 1
+    
+    /// Contains emoji shortcuts, an emoji keyboard, and modifies absorption time to match emoji
+    public init(foodType: Binding<String>, absorptionTime: Binding<TimeInterval>, selectedDefaultAbsorptionTimeEmoji: Binding<String>, usesCustomFoodType: Binding<Bool>, absorptionTimeWasEdited: Binding<Bool>, isFocused: Binding<Bool>, defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes) {
+        self._foodType = foodType
+        self._absorptionTime = absorptionTime
+        self._selectedDefaultAbsorptionTimeEmoji = selectedDefaultAbsorptionTimeEmoji
+        self._usesCustomFoodType = usesCustomFoodType
+        self._absorptionTimeWasEdited = absorptionTimeWasEdited
+        self._isFocused = isFocused
+        
+        self.defaultAbsorptionTimes = defaultAbsorptionTimes
+    }
+    
+    public var body: some View {
+        HStack {
+            Text("Food Type")
+                .foregroundColor(.primary)
+            
+            Spacer()
+            
+            if usesCustomFoodType {
+                RowEmojiTextField(text: $foodType, isFocused: $isFocused, emojiType: .food, didSelectItemInSection: didSelectEmojiInSection)
+                    .onTapGesture {
+                        // so that row does not lose focus on cursor move
+                        if !isFocused {
+                            rowTapped()
+                        }
+                    }
+            }
+            else {
+                HStack(spacing: 5) {
+                    ForEach(emojiShortcuts.indices, id: \.self) { index in
+                        let isSelected = index == selectedEmojiIndex
+                        let option = emojiShortcuts[index]
+                        Text(option.emoji)
+                            .font(.title3)
+                            .frame(width: 40, height: 40)
+                            .background(isSelected ? Color.gray.opacity(0.2) : Color.clear)
+                            .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+                            .onTapGesture {
+                                switch option {
+                                case .other:
+                                    rowTapped()
+                                default:
+                                    selectedDefaultAbsorptionTimeEmoji = option.emoji
+                                    selectedEmojiIndex = index
+                                    absorptionTime = orderedAbsorptionTimes[index]
+                                }
+                            }
+                    }
+                }
+                .onAppear {
+                    selectedDefaultAbsorptionTimeEmoji = emojiShortcuts[selectedEmojiIndex].emoji
+                }
+            }
+        }
+        .frame(height: 44)
+        .padding(.vertical, -8)
+        .onTapGesture {
+            rowTapped()
+        }
+    }
+    
+    private func didSelectEmojiInSection(_ section: Int) {
+        // only adjust if it wasn't already edited, food selected was not in other category
+        guard !absorptionTimeWasEdited, section < orderedAbsorptionTimes.count else {
+            return
+        }
+        
+        absorptionTime = orderedAbsorptionTimes[section]
+    }
+    
+    private func rowTapped() {
+        withAnimation {
+            if !isFocused {
+                usesCustomFoodType = true
+            }
+            isFocused.toggle()
+        }
+    }
+}
+
+fileprivate enum FoodEmojiShortcut {
+    case fast(emoji: String)
+    case medium(emoji: String)
+    case slow(emoji: String)
+    case other
+    
+    var emoji: String {
+        switch self {
+        case .fast(emoji: let emoji):
+            return emoji
+        case .medium(emoji: let emoji):
+            return emoji
+        case .slow(emoji: let emoji):
+            return emoji
+        case .other:
+            return "🍽️"
+        }
+    }
+    
+    static let all: [FoodEmojiShortcut] = [
+        .fast(emoji: "🍭"),
+        .medium(emoji: "🌮"),
+        .slow(emoji: "🍕"),
+        .other
+    ]
+}

+ 68 - 0
Dependencies/LoopKit/LoopKitUI/CarbKit/RowEmojiTextField.swift

@@ -0,0 +1,68 @@
+//
+//  RowEmojiTextField.swift
+//  LoopKitUI
+//
+//  Created by Noah Brauner on 8/1/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+
+/// Has the same functions as `RowTextField` and uses an `EmojiInputController` as the keyboard. This struct handles `standardInputMode` as well.
+struct RowEmojiTextField: View {
+    @Binding private var text: String
+    @Binding private var isFocused: Bool
+    
+    private var placeholder: String
+    private let emojiType: EmojiDataSourceType
+    
+    @StateObject private var viewModel: EmojiTextFieldViewModel
+    
+    class EmojiTextFieldViewModel: ObservableObject, EmojiInputControllerDelegate {
+        @Published var standardInputMode = false
+        let didSelectItemInSection: ((Int) -> Void)?
+        
+        init(didSelectItemInSection: ((Int) -> Void)?) {
+            self.didSelectItemInSection = didSelectItemInSection
+        }
+        
+        func emojiInputControllerDidAdvanceToStandardInputMode(_ controller: EmojiInputController) {
+            self.standardInputMode = true
+        }
+        
+        func emojiInputControllerDidSelectItemInSection(_ section: Int) {
+            didSelectItemInSection?(section)
+        }
+    }
+    
+    init(text: Binding<String>, isFocused: Binding<Bool>, placeholder: String = "", emojiType: EmojiDataSourceType, didSelectItemInSection: ((Int) -> Void)? = nil) {
+        self._text = text
+        self._isFocused = isFocused
+        self.placeholder = placeholder
+        self.emojiType = emojiType
+        self._viewModel = StateObject(wrappedValue: EmojiTextFieldViewModel(didSelectItemInSection: didSelectItemInSection))
+    }
+    
+    var body: some View {
+        // this if statement cannot be moved into the RowTextField closure because the closure does not refresh on state changes
+        if viewModel.standardInputMode {
+            RowTextField(text: $text, isFocused: $isFocused, maxLength: 20) { textField in
+                textField.textAlignment = .right
+                textField.font = UIFont.preferredFont(forTextStyle: .title3)
+                textField.autocorrectionType = .no
+                textField.autocapitalizationType = .none
+                textField.placeholder = placeholder
+            }
+        }
+        else {
+            RowTextField(text: $text, isFocused: $isFocused, maxLength: 20) { textField in
+                textField.textAlignment = .right
+                textField.font = UIFont.preferredFont(forTextStyle: .title3)
+                let emojiController = EmojiInputController.instance(withEmojis: emojiType.dataSource())
+                emojiController.delegate = viewModel
+                textField.customInput = emojiController
+                textField.placeholder = placeholder
+            }
+        }
+    }
+}

+ 84 - 0
Dependencies/LoopKit/LoopKitUI/CarbKit/RowTextfield.swift

@@ -0,0 +1,84 @@
+//
+//  RowTextField.swift
+//  LoopKitUI
+//
+//  Created by Noah Brauner on 7/20/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+
+/// A text field that supports custom input keyboards, moves the cursor to the end of the text, becomes the first responder when it's the focused row, and loses first responder when it's not
+struct RowTextField: UIViewRepresentable {
+    @Binding var text: String
+    @Binding var isFocused: Bool
+    var maxLength: Int? = nil
+    var configuration = { (view: CustomInputTextField) in }
+    
+    func makeCoordinator() -> Coordinator {
+        return Coordinator(text: $text, isFocused: $isFocused, maxLength: maxLength)
+    }
+
+    func makeUIView(context: UIViewRepresentableContext<RowTextField>) -> CustomInputTextField {
+        let textField = CustomInputTextField(frame: .zero)
+        textField.delegate = context.coordinator
+        textField.addTarget(context.coordinator, action: #selector(Coordinator.textChanged), for: .editingChanged)
+        return textField
+    }
+
+    func updateUIView(_ textField: CustomInputTextField, context: UIViewRepresentableContext<RowTextField>) {
+        textField.text = text
+        configuration(textField)
+        DispatchQueue.main.async {
+            if isFocused && !textField.isFirstResponder {
+                textField.becomeFirstResponder()
+            } else if !isFocused && textField.isFirstResponder {
+                textField.resignFirstResponder()
+            }
+        }
+    }
+    
+    class Coordinator: NSObject, UITextFieldDelegate {
+        @Binding var text: String
+        @Binding var isFocused: Bool
+        let maxLength: Int?
+        
+        init(text: Binding<String>, isFocused: Binding<Bool>, maxLength: Int?) {
+            self._text = text
+            self._isFocused = isFocused
+            self.maxLength = maxLength
+        }
+        
+        @objc fileprivate func textChanged(_ textField: UITextField) {
+            DispatchQueue.main.async {
+                self.text = textField.text ?? ""
+            }
+        }
+
+        func textFieldDidBeginEditing(_ textField: UITextField) {
+            DispatchQueue.main.async { [weak textField] in
+                textField?.selectedTextRange = textField?.textRange(from: textField!.endOfDocument, to: textField!.endOfDocument)
+            }
+            withAnimation {
+                isFocused = true
+            }
+        }
+        
+        func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
+            if isFocused {
+                isFocused = false
+            }
+            return true
+        }
+        
+        func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
+            guard let maxLength = maxLength else {
+                return true
+            }
+            let currentString: NSString = (textField.text ?? "") as NSString
+            let newString: NSString = currentString.replacingCharacters(in: range, with: string) as NSString
+            return newString.length <= maxLength
+        }
+    }
+}
+

+ 55 - 0
Dependencies/LoopKit/LoopKitUI/CarbKit/TextFieldRow.swift

@@ -0,0 +1,55 @@
+//
+//  TextFieldRow.swift
+//  LoopKitUI
+//
+//  Created by Noah Brauner on 7/31/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+
+public struct TextFieldRow: View {
+    @Binding private var text: String
+    @Binding private var isFocused: Bool
+    
+    let title: String
+    let placeholder: String
+    
+    public init(text: Binding<String>, isFocused: Binding<Bool>, title: String, placeholder: String) {
+        self._text = text
+        self._isFocused = isFocused
+        self.title = title
+        self.placeholder = placeholder
+    }
+
+    public var body: some View {
+        HStack {
+            Text(title)
+                .foregroundColor(.primary)
+            
+            Spacer()
+            
+            RowTextField(text: $text, isFocused: $isFocused) {
+                $0.textAlignment = .right
+                $0.placeholder = placeholder
+                $0.font = .preferredFont(forTextStyle: .body)
+            }
+            .onTapGesture {
+                // so that row does not lose focus on cursor move
+                if !isFocused {
+                    rowTapped()
+                }
+            }
+        }
+        .accessibilityElement(children: .combine)
+        .onTapGesture {
+            rowTapped()
+        }
+    }
+    
+    private func rowTapped() {
+        withAnimation {
+            isFocused.toggle()
+        }
+    }
+}

+ 105 - 0
Dependencies/LoopKit/LoopKitUI/Charts/COBChart.swift

@@ -0,0 +1,105 @@
+//
+//  COBChart.swift
+//  LoopUI
+//
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+import LoopKit
+import SwiftCharts
+import UIKit
+
+
+public class COBChart: ChartProviding {
+    public init() {
+    }
+
+    /// The chart points for COB
+    public private(set) var cobPoints: [ChartPoint] = [] {
+        didSet {
+            if let lastDate = cobPoints.last?.x as? ChartAxisValueDate {
+                endDate = lastDate.date
+            }
+        }
+    }
+
+    /// The minimum range to display for COB values.
+    private var cobDisplayRangePoints: [ChartPoint] = [0, 10].map {
+        return ChartPoint(
+            x: ChartAxisValue(scalar: 0),
+            y: ChartAxisValueInt($0)
+        )
+    }
+
+    public private(set) var endDate: Date?
+
+    private var cobChartCache: ChartPointsTouchHighlightLayerViewCache?
+}
+
+public extension COBChart {
+    func didReceiveMemoryWarning() {
+        cobPoints = []
+        cobChartCache = nil
+    }
+
+    func generate(withFrame frame: CGRect, xAxisModel: ChartAxisModel, xAxisValues: [ChartAxisValue], axisLabelSettings: ChartLabelSettings, guideLinesLayerSettings: ChartGuideLinesLayerSettings, colors: ChartColorPalette, chartSettings: ChartSettings, labelsWidthY: CGFloat, gestureRecognizer: UIGestureRecognizer?, traitCollection: UITraitCollection) -> Chart
+    {
+        let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesWithChartPoints(cobPoints + cobDisplayRangePoints, minSegmentCount: 2, maxSegmentCount: 3, multiple: 10, axisValueGenerator: { ChartAxisValueDouble($0, labelSettings: axisLabelSettings) }, addPaddingSegmentIfEdge: false)
+
+        let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: colors.axisLine, labelSpaceReservationMode: .fixed(labelsWidthY))
+
+        let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: frame, xModel: xAxisModel, yModel: yAxisModel)
+
+        let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame)
+
+        // The COB area
+        let lineModel = ChartLineModel(chartPoints: cobPoints, lineColor: colors.carbTint, lineWidth: 2, animDuration: 0, animDelay: 0)
+        let cobLine = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel])
+
+        let cobArea = ChartPointsFillsLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, fills: [ChartPointsFill(chartPoints: cobPoints, fillColor: colors.carbTint.withAlphaComponent(0.5))])
+
+        // Grid lines
+        let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues)
+
+        if gestureRecognizer != nil {
+            cobChartCache = ChartPointsTouchHighlightLayerViewCache(
+                xAxisLayer: xAxisLayer,
+                yAxisLayer: yAxisLayer,
+                axisLabelSettings: axisLabelSettings,
+                chartPoints: cobPoints,
+                tintColor: colors.carbTint,
+                gestureRecognizer: gestureRecognizer
+            )
+        }
+
+        let layers: [ChartLayer?] = [
+            gridLayer,
+            xAxisLayer,
+            yAxisLayer,
+            cobChartCache?.highlightLayer,
+            cobArea,
+            cobLine
+        ]
+
+        return Chart(frame: frame, innerFrame: innerFrame, settings: chartSettings, layers: layers.compactMap { $0 })
+    }
+}
+
+public extension COBChart {
+    func setCOBValues(_ cobValues: [CarbValue]) {
+        let dateFormatter = DateFormatter(timeStyle: .short)
+        let integerFormatter = NumberFormatter.integer
+
+        let unit = HKUnit.gram()
+        let unitString = unit.unitString
+
+        cobPoints = cobValues.map {
+            ChartPoint(
+                x: ChartAxisValueDate(date: $0.startDate, formatter: dateFormatter),
+                y: ChartAxisValueDoubleUnit($0.quantity.doubleValue(for: unit), unitString: unitString, formatter: integerFormatter)
+            )
+        }
+    }
+}

+ 221 - 0
Dependencies/LoopKit/LoopKitUI/Charts/CarbEffectChart.swift

@@ -0,0 +1,221 @@
+//
+//  CarbEffectChart.swift
+//  LoopUI
+//
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import LoopKit
+import SwiftCharts
+import UIKit
+
+public class CarbEffectChart: GlucoseChart, ChartProviding {
+    /// The chart points for expected carb effect velocity
+    public private(set) var carbEffectPoints: [ChartPoint] = [] {
+        didSet {
+            // don't extend the end date for carb effects
+        }
+    }
+
+    /// The chart points for observed insulin counteraction effect velocity
+    public private(set) var insulinCounteractionEffectPoints: [ChartPoint] = [] {
+        didSet {
+            // Extend 1 hour past the seen effect to ensure some future prediction is displayed
+            if let lastDate = insulinCounteractionEffectPoints.last?.x as? ChartAxisValueDate {
+                endDate = lastDate.date.addingTimeInterval(.hours(1))
+            }
+        }
+    }
+
+    /// The chart points used for selection in the carb effect chart
+    public private(set) var allCarbEffectPoints: [ChartPoint] = []
+
+    public private(set) var endDate: Date?
+
+    private lazy var dateFormatter = DateFormatter(timeStyle: .short)
+    private lazy var decimalFormatter = NumberFormatter.dose
+
+    private var carbEffectChartCache: ChartPointsTouchHighlightLayerViewCache?
+}
+
+extension CarbEffectChart {
+    public func didReceiveMemoryWarning() {
+        carbEffectPoints = []
+        insulinCounteractionEffectPoints = []
+        allCarbEffectPoints = []
+
+        carbEffectChartCache = nil
+    }
+
+    public func generate(withFrame frame: CGRect, xAxisModel: ChartAxisModel, xAxisValues: [ChartAxisValue], axisLabelSettings: ChartLabelSettings, guideLinesLayerSettings: ChartGuideLinesLayerSettings, colors: ChartColorPalette, chartSettings: ChartSettings, labelsWidthY: CGFloat, gestureRecognizer: UIGestureRecognizer?, traitCollection: UITraitCollection) -> Chart
+    {
+        /// The minimum range to display for carb effect values.
+        let carbEffectDisplayRangePoints: [ChartPoint] = [0, glucoseUnit.chartableIncrement].map {
+            return ChartPoint(
+                x: ChartAxisValue(scalar: 0),
+                y: ChartAxisValueDouble($0)
+            )
+        }
+
+        let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesWithChartPoints(carbEffectPoints + allCarbEffectPoints + carbEffectDisplayRangePoints,
+            minSegmentCount: 2,
+            maxSegmentCount: 4,
+            multiple: glucoseUnit.chartableIncrement / 2,
+            axisValueGenerator: {
+                ChartAxisValueDouble($0, labelSettings: axisLabelSettings)
+            },
+            addPaddingSegmentIfEdge: false
+        )
+
+        let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: colors.axisLine, labelSpaceReservationMode: .fixed(labelsWidthY))
+
+        let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: frame, xModel: xAxisModel, yModel: yAxisModel)
+
+        let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame)
+
+        let carbFillColor = colors.carbTint.withAlphaComponent(0.5)
+        let carbBlendMode: CGBlendMode
+        switch traitCollection.userInterfaceStyle {
+        case .dark:
+            carbBlendMode = .plusLighter
+        case .light, .unspecified:
+            carbBlendMode = .plusDarker
+        @unknown default:
+            carbBlendMode = .plusDarker
+        }
+
+        // Carb effect
+        let effectsLayer = ChartPointsFillsLayer(
+            xAxis: xAxisLayer.axis,
+            yAxis: yAxisLayer.axis,
+            fills: [
+                ChartPointsFill(chartPoints: carbEffectPoints, fillColor: UIColor.secondaryLabel.withAlphaComponent(0.5)),
+                ChartPointsFill(chartPoints: insulinCounteractionEffectPoints, fillColor: carbFillColor, blendMode: carbBlendMode)
+            ]
+        )
+
+        // Grid lines
+        let gridLayer = ChartGuideLinesForValuesLayer(
+            xAxis: xAxisLayer.axis,
+            yAxis: yAxisLayer.axis,
+            settings: guideLinesLayerSettings,
+            axisValuesX: Array(xAxisValues.dropFirst().dropLast()),
+            axisValuesY: yAxisValues
+        )
+
+        // 0-line
+        let dummyZeroChartPoint = ChartPoint(x: ChartAxisValueDouble(0), y: ChartAxisValueDouble(0))
+        let zeroGuidelineLayer = ChartPointsViewsLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: [dummyZeroChartPoint], viewGenerator: {(chartPointModel, layer, chart) -> UIView? in
+            let width: CGFloat = 1
+            let viewFrame = CGRect(x: chart.contentView.bounds.minX, y: chartPointModel.screenLoc.y - width / 2, width: chart.contentView.bounds.size.width, height: width)
+
+            let v = UIView(frame: viewFrame)
+            v.layer.backgroundColor = carbFillColor.cgColor
+            return v
+        })
+
+        if gestureRecognizer != nil {
+            carbEffectChartCache = ChartPointsTouchHighlightLayerViewCache(
+                xAxisLayer: xAxisLayer,
+                yAxisLayer: yAxisLayer,
+                axisLabelSettings: axisLabelSettings,
+                chartPoints: allCarbEffectPoints,
+                tintColor: colors.carbTint,
+                gestureRecognizer: gestureRecognizer
+            )
+        }
+
+        let layers: [ChartLayer?] = [
+            gridLayer,
+            xAxisLayer,
+            yAxisLayer,
+            zeroGuidelineLayer,
+            carbEffectChartCache?.highlightLayer,
+            effectsLayer
+        ]
+
+        return Chart(
+            frame: frame,
+            innerFrame: innerFrame,
+            settings: chartSettings,
+            layers: layers.compactMap { $0 }
+        )
+    }
+}
+
+extension CarbEffectChart {
+    /// Convert an array of GlucoseEffects (as glucose values) into glucose effect velocity (glucose/min) for charting
+    ///
+    /// - Parameter effects: A timeline of glucose values representing glucose change
+    public func setCarbEffects(_ effects: [GlucoseEffect]) {
+        let unit = glucoseUnit.unitDivided(by: .minute())
+        let unitString = unit.unitString
+
+        var lastDate = effects.first?.endDate
+        var lastValue = effects.first?.quantity.doubleValue(for: glucoseUnit)
+        let minuteInterval = 5.0
+
+        var carbEffectPoints = [ChartPoint]()
+
+        let zero = ChartAxisValueInt(0)
+
+        for effect in effects.dropFirst() {
+            let value = effect.quantity.doubleValue(for: glucoseUnit)
+            let valuePerMinute = (value - lastValue!) / minuteInterval
+            lastValue = value
+
+            let startX = ChartAxisValueDate(date: lastDate!, formatter: dateFormatter)
+            let endX = ChartAxisValueDate(date: effect.endDate, formatter: dateFormatter)
+            lastDate = effect.endDate
+
+            let valueY = ChartAxisValueDoubleUnit(valuePerMinute, unitString: unitString, formatter: decimalFormatter)
+
+            carbEffectPoints += [
+                ChartPoint(x: startX, y: zero),
+                ChartPoint(x: startX, y: valueY),
+                ChartPoint(x: endX, y: valueY),
+                ChartPoint(x: endX, y: zero)
+            ]
+        }
+
+        self.carbEffectPoints = carbEffectPoints
+    }
+
+    /// Charts glucose effect velocity
+    ///
+    /// - Parameter effects: A timeline of glucose velocity values
+    public func setInsulinCounteractionEffects(_ effects: [GlucoseEffectVelocity]) {
+        let unit = glucoseUnit.unitDivided(by: .minute())
+        let unitString = String(format: NSLocalizedString("%1$@/min", comment: "Format string describing glucose units per minute (1: glucose unit string)"), glucoseUnit.shortLocalizedUnitString())
+
+        var insulinCounteractionEffectPoints: [ChartPoint] = []
+        var allCarbEffectPoints: [ChartPoint] = []
+
+        let zero = ChartAxisValueInt(0)
+
+        for effect in effects {
+            let startX = ChartAxisValueDate(date: effect.startDate, formatter: dateFormatter)
+            let endX = ChartAxisValueDate(date: effect.endDate, formatter: dateFormatter)
+            let value = ChartAxisValueDoubleUnit(effect.quantity.doubleValue(for: unit), unitString: unitString, formatter: decimalFormatter)
+
+            guard value.scalar != 0 else {
+                continue
+            }
+
+            let valuePoint = ChartPoint(x: endX, y: value)
+
+            insulinCounteractionEffectPoints += [
+                ChartPoint(x: startX, y: zero),
+                ChartPoint(x: startX, y: value),
+                valuePoint,
+                ChartPoint(x: endX, y: zero)
+            ]
+
+            allCarbEffectPoints.append(valuePoint)
+        }
+
+        self.insulinCounteractionEffectPoints = insulinCounteractionEffectPoints
+        self.allCarbEffectPoints = allCarbEffectPoints
+    }
+}

+ 26 - 0
Dependencies/LoopKit/LoopKitUI/Charts/ChartConstants.swift

@@ -0,0 +1,26 @@
+//
+//  ChartConstants.swift
+//  LoopUI
+//
+//  Created by Pete Schwamb on 10/16/20.
+//  Copyright © 2020 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+import LoopKit
+
+public enum ChartConstants {
+    public static let minimumChartWidthPerHour: CGFloat = 50
+
+    public static let statusChartMinimumHistoryDisplay: TimeInterval = .hours(1)
+
+    public static let glucoseChartDefaultDisplayBound =
+        HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 175)
+
+    public static let glucoseChartDefaultDisplayRangeWide =
+        HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 60)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 200)
+
+    public static let glucoseChartDefaultDisplayBoundClamped =
+        HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 240)
+}

+ 210 - 0
Dependencies/LoopKit/LoopKitUI/Charts/DoseChart.swift

@@ -0,0 +1,210 @@
+//
+//  DoseChart.swift
+//  LoopUI
+//
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import LoopKit
+import SwiftCharts
+import UIKit
+
+fileprivate struct DosePointsCache {
+    let basal: [ChartPoint]
+    let basalFill: [ChartPoint]
+    let bolus: [ChartPoint]
+    let highlight: [ChartPoint]
+}
+
+public class DoseChart: ChartProviding {
+    public init() {
+        doseEntries = []
+    }
+    
+    public var doseEntries: [DoseEntry] {
+        didSet {
+            pointsCache = nil
+        }
+    }
+
+    private var pointsCache: DosePointsCache? {
+        didSet {
+            if let pointsCache = pointsCache {
+                if let lastDate = pointsCache.highlight.last?.x as? ChartAxisValueDate {
+                    endDate = lastDate.date
+                }
+            }
+        }
+    }
+
+    /// The minimum range to display for insulin values.
+    private let doseDisplayRangePoints: [ChartPoint] = [0, 1].map {
+        return ChartPoint(
+            x: ChartAxisValue(scalar: 0),
+            y: ChartAxisValueInt($0)
+        )
+    }
+
+    public private(set) var endDate: Date?
+
+    private var doseChartCache: ChartPointsTouchHighlightLayerViewCache?
+}
+
+public extension DoseChart {
+    func didReceiveMemoryWarning() {
+        pointsCache = nil
+        doseChartCache = nil
+    }
+
+    func generate(withFrame frame: CGRect, xAxisModel: ChartAxisModel, xAxisValues: [ChartAxisValue], axisLabelSettings: ChartLabelSettings, guideLinesLayerSettings: ChartGuideLinesLayerSettings, colors: ChartColorPalette, chartSettings: ChartSettings, labelsWidthY: CGFloat, gestureRecognizer: UIGestureRecognizer?, traitCollection: UITraitCollection) -> Chart
+    {
+        let integerFormatter = NumberFormatter.integer
+        
+        let startDate = ChartAxisValueDate.dateFromScalar(xAxisValues.first!.scalar)
+        
+        let points = generateDosePoints(startDate: startDate)
+
+        let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesUsingLinearSegmentStep(
+            chartPoints: points.basal + points.bolus + doseDisplayRangePoints,
+            minSegmentCount: 2,
+            maxSegmentCount: 3,
+            multiple: log(2) / 2,
+            axisValueGenerator: { ChartAxisValueDoubleLog(screenLocDouble: $0, formatter: integerFormatter, labelSettings: axisLabelSettings) },
+            addPaddingSegmentIfEdge: true)
+        
+        let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: colors.axisLine, labelSpaceReservationMode: .fixed(labelsWidthY))
+
+        let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: frame, xModel: xAxisModel, yModel: yAxisModel)
+
+        let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame)
+
+        // The dose area
+        let lineModel = ChartLineModel(chartPoints: points.basal, lineColor: colors.insulinTint, lineWidth: 2, animDuration: 0, animDelay: 0)
+        let doseLine = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel])
+
+        let doseArea = ChartPointsFillsLayer(
+            xAxis: xAxisLayer.axis,
+            yAxis: yAxisLayer.axis,
+            fills: [ChartPointsFill(
+                chartPoints: points.basalFill,
+                fillColor: colors.insulinTint.withAlphaComponent(0.5),
+                createContainerPoints: false
+            )]
+        )
+
+        // bolus points
+        let bolusPointSize: Double = 12
+        let bolusLayer: ChartPointsScatterDownTrianglesLayer<ChartPoint>?
+
+        if points.bolus.count > 0 {
+            bolusLayer = ChartPointsScatterDownTrianglesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: points.bolus, displayDelay: 0, itemSize: CGSize(width: bolusPointSize, height: bolusPointSize), itemFillColor: colors.insulinTint)
+        } else {
+            bolusLayer = nil
+        }
+
+        // Grid lines
+        let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues)
+
+        // 0-line
+        let dummyZeroChartPoint = ChartPoint(x: ChartAxisValueDouble(0), y: ChartAxisValueDouble(0))
+        let zeroGuidelineLayer = ChartPointsViewsLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: [dummyZeroChartPoint], viewGenerator: {(chartPointModel, layer, chart) -> UIView? in
+            let width: CGFloat = 1
+            let viewFrame = CGRect(x: chart.contentView.bounds.minX, y: chartPointModel.screenLoc.y - width / 2, width: chart.contentView.bounds.size.width, height: width)
+
+            let v = UIView(frame: viewFrame)
+            v.layer.backgroundColor = colors.insulinTint.cgColor
+            return v
+        })
+
+        if gestureRecognizer != nil {
+            doseChartCache = ChartPointsTouchHighlightLayerViewCache(
+                xAxisLayer: xAxisLayer,
+                yAxisLayer: yAxisLayer,
+                axisLabelSettings: axisLabelSettings,
+                chartPoints: points.highlight,
+                tintColor: colors.insulinTint,
+                gestureRecognizer: gestureRecognizer
+            )
+        }
+
+        let layers: [ChartLayer?] = [
+            gridLayer,
+            xAxisLayer,
+            yAxisLayer,
+            zeroGuidelineLayer,
+            doseChartCache?.highlightLayer,
+            doseArea,
+            doseLine,
+            bolusLayer
+        ]
+
+        let chart = Chart(frame: frame, innerFrame: innerFrame, settings: chartSettings, layers: layers.compactMap { $0 })
+
+        // the bolus points are drawn in the chart's drawersContentView. Update the drawersContentView frame to allow the bolus points to be drawn without clipping
+        var frame = chart.drawersContentView.frame
+        frame.size.height = frame.height+CGFloat(bolusPointSize/2)
+        chart.drawersContentView.frame = frame.offsetBy(dx: 0, dy: -CGFloat(bolusPointSize/2))
+
+        return chart
+    }
+    
+    private func generateDosePoints(startDate: Date) -> DosePointsCache {
+        
+        guard pointsCache == nil else {
+            return pointsCache!
+        }
+        
+        let dateFormatter = DateFormatter(timeStyle: .short)
+        let doseFormatter = NumberFormatter.dose
+
+        var basalPoints = [ChartPoint]()
+        var basalFillPoints = [ChartPoint]()
+        var bolusPoints = [ChartPoint]()
+        var highlightPoints = [ChartPoint]()
+        
+        for entry in doseEntries {
+            let time = entry.endDate.timeIntervalSince(entry.startDate)
+
+            if entry.type == .bolus && entry.netBasalUnits > 0 {
+                let x = ChartAxisValueDate(date: entry.startDate, formatter: dateFormatter)
+                let y = ChartAxisValueDoubleLog(actualDouble: entry.unitsInDeliverableIncrements, unitString: "U", formatter: doseFormatter)
+
+                let point = ChartPoint(x: x, y: y)
+                bolusPoints.append(point)
+                highlightPoints.append(point)
+            } else if time > 0 {
+                // TODO: Display the DateInterval
+                let startX = ChartAxisValueDate(date: max(startDate, entry.startDate), formatter: dateFormatter)
+                let endX = ChartAxisValueDate(date: entry.endDate, formatter: dateFormatter)
+                let zero = ChartAxisValueInt(0)
+                let rate = entry.netBasalUnitsPerHour
+                let value = ChartAxisValueDoubleLog(actualDouble: rate, unitString: "U/hour", formatter: doseFormatter)
+
+                let valuePoints: [ChartPoint]
+
+                if abs(rate) > .ulpOfOne {
+                    valuePoints = [
+                        ChartPoint(x: startX, y: value),
+                        ChartPoint(x: endX, y: value)
+                    ]
+                } else {
+                    valuePoints = []
+                }
+                
+                basalFillPoints += [ChartPoint(x: startX, y: zero)] + valuePoints + [ChartPoint(x: endX, y: zero)]
+                
+                if entry.startDate > startDate {
+                    basalPoints += [ChartPoint(x: startX, y: zero)]
+                }
+                basalPoints += valuePoints + [ChartPoint(x: endX, y: zero)]
+
+                highlightPoints += valuePoints
+            }
+        }
+        
+        let pointsCache = DosePointsCache(basal: basalPoints, basalFill: basalFillPoints, bolus: bolusPoints, highlight: highlightPoints)
+        self.pointsCache = pointsCache
+        return pointsCache
+    }
+}

+ 117 - 0
Dependencies/LoopKit/LoopKitUI/Charts/IOBChart.swift

@@ -0,0 +1,117 @@
+//
+//  IOBChart.swift
+//  LoopUI
+//
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import LoopKit
+import SwiftCharts
+import HealthKit
+import UIKit
+
+
+public class IOBChart: ChartProviding {
+
+    static let chartUnit = HKUnit.internationalUnit()
+
+    public init() {
+    }
+
+    /// The chart points for IOB
+    public private(set) var iobPoints: [ChartPoint] = [] {
+        didSet {
+            if let lastDate = iobPoints.last?.x as? ChartAxisValueDate {
+                endDate = lastDate.date
+            }
+        }
+    }
+
+    /// The minimum range to display for insulin values.
+    private let iobDisplayRangePoints: [ChartPoint] = [0, 1].map {
+        return ChartPoint(
+            x: ChartAxisValue(scalar: 0),
+            y: ChartAxisValueInt($0)
+        )
+    }
+
+    public private(set) var endDate: Date?
+
+    private var iobChartCache: ChartPointsTouchHighlightLayerViewCache?
+}
+
+public extension IOBChart {
+    func didReceiveMemoryWarning() {
+        iobPoints = []
+        iobChartCache = nil
+    }
+
+    func generate(withFrame frame: CGRect, xAxisModel: ChartAxisModel, xAxisValues: [ChartAxisValue], axisLabelSettings: ChartLabelSettings, guideLinesLayerSettings: ChartGuideLinesLayerSettings, colors: ChartColorPalette, chartSettings: ChartSettings, labelsWidthY: CGFloat, gestureRecognizer: UIGestureRecognizer?, traitCollection: UITraitCollection) -> Chart
+    {
+        let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesWithChartPoints(iobPoints + iobDisplayRangePoints, minSegmentCount: 2, maxSegmentCount: 3, multiple: 0.5, axisValueGenerator: { ChartAxisValueDouble($0, labelSettings: axisLabelSettings) }, addPaddingSegmentIfEdge: false)
+
+        let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: colors.axisLine, labelSpaceReservationMode: .fixed(labelsWidthY))
+
+        let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: frame, xModel: xAxisModel, yModel: yAxisModel)
+
+        let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame)
+
+        // The IOB area
+        let lineModel = ChartLineModel(chartPoints: iobPoints, lineColor: colors.insulinTint, lineWidth: 2, animDuration: 0, animDelay: 0)
+        let iobLine = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel])
+
+        let iobArea = ChartPointsFillsLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, fills: [ChartPointsFill(chartPoints: iobPoints, fillColor: colors.insulinTint.withAlphaComponent(0.5))])
+
+        // Grid lines
+        let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues)
+
+        // 0-line
+        let dummyZeroChartPoint = ChartPoint(x: ChartAxisValueDouble(0), y: ChartAxisValueDouble(0))
+        let zeroGuidelineLayer = ChartPointsViewsLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: [dummyZeroChartPoint], viewGenerator: {(chartPointModel, layer, chart) -> UIView? in
+            let width: CGFloat = 0.5
+            let viewFrame = CGRect(x: chart.contentView.bounds.minX, y: chartPointModel.screenLoc.y - width / 2, width: chart.contentView.bounds.size.width, height: width)
+
+            let v = UIView(frame: viewFrame)
+            v.layer.backgroundColor = colors.insulinTint.cgColor
+            return v
+        })
+
+        if gestureRecognizer != nil {
+            iobChartCache = ChartPointsTouchHighlightLayerViewCache(
+                xAxisLayer: xAxisLayer,
+                yAxisLayer: yAxisLayer,
+                axisLabelSettings: axisLabelSettings,
+                chartPoints: iobPoints,
+                tintColor: colors.insulinTint,
+                gestureRecognizer: gestureRecognizer
+            )
+        }
+
+        let layers: [ChartLayer?] = [
+            gridLayer,
+            xAxisLayer,
+            yAxisLayer,
+            zeroGuidelineLayer,
+            iobChartCache?.highlightLayer,
+            iobArea,
+            iobLine,
+        ]
+
+        return Chart(frame: frame, innerFrame: innerFrame, settings: chartSettings, layers: layers.compactMap { $0 })
+    }
+}
+
+public extension IOBChart {
+    func setIOBValues(_ iobValues: [InsulinValue]) {
+        let dateFormatter = DateFormatter(timeStyle: .short)
+        let doseFormatter = NumberFormatter.dose
+
+        iobPoints = iobValues.map {
+            return ChartPoint(
+                x: ChartAxisValueDate(date: $0.startDate, formatter: dateFormatter),
+                y: ChartAxisValueDoubleUnit($0.value, unitString: Self.chartUnit.shortLocalizedUnitString(), formatter: doseFormatter)
+            )
+        }
+    }
+}

+ 352 - 0
Dependencies/LoopKit/LoopKitUI/Charts/PredictedGlucoseChart.swift

@@ -0,0 +1,352 @@
+//
+//  PredictedGlucoseChart.swift
+//  LoopUI
+//
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import LoopKit
+import SwiftCharts
+import HealthKit
+import UIKit
+
+public class PredictedGlucoseChart: GlucoseChart, ChartProviding {
+
+    public private(set) var glucosePoints: [ChartPoint] = [] {
+        didSet {
+            if let lastDate = glucosePoints.last?.x as? ChartAxisValueDate {
+                updateEndDate(lastDate.date)
+            }
+        }
+    }
+
+    /// The chart points for predicted glucose
+    public private(set) var predictedGlucosePoints: [ChartPoint] = [] {
+        didSet {
+            if let lastDate = predictedGlucosePoints.last?.x as? ChartAxisValueDate {
+                updateEndDate(lastDate.date)
+            }
+        }
+    }
+
+    /// The chart points for alternate predicted glucose
+    public private(set) var alternatePredictedGlucosePoints: [ChartPoint]?
+
+    public var targetGlucoseSchedule: GlucoseRangeSchedule? {
+        didSet {
+            targetGlucosePoints = []
+        }
+    }
+
+    public var preMealOverride: TemporaryScheduleOverride? {
+        didSet {
+            preMealOverrideDurationPoints = []
+        }
+    }
+
+    public var scheduleOverride: TemporaryScheduleOverride? {
+        didSet {
+            targetOverrideDurationPoints = []
+        }
+    }
+
+    private var targetGlucosePoints = [TargetChartBar]()
+
+    private var preMealOverrideDurationPoints: [ChartPoint] = []
+
+    private var targetOverrideDurationPoints: [ChartPoint] = []
+
+    private var glucoseChartCache: ChartPointsTouchHighlightLayerViewCache?
+
+    public private(set) var endDate: Date?
+
+    private var predictedGlucoseSoftBounds: PredictedGlucoseBounds?
+    
+    private let yAxisStepSizeMGDLOverride: Double?
+        
+    private var maxYAxisSegmentCount: Double {
+        // when a glucose value is below the predicted glucose minimum soft bound, allow for more y-axis segments
+        return glucoseValueBelowSoftBoundsMinimum() ? 5 : 4
+    }
+    
+    private func updateEndDate(_ date: Date) {
+        if endDate == nil || date > endDate! {
+            self.endDate = date
+        }
+    }
+    
+    public init(predictedGlucoseBounds: PredictedGlucoseBounds? = nil,
+                yAxisStepSizeMGDLOverride: Double? = nil) {
+        self.predictedGlucoseSoftBounds = predictedGlucoseBounds
+        self.yAxisStepSizeMGDLOverride = yAxisStepSizeMGDLOverride
+        super.init()
+    }
+}
+
+extension PredictedGlucoseChart {
+    public func didReceiveMemoryWarning() {
+        glucosePoints = []
+        predictedGlucosePoints = []
+        alternatePredictedGlucosePoints = nil
+        targetGlucosePoints = [TargetChartBar]()
+        targetOverrideDurationPoints = []
+
+        glucoseChartCache = nil
+    }
+
+    public func generate(withFrame frame: CGRect, xAxisModel: ChartAxisModel, xAxisValues: [ChartAxisValue], axisLabelSettings: ChartLabelSettings, guideLinesLayerSettings: ChartGuideLinesLayerSettings, colors: ChartColorPalette, chartSettings: ChartSettings, labelsWidthY: CGFloat, gestureRecognizer: UIGestureRecognizer?, traitCollection: UITraitCollection) -> Chart
+    {
+        if targetGlucosePoints.isEmpty, xAxisValues.count > 1, let schedule = targetGlucoseSchedule {
+
+            // TODO: This only considers one override: pre-meal or an active override. ChartPoint.barsForGlucoseRangeSchedule needs to accept list of overridden ranges.
+            let potentialOverride = (preMealOverride?.isActive() ?? false) ? preMealOverride : (scheduleOverride?.isActive() ?? false) ? scheduleOverride : nil
+            targetGlucosePoints = ChartPoint.barsForGlucoseRangeSchedule(schedule, unit: glucoseUnit, xAxisValues: xAxisValues, considering: potentialOverride)
+
+            var displayedScheduleOverride = scheduleOverride
+            if let preMealOverride = preMealOverride, preMealOverride.isActive() {
+                preMealOverrideDurationPoints = ChartPoint.pointsForGlucoseRangeScheduleOverride(preMealOverride, unit: glucoseUnit, xAxisValues: xAxisValues)
+
+                if displayedScheduleOverride != nil {
+                    if displayedScheduleOverride!.scheduledEndDate > preMealOverride.scheduledEndDate {
+                        let start = max(displayedScheduleOverride!.startDate, preMealOverride.scheduledEndDate)
+                        displayedScheduleOverride!.scheduledInterval = DateInterval(start: start, end: displayedScheduleOverride!.scheduledEndDate)
+                    } else {
+                        displayedScheduleOverride = nil
+                    }
+                }
+            } else {
+                preMealOverrideDurationPoints = []
+            }
+
+            if let override = displayedScheduleOverride, override.isActive() || override.startDate > Date() {
+                targetOverrideDurationPoints = ChartPoint.pointsForGlucoseRangeScheduleOverride(override, unit: glucoseUnit, xAxisValues: xAxisValues)
+            } else {
+                targetOverrideDurationPoints = []
+            }
+        }
+        
+        let yAxisValues = determineYAxisValues(axisLabelSettings: axisLabelSettings)
+        let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: colors.axisLine, labelSpaceReservationMode: .fixed(labelsWidthY))
+
+        let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: frame, xModel: xAxisModel, yModel: yAxisModel)
+
+        let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame)
+
+        // The glucose targets
+        let targetFill = colors.glucoseTint.withAlphaComponent(0.2)
+        let overrideFill: UIColor = colors.glucoseTint.withAlphaComponent(0.45)
+        let fills =
+            targetGlucosePoints.map {
+                if $0.isOverride {
+                    return ChartPointsFill(
+                        chartPoints: $0.points,
+                        fillColor: overrideFill,
+                        createContainerPoints: false)
+                } else {
+                    return ChartPointsFill(
+                        chartPoints: $0.points,
+                        fillColor: targetFill,
+                        createContainerPoints: false)
+                }
+            } + [
+                ChartPointsFill(
+                    chartPoints: preMealOverrideDurationPoints,
+                    fillColor: overrideFill,
+                    createContainerPoints: false
+                ),
+                ChartPointsFill(
+                    chartPoints: targetOverrideDurationPoints,
+                    fillColor: overrideFill,
+                    createContainerPoints: false
+                )]
+        
+        let targetsLayer = ChartPointsFillsLayer(
+            xAxis: xAxisLayer.axis,
+            yAxis: yAxisLayer.axis,
+            fills: fills
+        )
+
+        // Grid lines
+        let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues)
+
+        let circles = ChartPointsScatterCirclesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: glucosePoints, displayDelay: 0, itemSize: CGSize(width: 4, height: 4), itemFillColor: colors.glucoseTint, optimized: true)
+
+        var alternatePrediction: ChartLayer?
+
+        if let altPoints = alternatePredictedGlucosePoints, altPoints.count > 1 {
+
+            let lineModel = ChartLineModel.predictionLine(points: altPoints, color: colors.glucoseTint, width: 2)
+
+            alternatePrediction = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel])
+        }
+
+        var prediction: ChartLayer?
+
+        if predictedGlucosePoints.count > 1 {
+            let lineColor = (alternatePrediction == nil) ? colors.glucoseTint : UIColor.secondaryLabel
+
+            let lineModel = ChartLineModel.predictionLine(
+                points: predictedGlucosePoints,
+                color: lineColor,
+                width: 1
+            )
+
+            prediction = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel])
+        }
+
+        if gestureRecognizer != nil {
+            glucoseChartCache = ChartPointsTouchHighlightLayerViewCache(
+                xAxisLayer: xAxisLayer,
+                yAxisLayer: yAxisLayer,
+                axisLabelSettings: axisLabelSettings,
+                chartPoints: glucosePoints + (alternatePredictedGlucosePoints ?? predictedGlucosePoints),
+                tintColor: colors.glucoseTint,
+                gestureRecognizer: gestureRecognizer
+            )
+        }
+
+        let layers: [ChartLayer?] = [
+            gridLayer,
+            targetsLayer,
+            xAxisLayer,
+            yAxisLayer,
+            glucoseChartCache?.highlightLayer,
+            prediction,
+            alternatePrediction,
+            circles
+        ]
+
+        return Chart(
+            frame: frame,
+            innerFrame: innerFrame,
+            settings: chartSettings,
+            layers: layers.compactMap { $0 }
+        )
+    }
+    
+    private func determineYAxisValues(axisLabelSettings: ChartLabelSettings? = nil) -> [ChartAxisValue] {
+        let points = [
+            glucosePoints, predictedGlucosePoints,
+            preMealOverrideDurationPoints, targetOverrideDurationPoints,
+            targetGlucosePoints.flatMap { $0.points },
+            glucoseDisplayRangePoints
+        ].flatMap { $0 }
+
+        let axisValueGenerator: ChartAxisValueStaticGenerator
+        if let axisLabelSettings = axisLabelSettings {
+            axisValueGenerator = { ChartAxisValueDouble($0, labelSettings: axisLabelSettings) }
+        } else {
+            axisValueGenerator = { ChartAxisValueDouble($0) }
+        }
+        
+        let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesUsingLinearSegmentStep(chartPoints: points,
+            minSegmentCount: 2,
+            maxSegmentCount: maxYAxisSegmentCount,
+            multiple: glucoseUnit == .milligramsPerDeciliter ? (yAxisStepSizeMGDLOverride ?? 25) : 1,
+            axisValueGenerator: axisValueGenerator,
+            addPaddingSegmentIfEdge: false
+        )
+        
+        return yAxisValues
+    }
+}
+
+extension PredictedGlucoseChart {
+    public func setGlucoseValues(_ glucoseValues: [GlucoseValue]) {
+        glucosePoints = glucosePointsFromValues(glucoseValues)
+    }
+
+    public func setPredictedGlucoseValues(_ glucoseValues: [GlucoseValue]) {
+        let clampedPredicatedGlucoseValues = clampPredictedGlucoseValues(glucoseValues)
+        predictedGlucosePoints = glucosePointsFromValues(clampedPredicatedGlucoseValues)
+    }
+
+    public func setAlternatePredictedGlucoseValues(_ glucoseValues: [GlucoseValue]) {
+        alternatePredictedGlucosePoints = glucosePointsFromValues(glucoseValues)
+    }
+}
+
+
+// MARK: - Clamping the predicted glucose values
+extension PredictedGlucoseChart {
+    var chartMaximumValue: HKQuantity? {
+        guard let glucosePointMaximum = glucosePoints.max(by: { point1, point2 in point1.y.scalar < point2.y.scalar }) else {
+            return nil
+        }
+        
+        let yAxisValues = determineYAxisValues()
+        
+        if let maxYAxisValue = yAxisValues.last,
+            maxYAxisValue.scalar > glucosePointMaximum.y.scalar
+        {
+            return HKQuantity(unit: glucoseUnit, doubleValue: maxYAxisValue.scalar)
+        }
+        
+        return HKQuantity(unit: glucoseUnit, doubleValue: glucosePointMaximum.y.scalar)
+    }
+        
+    var chartMinimumValue: HKQuantity? {
+        guard let glucosePointMinimum = glucosePoints.min(by: { point1, point2 in point1.y.scalar < point2.y.scalar }) else {
+            return nil
+        }
+        
+        let yAxisValues = determineYAxisValues()
+        
+        if let minYAxisValue = yAxisValues.first,
+            minYAxisValue.scalar < glucosePointMinimum.y.scalar
+        {
+            return HKQuantity(unit: glucoseUnit, doubleValue: minYAxisValue.scalar)
+        }
+        
+        return HKQuantity(unit: glucoseUnit, doubleValue: glucosePointMinimum.y.scalar)
+    }
+    
+    func clampPredictedGlucoseValues(_ glucoseValues: [GlucoseValue]) -> [GlucoseValue] {
+        guard let predictedGlucoseBounds = predictedGlucoseSoftBounds else {
+            return glucoseValues
+        }
+        
+        let predictedGlucoseValueMaximum = chartMaximumValue != nil ? max(predictedGlucoseBounds.maximum, chartMaximumValue!) : predictedGlucoseBounds.maximum
+        
+        let predictedGlucoseValueMinimum = chartMinimumValue != nil ? min(predictedGlucoseBounds.minimum, chartMinimumValue!) : predictedGlucoseBounds.minimum
+        
+        return glucoseValues.map {
+            if $0.quantity > predictedGlucoseValueMaximum {
+                return PredictedGlucoseValue(startDate: $0.startDate, quantity: predictedGlucoseValueMaximum)
+            } else if $0.quantity < predictedGlucoseValueMinimum {
+                return PredictedGlucoseValue(startDate: $0.startDate, quantity: predictedGlucoseValueMinimum)
+            } else {
+                return $0
+            }
+        }
+    }
+    
+    var chartedGlucoseValueMinimum: HKQuantity? {
+        guard let glucosePointMinimum = glucosePoints.min(by: { point1, point2 in point1.y.scalar < point2.y.scalar }) else {
+            return nil
+        }
+        
+        return HKQuantity(unit: glucoseUnit, doubleValue: glucosePointMinimum.y.scalar)
+    }
+    
+    func glucoseValueBelowSoftBoundsMinimum() -> Bool {
+        guard let predictedGlucoseSoftBounds = predictedGlucoseSoftBounds,
+            let chartedGlucoseValueMinimum = chartedGlucoseValueMinimum else
+        {
+            return false
+        }
+            
+        return chartedGlucoseValueMinimum < predictedGlucoseSoftBounds.minimum
+    }
+    
+    public struct PredictedGlucoseBounds {
+        var minimum: HKQuantity
+        var maximum: HKQuantity
+        
+        public static var `default`: PredictedGlucoseBounds {
+            return PredictedGlucoseBounds(minimum: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40),
+                                          maximum: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 400))
+        }
+    }
+}

+ 28 - 0
Dependencies/LoopKit/LoopKitUI/Extensions/CGPoint.swift

@@ -0,0 +1,28 @@
+//
+//  CGPoint.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 2/29/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import UIKit
+
+
+extension CGPoint {
+    /**
+     Rounds the coordinates to whole-pixel values
+
+     - parameter scale: The display scale to use. Defaults to the main screen scale.
+     */
+    mutating func makeIntegralInPlaceWithDisplayScale(_ scale: CGFloat = 0) {
+        var scale = scale
+
+        // It's possible for scale values retrieved from traitCollection objects to be 0.
+        if scale == 0 {
+            scale = UIScreen.main.scale
+        }
+        x = round(x * scale) / scale
+        y = round(y * scale) / scale
+    }
+}

+ 104 - 0
Dependencies/LoopKit/LoopKitUI/Extensions/ChartAxisValuesStaticGenerator.swift

@@ -0,0 +1,104 @@
+//
+//  ChartAxisValuesStaticGenerator.swift
+//  LoopUI
+//
+//  Created by Nathaniel Hamming on 2020-09-08.
+//  Copyright © 2020 LoopKit Authors. All rights reserved.
+//
+
+import SwiftCharts
+import UIKit
+
+extension ChartAxisValuesStaticGenerator {
+    // This is the same as SwiftChart ChartAxisValuesStaticGenerator.generateAxisValuesWithChartPoints(...) with the exception that the `currentMultiple` is calculated linearly instead of quadratically
+    static func generateYAxisValuesUsingLinearSegmentStep(chartPoints: [ChartPoint],
+                                                          minSegmentCount: Double,
+                                                          maxSegmentCount: Double,
+                                                          multiple: Double,
+                                                          axisValueGenerator: ChartAxisValueStaticGenerator,
+                                                          addPaddingSegmentIfEdge: Bool) -> [ChartAxisValue]
+    {
+        precondition(multiple > 0, "Invalid multiple: \(multiple)")
+        
+        let sortedChartPoints = chartPoints.sorted {(obj1, obj2) in
+            return obj1.y.scalar < obj2.y.scalar
+        }
+        
+        if let firstChartPoint = sortedChartPoints.first, let lastChartPoint = sortedChartPoints.last {
+            let first = firstChartPoint.y.scalar
+            let lastPar = lastChartPoint.y.scalar
+            
+            guard lastPar >=~ first else {fatalError("Invalid range generating axis values")}
+            
+            let last = lastPar =~ first ? lastPar + 1 : lastPar
+            
+            /// The first axis value will be less than or equal to the first scalar value, aligned with the desired multiple
+            var firstValue = first - (first.truncatingRemainder(dividingBy: multiple))
+            /// The last axis value will be greater than or equal to the last scalar value, aligned with the desired multiple
+            let remainder = last.truncatingRemainder(dividingBy: multiple)
+            var lastValue = remainder == 0 ? last : last + (multiple - remainder)
+            var segmentSize = multiple
+            
+            /// If there should be a padding segment added when a scalar value falls on the first or last axis value, adjust the first and last axis values
+            if firstValue =~ first && addPaddingSegmentIfEdge {
+               firstValue = firstValue - segmentSize
+            }
+            
+            // do not allow the first label to be displayed as -0
+            while firstValue < 0 && firstValue.rounded() == -0 {
+                firstValue = firstValue - segmentSize
+            }
+            
+            if lastValue =~ last && addPaddingSegmentIfEdge {
+                lastValue = lastValue + segmentSize
+            }
+            
+            let distance = lastValue - firstValue
+            var currentMultiple = multiple
+            var segmentCount = distance / currentMultiple
+            var potentialSegmentValues = stride(from: firstValue, to: lastValue, by: currentMultiple)
+
+            /// Find the optimal number of segments and segment width
+            /// If the number of segments is greater than desired, make each segment wider
+            /// ensure no label of -0 will be displayed on the axis
+            while segmentCount > maxSegmentCount ||
+                !potentialSegmentValues.filter({ $0 < 0 && $0.rounded() == -0 }).isEmpty
+            {
+                currentMultiple += multiple
+                segmentCount = distance / currentMultiple
+                potentialSegmentValues = stride(from: firstValue, to: lastValue, by: currentMultiple)
+            }
+            segmentCount = ceil(segmentCount)
+            
+            /// Increase the number of segments until there are enough as desired
+            while segmentCount < minSegmentCount {
+                segmentCount += 1
+            }
+            segmentSize = currentMultiple
+            
+            /// Generate axis values from the first value, segment size and number of segments
+            let offset = firstValue
+            return (0...Int(segmentCount)).map {segment in
+                var scalar = offset + (Double(segment) * segmentSize)
+                // a value that could be displayed as 0 should truly be 0 to have the zero-line drawn correctly.
+                if scalar != 0,
+                    scalar.rounded() == 0
+                {
+                    scalar = 0
+                }
+                return axisValueGenerator(scalar)
+            }
+        } else {
+            print("Trying to generate Y axis without datapoints, returning empty array")
+            return []
+        }
+    }
+}
+
+fileprivate func =~ (a: Double, b: Double) -> Bool {
+    return fabs(a - b) < Double.ulpOfOne
+}
+
+fileprivate func >=~ (a: Double, b: Double) -> Bool {
+    return a =~ b || a > b
+}

+ 144 - 0
Dependencies/LoopKit/LoopKitUI/Extensions/ChartPoint.swift

@@ -0,0 +1,144 @@
+//
+//  ChartPoint.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 2/19/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+import LoopKit
+import SwiftCharts
+
+struct TargetChartBar {
+    let points: [ChartPoint]
+    let isOverride: Bool
+}
+
+extension ChartPoint {
+    static func barsForGlucoseRangeSchedule(_ glucoseRangeSchedule: GlucoseRangeSchedule, unit: HKUnit, xAxisValues: [ChartAxisValue], considering potentialOverride: TemporaryScheduleOverride? = nil) -> [TargetChartBar] {
+        let targetRanges = glucoseRangeSchedule.quantityBetween(
+            start: ChartAxisValueDate.dateFromScalar(xAxisValues.first!.scalar),
+            end: ChartAxisValueDate.dateFromScalar(xAxisValues.last!.scalar)
+        )
+
+        let dateFormatter = DateFormatter()
+
+        var result = [TargetChartBar?]()
+
+        for (index, range) in targetRanges.enumerated() {
+            var startDate = ChartAxisValueDate(date: range.startDate, formatter: dateFormatter)
+            var endDate: ChartAxisValueDate
+
+            if index == targetRanges.startIndex, let firstDate = xAxisValues.first as? ChartAxisValueDate {
+                startDate = firstDate
+            }
+
+            if index == targetRanges.endIndex - 1, let lastDate = xAxisValues.last as? ChartAxisValueDate {
+                endDate = lastDate
+            } else {
+                endDate = ChartAxisValueDate(date: targetRanges[index + 1].startDate, formatter: dateFormatter)
+            }
+
+            if let override = potentialOverride,
+               startDate.date < endDate.date,
+               (override.startDate...override.scheduledEndDate).overlaps(startDate.date...endDate.date)
+            {
+                result.append(createBar(value: range.value, unit: unit, startDate: startDate, endDate: ChartAxisValueDate(date: override.startDate, formatter: dateFormatter), isOverride: false))
+                let targetDuringOverride = override.settings.targetRange ?? range.value
+                result.append(createBar(
+                    value: targetDuringOverride,
+                    unit: unit,
+                    startDate: ChartAxisValueDate(date: max(override.startDate, startDate.date), formatter: dateFormatter),
+                    endDate: ChartAxisValueDate(date: min(override.scheduledEndDate, endDate.date), formatter: dateFormatter),
+                    isOverride: true))
+                result.append(createBar(value: range.value, unit: unit, startDate: ChartAxisValueDate(date: override.scheduledEndDate, formatter: dateFormatter), endDate: endDate, isOverride: false))
+            } else {
+                result.append(createBar(value: range.value, unit: unit, startDate: startDate, endDate: endDate, isOverride: false))
+            }
+        }
+
+        return result.compactMap { $0 }
+    }
+    
+    static fileprivate func createBar(value: ClosedRange<HKQuantity>, unit: HKUnit, startDate: ChartAxisValueDate, endDate: ChartAxisValueDate, isOverride: Bool) -> TargetChartBar? {
+        guard startDate.date < endDate.date else { return nil }
+        
+        let value = value.doubleRangeWithMinimumIncrement(in: unit)
+        let minValue = ChartAxisValueDouble(value.minValue)
+        let maxValue = ChartAxisValueDouble(value.maxValue)
+
+        return TargetChartBar(
+            points: [
+                ChartPoint(x: startDate, y: maxValue),
+                ChartPoint(x: endDate, y: maxValue),
+                ChartPoint(x: endDate, y: minValue),
+                ChartPoint(x: startDate, y: minValue)
+            ],
+            isOverride: isOverride)
+    }
+
+    static func pointsForGlucoseRangeScheduleOverride(_ override: TemporaryScheduleOverride, unit: HKUnit, xAxisValues: [ChartAxisValue], extendEndDateToChart: Bool = false) -> [ChartPoint] {
+        guard let targetRange = override.settings.targetRange else {
+            return []
+        }
+
+        return pointsForGlucoseRangeScheduleOverride(
+            range: targetRange.doubleRangeWithMinimumIncrement(in: unit),
+            activeInterval: override.activeInterval,
+            unit: unit,
+            xAxisValues: xAxisValues,
+            extendEndDateToChart: extendEndDateToChart
+        )
+    }
+
+    private static func pointsForGlucoseRangeScheduleOverride(range: DoubleRange, activeInterval: DateInterval, unit: HKUnit, xAxisValues: [ChartAxisValue], extendEndDateToChart: Bool) -> [ChartPoint] {
+        guard let lastXAxisValue = xAxisValues.last as? ChartAxisValueDate else {
+            return []
+        }
+
+        let dateFormatter = DateFormatter()
+        let startDateAxisValue = ChartAxisValueDate(date: activeInterval.start, formatter: dateFormatter)
+        let displayEndDate = min(lastXAxisValue.date, extendEndDateToChart ? .distantFuture : activeInterval.end)
+        let endDateAxisValue = ChartAxisValueDate(date: displayEndDate, formatter: dateFormatter)
+        let minValue = ChartAxisValueDouble(range.minValue)
+        let maxValue = ChartAxisValueDouble(range.maxValue)
+
+        return [
+            ChartPoint(x: startDateAxisValue, y: maxValue),
+            ChartPoint(x: endDateAxisValue, y: maxValue),
+            ChartPoint(x: endDateAxisValue, y: minValue),
+            ChartPoint(x: startDateAxisValue, y: minValue)
+        ]
+    }
+}
+
+
+
+extension ChartPoint: TimelineValue {
+    public var startDate: Date {
+        if let dateValue = x as? ChartAxisValueDate {
+            return dateValue.date
+        } else {
+            return Date.distantPast
+        }
+    }
+}
+
+
+private extension ClosedRange where Bound == HKQuantity {
+    func doubleRangeWithMinimumIncrement(in unit: HKUnit) -> DoubleRange {
+        let increment = unit.chartableIncrement
+
+        var minValue = self.lowerBound.doubleValue(for: unit)
+        var maxValue = self.upperBound.doubleValue(for: unit)
+
+        if (maxValue - minValue) < .ulpOfOne {
+            minValue -= increment
+            maxValue += increment
+        }
+
+        return DoubleRange(minValue: minValue, maxValue: maxValue)
+    }
+}

+ 73 - 0
Dependencies/LoopKit/LoopKitUI/Extensions/CollectionType.swift

@@ -0,0 +1,73 @@
+//
+//  CollectionType.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 2/21/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+
+
+extension BidirectionalCollection where Index: Strideable, Element: Comparable, Index.Stride == Int {
+
+    /**
+     Returns the insertion index of a new value in a sorted collection
+
+     Based on some helpful responses found at [StackOverflow](http://stackoverflow.com/a/33674192)
+     
+     - parameter value: The value to insert
+
+     - returns: The appropriate insertion index, between `startIndex` and `endIndex`
+     */
+    func findInsertionIndex(for value: Element) -> Index {
+        var low = startIndex
+        var high = endIndex
+
+        while low != high {
+            let mid = low.advanced(by: low.distance(to: high) / 2)
+
+            if self[mid] < value {
+                low = mid.advanced(by: 1)
+            } else {
+                high = mid
+            }
+        }
+
+        return low
+    }
+}
+
+
+extension BidirectionalCollection where Index: Strideable, Element: Strideable, Index.Stride == Int {
+    /**
+     Returns the index of the closest element to a specified value in a sorted collection
+
+     - parameter value: The value to match
+
+     - returns: The index of the closest element, or nil if the collection is empty
+     */
+    func findClosestElementIndex(matching value: Element) -> Index? {
+        let upperBound = findInsertionIndex(for: value)
+
+        if upperBound == startIndex {
+            if upperBound == endIndex {
+                return nil
+            }
+            return upperBound
+        }
+
+        let lowerBound = upperBound.advanced(by: -1)
+
+        if upperBound == endIndex {
+            return lowerBound
+        }
+
+        if value.distance(to: self[upperBound]) < self[lowerBound].distance(to: value) {
+            return upperBound
+        }
+        
+        return lowerBound
+    }
+}
+

+ 27 - 0
Dependencies/LoopKit/LoopKitUI/Extensions/NumberFormatter+Charts.swift

@@ -0,0 +1,27 @@
+//
+//  NumberFormatter.swift
+//  LoopUI
+//
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+extension NumberFormatter {
+    class var dose: NumberFormatter {
+        let numberFormatter = NumberFormatter()
+        numberFormatter.numberStyle = .decimal
+        numberFormatter.minimumFractionDigits = 2
+        numberFormatter.maximumFractionDigits = 2
+
+        return numberFormatter
+    }
+
+    class var integer: NumberFormatter {
+        let numberFormatter = NumberFormatter()
+        numberFormatter.numberStyle = .none
+        numberFormatter.maximumFractionDigits = 0
+
+        return numberFormatter
+    }
+}

+ 56 - 0
Dependencies/LoopKit/LoopKitUI/Models/ChartAxisValueDoubleLog.swift

@@ -0,0 +1,56 @@
+//
+//  ChartAxisValueDoubleLog.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 2/29/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import UIKit
+import SwiftCharts
+
+
+public final class ChartAxisValueDoubleLog: ChartAxisValueDoubleScreenLoc {
+
+    let unitString: String?
+
+    public init(actualDouble: Double, unitString: String? = nil, formatter: NumberFormatter, labelSettings: ChartLabelSettings = ChartLabelSettings()) {
+        let screenLocDouble: Double
+
+        switch actualDouble {
+        case let x where x < 0:
+            screenLocDouble = -log(-x + 1)
+        case let x where x > 0:
+            screenLocDouble = log(x + 1)
+        default:  // 0
+            screenLocDouble = 0
+        }
+
+        self.unitString = unitString
+
+        super.init(screenLocDouble: screenLocDouble, actualDouble: actualDouble, formatter: formatter, labelSettings: labelSettings)
+    }
+
+    public init(screenLocDouble: Double, formatter: NumberFormatter, labelSettings: ChartLabelSettings = ChartLabelSettings()) {
+        let actualDouble: Double
+
+        switch screenLocDouble {
+        case let x where x < 0:
+            actualDouble = -pow(M_E, -x) + 1
+        case let x where x > 0:
+            actualDouble = pow(M_E, x) - 1
+        default:  // 0
+            actualDouble = 0
+        }
+
+        self.unitString = nil
+
+        super.init(screenLocDouble: screenLocDouble, actualDouble: actualDouble, formatter: formatter, labelSettings: labelSettings)
+    }
+
+    override public var description: String {
+        let suffix = unitString != nil ? " \(unitString!)" : ""
+
+        return super.description + suffix
+    }
+}

+ 58 - 0
Dependencies/LoopKit/LoopKitUI/ViewModels/DisplayGlucosePreference.swift

@@ -0,0 +1,58 @@
+//
+//  DisplayGlucosePreference.swift
+//  LoopKitUI
+//
+//  Created by Nathaniel Hamming on 2021-03-10.
+//  Copyright © 2021 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+import SwiftUI
+import LoopKit
+
+public class DisplayGlucosePreference: ObservableObject {
+    @Published public private(set) var unit: HKUnit
+    @Published public private(set) var rateUnit: HKUnit
+    @Published public private(set) var formatter: QuantityFormatter
+    @Published public private(set) var minuteRateFormatter: QuantityFormatter
+
+    public init(displayGlucoseUnit: HKUnit) {
+        let rateUnit = displayGlucoseUnit.unitDivided(by: .minute())
+
+        self.unit = displayGlucoseUnit
+        self.rateUnit = rateUnit
+        self.formatter = QuantityFormatter(for: displayGlucoseUnit)
+        self.minuteRateFormatter = QuantityFormatter(for: rateUnit)
+        self.formatter.numberFormatter.notANumberSymbol = "–"
+        self.minuteRateFormatter.numberFormatter.notANumberSymbol = "–"
+    }
+
+    /// Formats a glucose HKQuantity and unit as a localized string
+    ///
+    /// - Parameters:
+    ///   - quantity: The quantity
+    ///   - includeUnit: Whether or not to include the unit in the returned string
+    /// - Returns: A localized string, or the numberFormatter's notANumberSymbol (default is "–")
+    open func format(_ quantity: HKQuantity, includeUnit: Bool = true) -> String {
+        return formatter.string(from: quantity, includeUnit: includeUnit) ?? self.formatter.numberFormatter.notANumberSymbol
+    }
+
+    /// Formats a glucose HKQuantity rate (in terms of mg/dL/min or mmol/L/min and unit as a localized string
+    ///
+    /// - Parameters:
+    ///   - quantity: The quantity
+    ///   - includeUnit: Whether or not to include the unit in the returned string
+    /// - Returns: A localized string, or the numberFormatter's notANumberSymbol (default is "–")
+    open func formatMinuteRate(_ quantity: HKQuantity, includeUnit: Bool = true) -> String {
+        return minuteRateFormatter.string(from: quantity, includeUnit: includeUnit) ?? self.formatter.numberFormatter.notANumberSymbol
+    }
+
+}
+
+extension DisplayGlucosePreference: DisplayGlucoseUnitObserver {
+    public func unitDidChange(to displayGlucoseUnit: HKUnit) {
+        self.unit = displayGlucoseUnit
+        self.formatter = QuantityFormatter(for: displayGlucoseUnit)
+    }
+}

+ 122 - 0
Dependencies/LoopKit/LoopKitUI/Views/ChartPointsContextFillLayer.swift

@@ -0,0 +1,122 @@
+//
+//  ChartPointsContextFillLayer.swift
+//  Loop
+//
+//  Copyright © 2017 LoopKit Authors. All rights reserved.
+//
+
+import SwiftCharts
+import CoreGraphics
+import UIKit
+
+struct ChartPointsFill {
+    let chartPoints: [ChartPoint]
+    let fillColor: UIColor
+    let createContainerPoints: Bool
+    let blendMode: CGBlendMode
+    fileprivate var screenPoints: [CGPoint] = []
+
+    init?(chartPoints: [ChartPoint], fillColor: UIColor, createContainerPoints: Bool = true, blendMode: CGBlendMode = .normal) {
+        guard chartPoints.count > 1 else {
+            return nil;
+        }
+
+        var chartPoints = chartPoints
+
+        if createContainerPoints {
+            // Create a container line at value position 0
+            if let first = chartPoints.first {
+                chartPoints.insert(ChartPoint(x: first.x, y: ChartAxisValueInt(0)), at: 0)
+            }
+
+            if let last = chartPoints.last {
+                chartPoints.append(ChartPoint(x: last.x, y: ChartAxisValueInt(0)))
+            }
+        }
+
+        self.chartPoints = chartPoints
+        self.fillColor = fillColor
+        self.createContainerPoints = createContainerPoints
+        self.blendMode = blendMode
+    }
+
+    var areaPath: UIBezierPath {
+        let path = UIBezierPath()
+
+        if let point = screenPoints.first {
+            path.move(to: point)
+        }
+
+        for point in screenPoints.dropFirst() {
+            path.addLine(to: point)
+        }
+
+        return path
+    }
+}
+
+
+final class ChartPointsFillsLayer: ChartCoordsSpaceLayer {
+    let fills: [ChartPointsFill]
+
+    init?(xAxis: ChartAxis, yAxis: ChartAxis, fills: [ChartPointsFill?]) {
+        self.fills = fills.compactMap({ $0 })
+
+        guard fills.count > 0 else {
+            return nil
+        }
+
+        super.init(xAxis: xAxis, yAxis: yAxis)
+    }
+
+    override func chartInitialized(chart: Chart) {
+        super.chartInitialized(chart: chart)
+
+        let view = ChartPointsFillsView(
+            frame: chart.bounds,
+            chartPointsFills: fills.map { (fill) -> ChartPointsFill in
+                var fill = fill
+
+                fill.screenPoints = fill.chartPoints.map { (point) -> CGPoint in
+                    return modelLocToScreenLoc(x: point.x.scalar, y: point.y.scalar)
+                }
+
+                return fill
+            }
+        )
+
+        chart.addSubview(view)
+    }
+}
+
+
+class ChartPointsFillsView: UIView {
+    let chartPointsFills: [ChartPointsFill]
+    var allowsAntialiasing = false
+
+    init(frame: CGRect, chartPointsFills: [ChartPointsFill]) {
+        self.chartPointsFills = chartPointsFills
+
+        super.init(frame: frame)
+
+        backgroundColor = .clear
+    }
+    
+    required init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    override func draw(_ rect: CGRect) {
+        guard let context = UIGraphicsGetCurrentContext() else { return }
+
+        context.saveGState()
+        context.setAllowsAntialiasing(allowsAntialiasing)
+
+        for fill in chartPointsFills {
+            context.setFillColor(fill.fillColor.cgColor)
+            fill.areaPath.fill(with: fill.blendMode, alpha: 1)
+        }
+
+        context.restoreGState()
+    }
+}

+ 54 - 0
Dependencies/LoopKit/LoopKitUI/Views/ChartPointsScatterDownTrianglesLayer.swift

@@ -0,0 +1,54 @@
+//
+//  ChartPointsScatterDownTrianglesLayer.swift
+//  Loop
+//
+//  Created by Nate Racklyeft on 9/28/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import SwiftCharts
+import CoreGraphics
+import UIKit
+
+public class ChartPointsScatterDownTrianglesLayer<T: ChartPoint>: ChartPointsScatterLayer<T> {
+    public required init(
+        xAxis: ChartAxis,
+        yAxis: ChartAxis,
+        chartPoints: [T],
+        displayDelay: Float,
+        itemSize: CGSize,
+        itemFillColor: UIColor,
+        optimized: Bool = false,
+        tapSettings: ChartPointsTapSettings<T>? = nil
+    ) {
+        // optimized must be set to false because `generateCGLayer` isn't public and can't be overridden
+        super.init(
+            xAxis: xAxis,
+            yAxis: yAxis,
+            chartPoints: chartPoints,
+            displayDelay: displayDelay,
+            itemSize: itemSize,
+            itemFillColor: itemFillColor,
+            optimized: false,
+            tapSettings: tapSettings
+        )
+    }
+
+    public override func drawChartPointModel(_ context: CGContext, chartPointModel: ChartPointLayerModel<T>, view: UIView) {
+        let w = self.itemSize.width
+        let h = self.itemSize.height
+
+        let horizontalOffset = -view.frame.origin.x
+        let verticalOffset = -view.frame.origin.y
+
+        let path = CGMutablePath()
+        path.move(to: CGPoint(x: chartPointModel.screenLoc.x + horizontalOffset, y: chartPointModel.screenLoc.y + verticalOffset + h / 2))
+        path.addLine(to: CGPoint(x: chartPointModel.screenLoc.x + horizontalOffset + w / 2, y: chartPointModel.screenLoc.y + verticalOffset - h / 2))
+        path.addLine(to: CGPoint(x: chartPointModel.screenLoc.x + horizontalOffset - w / 2, y: chartPointModel.screenLoc.y + verticalOffset - h / 2))
+        path.closeSubpath()
+
+        context.setFillColor(self.itemFillColor.cgColor)
+        context.addPath(path)
+        context.fillPath()
+    }
+}

+ 117 - 0
Dependencies/LoopKit/LoopKitUI/Views/ChartPointsTouchHighlightLayerViewCache.swift

@@ -0,0 +1,117 @@
+//
+//  StatusChartHighlightLayer.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 2/28/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import SwiftCharts
+import UIKit
+
+final class ChartPointsTouchHighlightLayerViewCache {
+    private lazy var containerView = UIView(frame: .zero)
+
+    private lazy var xAxisOverlayView = UIView()
+
+    private lazy var point = ChartPointEllipseView(center: .zero, diameter: 16)
+
+    private lazy var labelY: UILabel = {
+        let label = UILabel()
+        label.font = UIFont.monospacedDigitSystemFont(ofSize: 15, weight: UIFont.Weight.bold)
+
+        return label
+    }()
+
+    private lazy var labelX: UILabel = {
+        let label = UILabel()
+        label.font = self.axisLabelSettings.font
+        label.textColor = self.axisLabelSettings.fontColor
+
+        return label
+    }()
+
+    private let axisLabelSettings: ChartLabelSettings
+
+    private(set) var highlightLayer: ChartPointsTouchHighlightLayer<ChartPoint, UIView>!
+
+    init(xAxisLayer: ChartAxisLayer, yAxisLayer: ChartAxisLayer, axisLabelSettings: ChartLabelSettings, chartPoints: [ChartPoint], tintColor: UIColor, gestureRecognizer: UIGestureRecognizer? = nil, onCompleteHighlight: (() -> Void)? = nil) {
+
+        self.axisLabelSettings = axisLabelSettings
+
+        highlightLayer = ChartPointsTouchHighlightLayer(
+            xAxis: xAxisLayer.axis,
+            yAxis: yAxisLayer.axis,
+            chartPoints: chartPoints,
+            gestureRecognizer: gestureRecognizer,
+            onCompleteHighlight: onCompleteHighlight,
+            modelFilter: { (screenLoc, chartPointModels) -> ChartPointLayerModel<ChartPoint>? in
+                if let index = chartPointModels.map({ $0.screenLoc.x }).findClosestElementIndex(matching: screenLoc.x) {
+                    return chartPointModels[index]
+                } else {
+                    return nil
+                }
+            },
+            viewGenerator: { [weak self] (chartPointModel, layer, chart) -> UIView? in
+                guard let strongSelf = self else {
+                    return nil
+                }
+
+                let containerView = strongSelf.containerView
+                containerView.frame = chart.contentView.bounds
+                containerView.alpha = 1  // This is animated to 0 when touch last ended
+
+                let xAxisOverlayView = strongSelf.xAxisOverlayView
+                if xAxisOverlayView.superview == nil {
+                    xAxisOverlayView.frame = CGRect(
+                        origin: CGPoint(x: containerView.bounds.minX,
+                                        y: containerView.bounds.maxY + 1), // Don't clip X line
+                        size: xAxisLayer.frame.size
+                    )
+                    xAxisOverlayView.backgroundColor = .systemBackground
+                    xAxisOverlayView.isOpaque = true
+                    containerView.addSubview(xAxisOverlayView)
+                }
+
+                let point = strongSelf.point
+                point.center = chartPointModel.screenLoc
+                if point.superview == nil {
+                    point.fillColor = tintColor.withAlphaComponent(0.5)
+                    containerView.addSubview(point)
+                }
+
+                if let text = chartPointModel.chartPoint.y.labels.first?.text {
+                    let label = strongSelf.labelY
+
+                    label.text = text
+                    label.sizeToFit()
+                    label.center.y = containerView.frame.minY - 21
+                    label.center.x = chartPointModel.screenLoc.x
+                    label.frame.origin.x = min(max(label.frame.origin.x, containerView.bounds.minX), containerView.bounds.maxX - label.frame.size.width)
+                    label.frame.origin.makeIntegralInPlaceWithDisplayScale(chart.view.traitCollection.displayScale)
+
+                    if label.superview == nil {
+                        label.textColor = tintColor
+
+                        containerView.addSubview(label)
+                    }
+                }
+
+                if let text = chartPointModel.chartPoint.x.labels.first?.text {
+                    let label = strongSelf.labelX
+                    label.text = text
+                    label.sizeToFit()
+                    label.center = CGPoint(x: chartPointModel.screenLoc.x, y: xAxisOverlayView.center.y)
+                    label.frame.origin.makeIntegralInPlaceWithDisplayScale(chart.view.traitCollection.displayScale)
+
+                    if label.superview == nil {
+                        containerView.addSubview(label)
+                    }
+                }
+                
+                return containerView
+            }
+        )
+    }
+}

+ 52 - 0
Dependencies/LoopKit/LoopKitUI/Views/DateAndDurationTableViewCell.swift

@@ -0,0 +1,52 @@
+//
+//  DateAndDurationTableViewCell.swift
+//  LoopKitUI
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+import UIKit
+
+public class DateAndDurationTableViewCell: DatePickerTableViewCell {
+
+    public weak var delegate: DatePickerTableViewCellDelegate?
+
+    @IBOutlet public weak var titleLabel: UILabel!
+
+    @IBOutlet public weak var dateLabel: UILabel! {
+        didSet {
+            // Setting this color in code because the nib isn't being applied correctly
+            dateLabel.textColor = .secondaryLabel
+        }
+    }
+
+    private lazy var durationFormatter: DateComponentsFormatter = {
+        let formatter = DateComponentsFormatter()
+
+        formatter.allowedUnits = [.hour, .minute]
+        formatter.unitsStyle = .short
+
+        return formatter
+    }()
+
+    public override func updateDateLabel() {
+        switch datePicker.datePickerMode {
+        case .countDownTimer:
+            dateLabel.text = durationFormatter.string(from: duration)
+        case .date:
+            dateLabel.text = DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .none)
+        case .dateAndTime:
+            dateLabel.text = DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short)
+        case .time:
+            dateLabel.text = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .medium)
+        @unknown default:
+            break // Do nothing
+        }
+    }
+
+    public override func dateChanged(_ sender: UIDatePicker) {
+        super.dateChanged(sender)
+
+        delegate?.datePickerTableViewCellDidUpdateDate(self)
+    }
+}

+ 74 - 0
Dependencies/LoopKit/LoopKitUI/Views/DateAndDurationTableViewCell.xib

@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097.3" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
+    <device id="retina4_7" orientation="portrait" appearance="light"/>
+    <dependencies>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
+        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
+    </dependencies>
+    <objects>
+        <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
+        <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
+        <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="244" id="g8V-eF-Mfe" customClass="DateAndDurationTableViewCell" customModule="LoopKitUI" customModuleProvider="target">
+            <rect key="frame" x="0.0" y="0.0" width="375" height="244"/>
+            <autoresizingMask key="autoresizingMask"/>
+            <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="g8V-eF-Mfe" id="5ZV-xx-TUU">
+                <rect key="frame" x="0.0" y="0.0" width="375" height="244"/>
+                <autoresizingMask key="autoresizingMask"/>
+                <subviews>
+                    <view contentMode="scaleToFill" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="R2S-Ub-M7W">
+                        <rect key="frame" x="15" y="11" width="345" height="28"/>
+                        <subviews>
+                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Date" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" adjustsFontForContentSizeCategory="YES" translatesAutoresizingMaskIntoConstraints="NO" id="lrl-hk-6Uj">
+                                <rect key="frame" x="0.0" y="0.0" width="36" height="28"/>
+                                <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
+                                <nil key="textColor"/>
+                                <nil key="highlightedColor"/>
+                            </label>
+                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" adjustsFontForContentSizeCategory="YES" translatesAutoresizingMaskIntoConstraints="NO" id="AQf-Rb-UwO">
+                                <rect key="frame" x="303" y="0.0" width="42" height="28"/>
+                                <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
+                                <color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
+                                <nil key="highlightedColor"/>
+                            </label>
+                        </subviews>
+                        <constraints>
+                            <constraint firstItem="lrl-hk-6Uj" firstAttribute="top" secondItem="R2S-Ub-M7W" secondAttribute="top" id="3uk-QP-JjL"/>
+                            <constraint firstAttribute="trailing" secondItem="AQf-Rb-UwO" secondAttribute="trailing" id="5a8-7D-t3d"/>
+                            <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="28" id="7Jq-HV-uMc"/>
+                            <constraint firstItem="AQf-Rb-UwO" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="lrl-hk-6Uj" secondAttribute="trailing" constant="8" symbolic="YES" id="855-2i-Bjg"/>
+                            <constraint firstAttribute="bottom" secondItem="AQf-Rb-UwO" secondAttribute="bottom" id="dca-Sb-3xJ"/>
+                            <constraint firstItem="lrl-hk-6Uj" firstAttribute="leading" secondItem="R2S-Ub-M7W" secondAttribute="leading" id="dhX-4T-BRJ"/>
+                            <constraint firstItem="AQf-Rb-UwO" firstAttribute="top" secondItem="R2S-Ub-M7W" secondAttribute="top" id="gZJ-Ic-zYx"/>
+                            <constraint firstAttribute="bottom" secondItem="lrl-hk-6Uj" secondAttribute="bottom" id="y2J-K7-2r6"/>
+                        </constraints>
+                    </view>
+                    <datePicker contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" datePickerMode="dateAndTime" minuteInterval="1" translatesAutoresizingMaskIntoConstraints="NO" id="uHg-qt-dX0">
+                        <rect key="frame" x="0.0" y="47" width="375" height="194"/>
+                        <constraints>
+                            <constraint firstAttribute="height" priority="750" constant="200" id="V2S-yZ-Wpl"/>
+                        </constraints>
+                        <connections>
+                            <action selector="dateChanged:" destination="g8V-eF-Mfe" eventType="valueChanged" id="eHp-Or-G7k"/>
+                        </connections>
+                    </datePicker>
+                </subviews>
+                <constraints>
+                    <constraint firstItem="uHg-qt-dX0" firstAttribute="leading" secondItem="5ZV-xx-TUU" secondAttribute="leading" id="8mt-Np-dQX"/>
+                    <constraint firstAttribute="trailing" secondItem="uHg-qt-dX0" secondAttribute="trailing" id="DIU-Dd-v1R"/>
+                    <constraint firstItem="uHg-qt-dX0" firstAttribute="top" secondItem="R2S-Ub-M7W" secondAttribute="bottom" constant="8" id="Fb5-66-uiW"/>
+                    <constraint firstAttribute="bottomMargin" secondItem="uHg-qt-dX0" secondAttribute="bottom" priority="750" constant="-8" id="U4P-Ys-AuD"/>
+                    <constraint firstItem="R2S-Ub-M7W" firstAttribute="leading" secondItem="5ZV-xx-TUU" secondAttribute="leadingMargin" id="VSV-R2-CjN"/>
+                    <constraint firstAttribute="trailingMargin" secondItem="R2S-Ub-M7W" secondAttribute="trailing" id="fmT-fS-Gar"/>
+                    <constraint firstItem="R2S-Ub-M7W" firstAttribute="top" secondItem="5ZV-xx-TUU" secondAttribute="topMargin" id="kfl-01-9t8"/>
+                </constraints>
+            </tableViewCellContentView>
+            <connections>
+                <outlet property="dateLabel" destination="AQf-Rb-UwO" id="2Zm-Dx-YYA"/>
+                <outlet property="datePicker" destination="uHg-qt-dX0" id="Ejf-tw-dz0"/>
+                <outlet property="datePickerHeightConstraint" destination="V2S-yZ-Wpl" id="8lN-gu-CDh"/>
+                <outlet property="titleLabel" destination="lrl-hk-6Uj" id="aBm-pc-R9W"/>
+            </connections>
+            <point key="canvasLocation" x="138" y="141"/>
+        </tableViewCell>
+    </objects>
+</document>

+ 47 - 0
Dependencies/LoopKit/LoopKitUI/Views/DecimalTextFieldTableViewCell.swift

@@ -0,0 +1,47 @@
+//
+//  DecimalTextFieldTableViewCell.swift
+//  CarbKit
+//
+//  Created by Nathan Racklyeft on 1/15/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import UIKit
+
+public class DecimalTextFieldTableViewCell: TextFieldTableViewCell {
+
+    @IBOutlet weak var titleLabel: UILabel!
+    
+    var numberFormatter: NumberFormatter = {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+
+        return formatter
+    }()
+
+    public var number: NSNumber? {
+        get {
+            return numberFormatter.number(from: textField.text ?? "")
+        }
+        set {
+            if let value = newValue {
+                textField.text = numberFormatter.string(from: value)
+            } else {
+                textField.text = nil
+            }
+        }
+    }
+
+    // MARK: - UITextFieldDelegate
+
+    public override func textFieldDidEndEditing(_ textField: UITextField) {
+        if let number = number {
+            textField.text = numberFormatter.string(from: number)
+        } else {
+            textField.text = nil
+        }
+
+        super.textFieldDidEndEditing(textField)
+    }
+}
+

+ 50 - 0
Dependencies/LoopKit/LoopKitUI/Views/DemoPlaceHolderView.swift

@@ -0,0 +1,50 @@
+//
+//  DemoPlaceHolderView.swift
+//  LoopKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+
+public struct DemoPlaceHolderView: View {
+    var appName: String
+    
+    public init(appName: String) {
+        self.appName = appName
+    }
+    
+    public var body: some View {
+        VStack {
+            Spacer()
+            
+            VStack(alignment: .center, spacing: 30) {
+                Image(systemName: "minus.circle")
+                    .font(Font.system(size: 76, weight: .bold))
+                
+                Text("Nothing to See Here!")
+                    .font(.title2)
+                    .bold()
+                
+                Text("This section of the \(appName) app is unavailable in this simulator.")
+                    .multilineTextAlignment(.center)
+                
+                Text("Tap back to continue exploring the rest of the \(appName) interface.")
+                    .multilineTextAlignment(.center)
+            }
+            .padding(.horizontal, 40)
+            .padding(.top, -130) // to center the copy
+            
+            Spacer()
+        }
+        .background(Color(.systemGroupedBackground))
+        .navigationBarTitleDisplayMode(.inline)
+    }
+}
+
+struct DemoPlaceHolderView_Previews: PreviewProvider {
+    static var previews: some View {
+        DemoPlaceHolderView(appName: "Loop")
+    }
+}

+ 122 - 0
Dependencies/LoopKit/LoopKitUI/Views/FavoriteFoodListRow.swift

@@ -0,0 +1,122 @@
+//
+//  FavoriteFoodListRow.swift
+//  LoopKitUI
+//
+//  Created by Noah Brauner on 8/9/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import LoopKit
+import HealthKit
+
+public struct FavoriteFoodListRow: View {
+    @Environment(\.editMode) var editMode
+    
+    private let cornerRadius: CGFloat = 10
+    
+    let food: StoredFavoriteFood
+    @Binding var foodToConfirmDeleteId: String?
+    
+    let onTap: (StoredFavoriteFood) -> ()
+    let onDelete: (StoredFavoriteFood) -> ()
+    
+    let carbFormatter: QuantityFormatter
+    let absorptionTimeFormatter: DateComponentsFormatter
+    let preferredCarbUnit: HKUnit
+
+    public init(food: StoredFavoriteFood, foodToConfirmDeleteId: Binding<String?>, onFoodTap: @escaping (StoredFavoriteFood) -> Void, onFoodDelete: @escaping (StoredFavoriteFood) -> Void, carbFormatter: QuantityFormatter, absorptionTimeFormatter: DateComponentsFormatter, preferredCarbUnit: HKUnit = .gram()) {
+        self.food = food
+        self._foodToConfirmDeleteId = foodToConfirmDeleteId
+        self.onTap = onFoodTap
+        self.onDelete = onFoodDelete
+        self.carbFormatter = carbFormatter
+        self.absorptionTimeFormatter = absorptionTimeFormatter
+        self.preferredCarbUnit = preferredCarbUnit
+    }
+    
+    public var body: some View {
+        let isEditing = editMode?.wrappedValue == .active
+        let isConfirmingDelete = foodToConfirmDeleteId == food.id
+        
+        HStack(spacing: 0) {
+            if isEditing {
+                deleteButton
+                    .onTapGesture {
+                        if isConfirmingDelete {
+                            onDelete(food)
+                        }
+                        else {
+                            withAnimation(.easeInOut(duration: 0.3)) {
+                                foodToConfirmDeleteId = food.id
+                            }
+                        }
+                    }
+            }
+                        
+            HStack {
+                foodCardContent
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                
+                if isEditing {
+                    editBars
+                }
+                else {
+                    disclosure
+                }
+            }
+            .padding(.horizontal)
+            .padding(.vertical, 8)
+            .contentShape(Rectangle())
+            .onTapGesture {
+                onTap(food)
+            }
+        }
+    }
+}
+
+extension FavoriteFoodListRow {
+    private var foodCardContent: some View {
+        VStack(alignment: .leading, spacing: 6) {
+            Text(food.title)
+            
+            Text("\(food.carbsString(formatter: carbFormatter)) carbs, \(food.absorptionTimeString(formatter: absorptionTimeFormatter)) absorption")
+                .font(.footnote)
+        }
+        .foregroundColor(.primary)
+    }
+    
+    private var deleteButton: some View {
+        let isEditing = editMode?.wrappedValue == .active
+        let isConfirmingDelete = foodToConfirmDeleteId == food.id
+        
+        return ZStack {
+            Color.red
+                .clipShape(RoundedRectangle(cornerRadius: isConfirmingDelete ? 0 : 12.5))
+                .frame(width: isConfirmingDelete ? nil : 25, height: isConfirmingDelete ? nil : 25)
+            
+            if isConfirmingDelete {
+                Text("Delete")
+                    .foregroundColor(.white)
+            }
+            else {
+                Image(systemName: "minus")
+                    .foregroundColor(.white)
+            }
+        }
+        .frame(width: isEditing ? isConfirmingDelete ? 72 : 45 : 0, alignment: .trailing)
+        .contentShape(Rectangle())
+    }
+    
+    private var disclosure: some View {
+        Image(systemName: "chevron.forward")
+            .font(.footnote.weight(.semibold))
+            .foregroundColor(Color(UIColor.tertiaryLabel))
+    }
+    
+    private var editBars: some View {
+        Image(systemName: "line.3.horizontal")
+            .foregroundColor(Color(UIColor.tertiaryLabel))
+            .font(.title2)
+    }
+}

+ 12 - 0
Dependencies/LoopKit/LoopKitUI/Views/HUDAssets.xcassets/reservoir/generic-reservoir-mask.imageset/Contents.json

@@ -0,0 +1,12 @@
+{
+  "images" : [
+    {
+      "filename" : "generic-reservoir-fill.png",
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Dependencies/LoopKit/LoopKitUI/Views/HUDAssets.xcassets/reservoir/generic-reservoir-mask.imageset/generic-reservoir-fill.png


+ 12 - 0
Dependencies/LoopKit/LoopKitUI/Views/HUDAssets.xcassets/reservoir/generic-reservoir.imageset/Contents.json

@@ -0,0 +1,12 @@
+{
+  "images" : [
+    {
+      "filename" : "generic-reservoir-outline.png",
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Dependencies/LoopKit/LoopKitUI/Views/HUDAssets.xcassets/reservoir/generic-reservoir.imageset/generic-reservoir-outline.png


+ 21 - 0
Dependencies/LoopKit/LoopKitUI/Views/ListButtonStyle.swift

@@ -0,0 +1,21 @@
+//
+//  ListButtonStyle.swift
+//  LoopKitUI
+//
+//  Created by Noah Brauner on 7/13/23.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+
+public struct ListButtonStyle: ButtonStyle {
+    public init() { }
+    
+    public func makeBody(configuration: Configuration) -> some View {
+        configuration.label
+            .overlay(
+                Color(UIColor.tertiarySystemFill)
+                    .opacity(configuration.isPressed ? 0.5 : 0)
+            )
+    }
+}

+ 14 - 0
Dependencies/LoopKit/LoopTestingKit/DeviceAction.swift

@@ -0,0 +1,14 @@
+//
+//  DeviceAction.swift
+//  LoopTestingKit
+//
+//  Created by Nathaniel Hamming on 2023-04-19.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+public struct DeviceAction: Equatable, Codable {
+    public let managerIdentifier: String
+    public let details: String
+}

+ 20 - 0
Dependencies/LoopKit/MockKitUI/Extensions/Color.swift

@@ -0,0 +1,20 @@
+//
+//  Color.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+
+private class FrameworkBundle {
+    static let main = Bundle(for: FrameworkBundle.self)
+}
+
+extension Color {
+    init?(frameworkColor name: String) {
+        self.init(name, bundle: FrameworkBundle.main)
+    }
+}
+

+ 60 - 0
Dependencies/LoopKit/MockKitUI/Extensions/HKUnit.swift

@@ -0,0 +1,60 @@
+//
+//  HKUnit.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import HealthKit
+
+extension HKUnit {
+    static let milligramsPerDeciliter: HKUnit = {
+        return HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci))
+    }()
+
+    static let millimolesPerLiter: HKUnit = {
+        return HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter())
+    }()
+
+    static let milligramsPerDeciliterPerMinute: HKUnit = {
+        return HKUnit.milligramsPerDeciliter.unitDivided(by: .minute())
+    }()
+
+    static let millimolesPerLiterPerMinute: HKUnit = {
+        return HKUnit.millimolesPerLiter.unitDivided(by: .minute())
+    }()
+
+    static let internationalUnitsPerHour: HKUnit = {
+        return HKUnit.internationalUnit().unitDivided(by: .hour())
+    }()
+
+    static let gramsPerUnit: HKUnit = {
+        return HKUnit.gram().unitDivided(by: .internationalUnit())
+    }()
+    
+    var foundationUnit: Unit? {
+        if self == HKUnit.milligramsPerDeciliter {
+            return UnitConcentrationMass.milligramsPerDeciliter
+        }
+
+        if self == HKUnit.millimolesPerLiter {
+            return UnitConcentrationMass.millimolesPerLiter(withGramsPerMole: HKUnitMolarMassBloodGlucose)
+        }
+
+        if self == HKUnit.gram() {
+            return UnitMass.grams
+        }
+
+        return nil
+    }
+    
+    /// The smallest value expected to be visible on a chart
+    var chartableIncrement: Double {
+        if self == .milligramsPerDeciliter {
+            return 1
+        } else {
+            return 1 / 25
+        }
+    }
+}

+ 20 - 0
Dependencies/LoopKit/MockKitUI/Extensions/Image.swift

@@ -0,0 +1,20 @@
+//
+//  Image.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+
+private class FrameworkBundle {
+    static let main = Bundle(for: FrameworkBundle.self)
+}
+
+extension Image {
+    init(frameworkImage name: String) {
+        self.init(name, bundle: FrameworkBundle.main)
+    }
+}
+

+ 6 - 0
Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Colors/Contents.json

@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 20 - 0
Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Colors/LightGrey.colorset/Contents.json

@@ -0,0 +1,20 @@
+{
+  "colors" : [
+    {
+      "color" : {
+        "color-space" : "srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0.949",
+          "green" : "0.949",
+          "red" : "0.949"
+        }
+      },
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 6 - 0
Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Reservoir/Contents.json

@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 12 - 0
Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Reservoir/generic-reservoir-mask.imageset/Contents.json

@@ -0,0 +1,12 @@
+{
+  "images" : [
+    {
+      "filename" : "generic-reservoir-fill.png",
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Reservoir/generic-reservoir-mask.imageset/generic-reservoir-fill.png


+ 12 - 0
Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Reservoir/generic-reservoir.imageset/Contents.json

@@ -0,0 +1,12 @@
+{
+  "images" : [
+    {
+      "filename" : "generic-reservoir-outline.png",
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Dependencies/LoopKit/MockKitUI/Resources/Assets.xcassets/Reservoir/generic-reservoir.imageset/generic-reservoir-outline.png


+ 119 - 0
Dependencies/LoopKit/MockKitUI/ViewModel/MockCGMManagerSettingsViewModel.swift

@@ -0,0 +1,119 @@
+//
+//  MockCGMManagerSettingsViewModel.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import Combine
+import HealthKit
+import LoopKit
+import LoopKitUI
+import MockKit
+
+class MockCGMManagerSettingsViewModel: ObservableObject {
+    
+    let cgmManager: MockCGMManager
+    
+    var displayGlucosePreference: DisplayGlucosePreference
+
+    static private let dateTimeFormatter: DateFormatter = {
+        let timeFormatter = DateFormatter()
+        timeFormatter.dateStyle = .short
+        timeFormatter.timeStyle = .short
+        return timeFormatter
+    }()
+    
+    var sensorInsertionDateTimeString: String {
+        Self.dateTimeFormatter.string(from: Date().addingTimeInterval(sensorInsertionInterval))
+    }
+
+    var sensorExpirationRemaining = TimeInterval(days: 5.0)
+    var sensorInsertionInterval = TimeInterval(days: -5.0)
+    var sensorExpirationPercentComplete: Double = 0.25
+
+    var sensorExpirationDateTimeString: String {
+        Self.dateTimeFormatter.string(from: Date().addingTimeInterval(sensorExpirationRemaining))
+    }
+    
+    @Published private(set) var lastGlucoseValueWithUnitFormatted: String?
+    
+    @Published private(set) var lastGlucoseValueFormatted: String = "---"
+    
+    var glucoseUnitString: String {
+        displayGlucosePreference.unit.shortLocalizedUnitString()
+    }
+    
+    @Published private(set) var lastGlucoseDate: Date? {
+        didSet {
+            updateLastReadingTime()
+        }
+    }
+    
+    @Published var lastReadingMinutesFromNow: Int = 0
+    
+    func updateLastReadingTime() {
+        guard let lastGlucoseDate = lastGlucoseDate else {
+            lastReadingMinutesFromNow = 0
+            return
+        }
+        lastReadingMinutesFromNow = Int(Date().timeIntervalSince(lastGlucoseDate).minutes)
+    }
+    
+    @Published private(set) var lastGlucoseTrend: GlucoseTrend?
+    
+    var lastGlucoseDateFormatted: String? {
+        guard let lastGlucoseDate = lastGlucoseDate else {
+            return nil
+        }
+        return Self.dateTimeFormatter.string(from: lastGlucoseDate)
+    }
+    
+    @Published private(set) var lastGlucoseTrendFormatted: String?
+    
+    init(cgmManager: MockCGMManager, displayGlucosePreference: DisplayGlucosePreference) {
+        self.cgmManager = cgmManager
+        self.displayGlucosePreference = displayGlucosePreference
+                
+        lastGlucoseDate = cgmManager.cgmManagerStatus.lastCommunicationDate
+        lastGlucoseTrend = cgmManager.mockSensorState.trendType
+        setLastGlucoseTrend(cgmManager.mockSensorState.trendRate)
+        setLastGlucoseValue(cgmManager.mockSensorState.currentGlucose)
+        
+        cgmManager.addStatusObserver(self, queue: .main)
+    }
+    
+    func setLastGlucoseTrend(_ trendRate: HKQuantity?) {
+        guard let trendRate = trendRate else {
+            lastGlucoseTrendFormatted = nil
+            return
+        }
+        let glucoseUnitPerMinute = displayGlucosePreference.unit.unitDivided(by: .minute())
+        lastGlucoseTrendFormatted = displayGlucosePreference.formatMinuteRate(trendRate)
+    }
+    
+    func setLastGlucoseValue(_ lastGlucose: HKQuantity?) {
+        guard let lastGlucose = lastGlucose else {
+            lastGlucoseValueWithUnitFormatted = nil
+            lastGlucoseValueFormatted = "---"
+            return
+        }
+
+        lastGlucoseValueWithUnitFormatted = displayGlucosePreference.format(lastGlucose)
+        lastGlucoseValueFormatted = displayGlucosePreference.format(lastGlucose, includeUnit: false)
+    }
+}
+
+extension MockCGMManagerSettingsViewModel: CGMManagerStatusObserver {
+    func cgmManager(_ manager: LoopKit.CGMManager, didUpdate status: LoopKit.CGMManagerStatus) {
+        lastGlucoseDate = status.lastCommunicationDate
+
+        lastGlucoseTrend = cgmManager.mockSensorState.trendType
+        
+        setLastGlucoseTrend(cgmManager.mockSensorState.trendRate)
+        
+        setLastGlucoseValue(cgmManager.mockSensorState.currentGlucose)
+    }
+}

+ 175 - 0
Dependencies/LoopKit/MockKitUI/ViewModel/MockPumpManagerSettingsViewModel.swift

@@ -0,0 +1,175 @@
+//
+//  MockPumpManagerSettingsViewModel.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import LoopKit
+import MockKit
+
+class MockPumpManagerSettingsViewModel: ObservableObject {
+    let pumpManager: MockPumpManager
+    
+    @Published private(set) var isDeliverySuspended: Bool {
+        didSet {
+            transitioningSuspendResumeInsulinDelivery = false
+            basalDeliveryState = pumpManager.status.basalDeliveryState
+        }
+    }
+    
+    @Published private(set) var transitioningSuspendResumeInsulinDelivery = false
+    
+    @Published private(set) var suspendedAtString: String? = nil
+    
+    var suspendResumeInsulinDeliveryLabel: String {
+        if isDeliverySuspended {
+            return "Tap to Resume Insulin Delivery"
+        } else {
+            return "Suspend Insulin Delivery"
+        }
+    }
+    
+    static private let dateTimeFormatter: DateFormatter = {
+        let timeFormatter = DateFormatter()
+        timeFormatter.dateStyle = .short
+        timeFormatter.timeStyle = .short
+        return timeFormatter
+    }()
+    
+    static private let shortTimeFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.dateStyle = .none
+        formatter.timeStyle = .short
+        return formatter
+    }()
+    
+    private var pumpPairedInterval: TimeInterval {
+        pumpExpirationRemaing - pumpLifeTime
+    }
+    
+    var lastPumpPairedDateTimeString: String {
+        Self.dateTimeFormatter.string(from: Date().addingTimeInterval(pumpPairedInterval))
+    }
+
+    private let pumpExpirationRemaing = TimeInterval(days: 2.0)
+    private let pumpLifeTime = TimeInterval(days: 3.0)
+    var pumpExpirationPercentComplete: Double {
+        (pumpLifeTime - pumpExpirationRemaing) / pumpLifeTime
+    }
+
+    var pumpExpirationDateTimeString: String {
+        Self.dateTimeFormatter.string(from: Date().addingTimeInterval(pumpExpirationRemaing))
+    }
+    
+    var pumpTimeString: String {
+        Self.shortTimeFormatter.string(from: Date())
+    }
+    
+    @Published private(set) var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? {
+        didSet {
+            setSuspenededAtString()
+        }
+    }
+
+    @Published private(set) var basalDeliveryRate: Double?
+
+    @Published private(set) var presentDeliveryWarning: Bool?
+    
+    var isScheduledBasal: Bool {
+        switch basalDeliveryState {
+        case .active, .initiatingTempBasal:
+            return true
+        case .tempBasal, .cancelingTempBasal, .suspending, .suspended, .resuming, .none:
+            return false
+        }
+    }
+    
+    var isTempBasal: Bool {
+        switch basalDeliveryState {
+        case .tempBasal, .cancelingTempBasal:
+            return true
+        case .active, .initiatingTempBasal, .suspending, .suspended, .resuming, .none:
+            return false
+        }
+    }
+    
+    init(pumpManager: MockPumpManager) {
+        self.pumpManager = pumpManager
+        
+        isDeliverySuspended = pumpManager.status.basalDeliveryState?.isSuspended == true
+        basalDeliveryState = pumpManager.status.basalDeliveryState
+        basalDeliveryRate = pumpManager.state.basalDeliveryRate(at: Date())
+        setSuspenededAtString()
+        
+        pumpManager.addStateObserver(self, queue: .main)
+    }
+    
+    private func setSuspenededAtString() {
+        switch basalDeliveryState {
+        case .suspended(let suspendedAt):
+            let formatter = DateFormatter()
+            formatter.dateStyle = .medium
+            formatter.timeStyle = .short
+            formatter.doesRelativeDateFormatting = true
+            suspendedAtString = formatter.string(from: suspendedAt)
+        default:
+            suspendedAtString = nil
+        }
+    }
+    
+    func resumeDelivery(completion: @escaping (Error?) -> Void) {
+        transitioningSuspendResumeInsulinDelivery = true
+        pumpManager.resumeDelivery() { [weak self] error in
+            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+                if error == nil {
+                    self?.isDeliverySuspended = false
+                }
+                completion(error)
+            }
+        }
+    }
+    
+    func suspendDelivery(completion: @escaping (Error?) -> Void) {
+        transitioningSuspendResumeInsulinDelivery = true
+        pumpManager.suspendDelivery() { [weak self] error in
+            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+                if error == nil {
+                    self?.isDeliverySuspended = true
+                }
+                completion(error)
+            }
+        }
+    }
+}
+
+extension MockPumpManagerSettingsViewModel: MockPumpManagerStateObserver {
+    func mockPumpManager(_ manager: MockKit.MockPumpManager, didUpdate state: MockKit.MockPumpManagerState) {
+        guard !transitioningSuspendResumeInsulinDelivery else { return }
+        basalDeliveryRate = state.basalDeliveryRate(at: Date())
+        basalDeliveryState = manager.status.basalDeliveryState
+    }
+    
+    func mockPumpManager(_ manager: MockKit.MockPumpManager, didUpdate status: LoopKit.PumpManagerStatus, oldStatus: LoopKit.PumpManagerStatus) {
+        guard !transitioningSuspendResumeInsulinDelivery else { return }
+        basalDeliveryRate = manager.state.basalDeliveryRate(at: Date())
+        basalDeliveryState = status.basalDeliveryState
+    }
+}
+ 
+extension MockPumpManagerState {
+    func basalDeliveryRate(at now: Date) -> Double? {
+        switch suspendState {
+        case .resumed:
+            if let tempBasal = unfinalizedTempBasal, !tempBasal.isFinished(at: now) {
+                return tempBasal.rate
+            } else {
+                return basalRateSchedule?.value(at: now)
+            }
+        case .suspended:
+            return nil
+        }
+    }
+}

+ 51 - 0
Dependencies/LoopKit/MockKitUI/ViewModifier/OpenMockCGMSettingsOnLongPressGesture.swift

@@ -0,0 +1,51 @@
+//
+//  OpenMockCGMSettingsOnLongPressGesture.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import LoopKitUI
+import MockKit
+
+extension View {
+    func openMockCGMSettingsOnLongPress(enabled: Bool = true, minimumDuration: Double = 5, cgmManager: MockCGMManager, displayGlucosePreference: DisplayGlucosePreference) -> some View {
+        modifier(OpenMockCGMSettingsOnLongPressGesture(enabled: enabled, minimumDuration: minimumDuration, cgmManager: cgmManager, displayGlucosePreference: displayGlucosePreference))
+    }
+}
+
+fileprivate struct OpenMockCGMSettingsOnLongPressGesture: ViewModifier {
+    private let enabled: Bool
+    private let minimumDuration: TimeInterval
+    private let cgmManager: MockCGMManager
+    private let displayGlucosePreference: DisplayGlucosePreference
+    @State private var mockCGMSettingsDisplayed = false
+
+    init(enabled: Bool, minimumDuration: Double, cgmManager: MockCGMManager, displayGlucosePreference: DisplayGlucosePreference) {
+        self.enabled = enabled
+        self.minimumDuration = minimumDuration
+        self.cgmManager = cgmManager
+        self.displayGlucosePreference = displayGlucosePreference
+    }
+
+    func body(content: Content) -> some View {
+        modifiedContent(content: content)
+    }
+    
+    func modifiedContent(content: Content) -> some View {
+        ZStack {
+            content
+                .onLongPressGesture(minimumDuration: minimumDuration) {
+                    mockCGMSettingsDisplayed = true
+                }
+            NavigationLink(destination: MockCGMManagerControlsView(cgmManager: cgmManager, displayGlucosePreference: displayGlucosePreference), isActive: $mockCGMSettingsDisplayed) {
+                EmptyView()
+            }
+            .opacity(0) // <- Hides the Chevron
+            .buttonStyle(PlainButtonStyle())
+            .disabled(true)
+        }
+    }
+}

+ 51 - 0
Dependencies/LoopKit/MockKitUI/ViewModifier/OpenMockPumpSettingsOnLongPressGesture.swift

@@ -0,0 +1,51 @@
+//
+//  OpenMockPumpSettingsOnLongPressGesture.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import LoopKit
+import MockKit
+
+extension View {
+    func openMockPumpSettingsOnLongPress(enabled: Bool = true, minimumDuration: Double = 5, pumpManager: MockPumpManager, supportedInsulinTypes: [InsulinType]) -> some View {
+        modifier(OpenMockPumpSettingsOnLongPressGesture(enabled: enabled, minimumDuration: minimumDuration, pumpManager: pumpManager, supportedInsulinTypes: supportedInsulinTypes))
+    }
+}
+
+fileprivate struct OpenMockPumpSettingsOnLongPressGesture: ViewModifier {
+    private let enabled: Bool
+    private let minimumDuration: TimeInterval
+    private let pumpManager: MockPumpManager
+    private let supportedInsulinTypes: [InsulinType]
+    @State private var mockPumpSettingsDisplayed = false
+
+    init(enabled: Bool, minimumDuration: Double, pumpManager: MockPumpManager, supportedInsulinTypes: [InsulinType]) {
+        self.enabled = enabled
+        self.minimumDuration = minimumDuration
+        self.pumpManager = pumpManager
+        self.supportedInsulinTypes = supportedInsulinTypes
+    }
+
+    func body(content: Content) -> some View {
+        modifiedContent(content: content)
+    }
+    
+    func modifiedContent(content: Content) -> some View {
+        ZStack {
+            content
+                .onLongPressGesture(minimumDuration: minimumDuration) {
+                    mockPumpSettingsDisplayed = true
+                }
+            NavigationLink(destination: MockPumpManagerControlsView(pumpManager: pumpManager, supportedInsulinTypes: supportedInsulinTypes), isActive: $mockPumpSettingsDisplayed) {
+                EmptyView()
+            }
+            .opacity(0) // <- Hides the Chevron
+            .buttonStyle(PlainButtonStyle())
+            .disabled(true)
+        }
+    }
+}

+ 177 - 0
Dependencies/LoopKit/MockKitUI/Views/InsulinStatusView.swift

@@ -0,0 +1,177 @@
+//
+//  InsulinStatusView.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import HealthKit
+import LoopKit
+
+struct InsulinStatusView: View {
+    @Environment(\.guidanceColors) var guidanceColors
+    @Environment(\.insulinTintColor) var insulinTintColor
+
+    @ObservedObject var viewModel: MockPumpManagerSettingsViewModel
+
+    private let subViewSpacing: CGFloat = 14
+
+    var body: some View {
+        HStack(alignment: .top, spacing: 0) {
+            deliveryStatus
+                .fixedSize(horizontal: true, vertical: true)
+            Spacer()
+            Divider()
+                .frame(height: dividerHeight)
+                .offset(y:3)
+            Spacer()
+            reservoirStatus
+                .fixedSize(horizontal: true, vertical: true)
+        }
+    }
+
+    private var dividerHeight: CGFloat {
+        guard inNoDelivery == false else {
+            return 65 + subViewSpacing-10
+        }
+
+        return 65 + subViewSpacing
+    }
+
+    let basalRateFormatter = QuantityFormatter(for: .internationalUnitsPerHour)
+    let reservoirVolumeFormatter = QuantityFormatter(for: .internationalUnit())
+
+    private var inNoDelivery: Bool {
+        !viewModel.isDeliverySuspended && viewModel.basalDeliveryRate == nil
+    }
+
+    private var deliveryStatusSpacing: CGFloat {
+        return subViewSpacing
+    }
+
+    var deliveryStatus: some View {
+        VStack(alignment: .leading, spacing: deliveryStatusSpacing) {
+            Text(deliverySectionTitle)
+                .foregroundColor(.secondary)
+                .fixedSize(horizontal: false, vertical: true)
+            if viewModel.isDeliverySuspended {
+                insulinSuspended
+            } else if let basalRate = viewModel.basalDeliveryRate {
+                basalRateView(basalRate)
+            } else {
+                noDelivery
+            }
+        }
+    }
+
+    var insulinSuspended: some View {
+        HStack(alignment: .center, spacing: 2) {
+            Image(systemName: "pause.circle.fill")
+                .font(.system(size: 34))
+                .fixedSize()
+                .foregroundColor(guidanceColors.warning)
+            Text("Insulin\nSuspended")
+                .font(.system(size: 14, weight: .heavy, design: .default))
+                .lineSpacing(0.01)
+                .fixedSize()
+        }
+    }
+
+    private func basalRateView(_ basalRate: Double) -> some View {
+        HStack(alignment: .center) {
+            VStack(alignment: .leading) {
+                HStack(alignment: .lastTextBaseline, spacing: 3) {
+                    let unit = HKUnit.internationalUnitsPerHour
+                    let quantity = HKQuantity(unit: unit, doubleValue: basalRate)
+                    if viewModel.presentDeliveryWarning == true {
+                        Image(systemName: "exclamationmark.circle.fill")
+                            .foregroundColor(guidanceColors.warning)
+                            .font(.system(size: 28))
+                            .fixedSize()
+                    }
+                    Text(basalRateFormatter.string(from: quantity, includeUnit: false) ?? "")
+                        .font(.system(size: 28))
+                        .fontWeight(.heavy)
+                        .fixedSize()
+                    Text(basalRateFormatter.localizedUnitStringWithPlurality(forQuantity: quantity))
+                        .foregroundColor(.secondary)
+                }
+                Group {
+                    if viewModel.isScheduledBasal {
+                        Text("Scheduled\(String.nonBreakingSpace)Basal")
+                    } else if viewModel.isTempBasal {
+                        Text("Temporary\(String.nonBreakingSpace)Basal")
+                    }
+                }
+                .font(.footnote)
+                .foregroundColor(.accentColor)
+            }
+        }
+    }
+
+    var noDelivery: some View {
+        HStack(alignment: .center, spacing: 2) {
+            Image(systemName: "xmark.circle.fill")
+                .font(.system(size: 34))
+                .fixedSize()
+                .foregroundColor(guidanceColors.critical)
+            Text("No\nDelivery")
+                .font(.system(size: 16, weight: .heavy, design: .default))
+                .lineSpacing(0.01)
+                .fixedSize()
+        }
+    }
+
+    var deliverySectionTitle: String {
+        LocalizedString("Insulin\(String.nonBreakingSpace)Delivery", comment: "Title of insulin delivery section")
+    }
+
+    private var reservoirStatusSpacing: CGFloat {
+        subViewSpacing
+    }
+
+    var reservoirStatus: some View {
+        VStack(alignment: .trailing) {
+            VStack(alignment: .leading, spacing: reservoirStatusSpacing) {
+                Text("Insulin\(String.nonBreakingSpace)Remaining")
+                    .foregroundColor(Color(UIColor.secondaryLabel))
+                HStack {
+                    reservoirLevelStatus
+                }
+            }
+        }
+    }
+
+    @ViewBuilder
+    var reservoirLevelStatus: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            HStack(alignment: .lastTextBaseline) {
+                ZStack(alignment: .center) {
+                    Image(frameworkImage: "generic-reservoir")
+                        .resizable()
+                        .foregroundColor(.accentColor)
+                        .frame(width: 26, height: 34, alignment: .bottom)
+                    Image(frameworkImage: "generic-reservoir-mask")
+                        .resizable()
+                        .foregroundColor(.accentColor)
+                        .frame(width: 23, height: 34, alignment: .bottom)
+                }
+                HStack(alignment: .firstTextBaseline, spacing: 3) {
+                    Text("50+")
+                        .font(.system(size: 28))
+                        .fontWeight(.heavy)
+                        .fixedSize()
+                    Text(reservoirVolumeFormatter.localizedUnitStringWithPlurality())
+                        .foregroundColor(.secondary)
+                }
+            }
+            Text("Estimated Reading")
+                .font(.footnote)
+                .foregroundColor(.accentColor)
+        }
+        .offset(y: -7) // the reservoir image should have tight spacing so move the view up
+        .padding(.bottom, -7)
+    }
+}

+ 37 - 0
Dependencies/LoopKit/MockKitUI/Views/MockCGMManagerControlsView.swift

@@ -0,0 +1,37 @@
+//
+//  MockCGMManagerControlsView.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import LoopKitUI
+import MockKit
+
+struct MockCGMManagerControlsView: UIViewControllerRepresentable {
+    private let cgmManager: MockCGMManager
+    private let displayGlucosePreference: DisplayGlucosePreference
+
+    init(cgmManager: MockCGMManager, displayGlucosePreference: DisplayGlucosePreference) {
+        self.cgmManager = cgmManager
+        self.displayGlucosePreference = displayGlucosePreference
+    }
+
+    final class Coordinator: NSObject {
+        private let parent: MockCGMManagerControlsView
+
+        init(_ parent: MockCGMManagerControlsView) {
+            self.parent = parent
+        }
+    }
+
+    func makeUIViewController(context: Context) -> UIViewController {
+        return MockCGMManagerSettingsViewController(cgmManager: cgmManager, displayGlucosePreference: displayGlucosePreference)
+    }
+
+    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+
+    func makeCoordinator() -> Coordinator { Coordinator(self) }
+}

+ 274 - 0
Dependencies/LoopKit/MockKitUI/Views/MockCGMManagerSettingsView.swift

@@ -0,0 +1,274 @@
+//
+//  MockCGMManagerSettingsView.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import LoopKit
+import LoopKitUI
+import MockKit
+
+struct MockCGMManagerSettingsView: View {
+    fileprivate enum PresentedAlert {
+        case resumeInsulinDeliveryError(Error)
+        case suspendInsulinDeliveryError(Error)
+    }
+    
+    @Environment(\.dismissAction) private var dismiss
+    @Environment(\.guidanceColors) private var guidanceColors
+    @Environment(\.glucoseTintColor) private var glucoseTintColor
+    @ObservedObject var viewModel: MockCGMManagerSettingsViewModel
+    
+    @State private var showSuspendOptions = false
+    @State private var presentedAlert: PresentedAlert?
+    private var displayGlucosePreference: DisplayGlucosePreference
+    private let appName: String
+    private let allowDebugFeatures : Bool
+    
+    private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
+    
+    init(cgmManager: MockCGMManager, displayGlucosePreference: DisplayGlucosePreference, appName: String, allowDebugFeatures: Bool) {
+        viewModel = MockCGMManagerSettingsViewModel(cgmManager: cgmManager, displayGlucosePreference: displayGlucosePreference)
+        self.displayGlucosePreference = displayGlucosePreference
+        self.appName = appName
+        self.allowDebugFeatures = allowDebugFeatures
+    }
+    
+    var body: some View {
+        List {
+            statusSection
+            
+            sensorSection
+            
+            lastReadingSection
+            
+            supportSection
+        }
+        .insetGroupedListStyle()
+        .navigationBarItems(trailing: doneButton)
+        .navigationBarTitle(Text("CGM Simulator"), displayMode: .large)
+        .alert(item: $presentedAlert, content: alert(for:))
+    }
+    
+    @ViewBuilder
+    private var statusSection: some View {
+        statusCardSubSection
+        
+        notificationSubSection
+        
+        if (allowDebugFeatures) {
+            settingsSubSection
+        }
+    }
+    
+    private var statusCardSubSection: some View {
+        Section {
+            VStack(spacing: 8) {
+                sensorProgressView
+                    .openMockCGMSettingsOnLongPress(enabled: true, cgmManager: viewModel.cgmManager, displayGlucosePreference: displayGlucosePreference)
+                Divider()
+                lastReadingInfo
+            }
+        }
+    }
+        
+    private var sensorProgressView: some View {
+        HStack(alignment: .center, spacing: 16) {
+            pumpImage
+            expirationArea
+                .offset(y: -3)
+        }
+    }
+    
+    private var pumpImage: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 5)
+                .fill(Color(frameworkColor: "LightGrey")!)
+                .frame(width: 77, height: 76)
+            Image(frameworkImage: "CGM Simulator")
+                .resizable()
+                .aspectRatio(contentMode: ContentMode.fit)
+                .frame(maxHeight: 70)
+                .frame(width: 70)
+        }
+    }
+    
+    private var expirationArea: some View {
+        VStack(alignment: .leading) {
+            expirationText
+                .offset(y: 4)
+            expirationTime
+                .offset(y: 10)
+            progressBar
+        }
+    }
+    
+    private var expirationText: some View {
+        Text("Sensor expires in ")
+            .font(.subheadline)
+            .foregroundColor(.secondary)
+    }
+    
+    private var expirationTime: some View {
+        HStack(alignment: .lastTextBaseline) {
+            Text("5")
+                .font(.system(size: 24, weight: .heavy, design: .default))
+            Text("days")
+                .font(.system(size: 15, weight: .regular, design: .default))
+                .foregroundColor(.secondary)
+                .offset(x: -3)
+        }
+    }
+    
+    private var progressBar: some View {
+        ProgressView(progress: viewModel.sensorExpirationPercentComplete)
+            .accentColor(glucoseTintColor)
+    }
+    
+    var lastReadingInfo: some View {
+        HStack(alignment: .lastTextBaseline) {
+            lastGlucoseReading
+                .frame(idealWidth: 100)
+            Spacer()
+            lastReadingTime
+                .onReceive(timer) { _ in
+                    // Update every second
+                    viewModel.updateLastReadingTime()
+                }
+        }
+    }
+    
+    @ViewBuilder
+    private var lastGlucoseReading: some View {
+        VStack(alignment: .leading, spacing: 5) {
+            Text("Last Reading")
+                .foregroundColor(.secondary)
+            
+            HStack(alignment: .center, spacing: 16) {
+                viewModel.lastGlucoseTrend?.filledImage
+                    .scaleEffect(1.7, anchor: .leading)
+                    .foregroundColor(glucoseTintColor)
+                HStack(alignment: .firstTextBaseline, spacing: 4) {
+                    Text(viewModel.lastGlucoseValueFormatted)
+                        .font(.title)
+                        .fontWeight(.heavy)
+                    Text(viewModel.glucoseUnitString)
+                        .foregroundColor(.secondary)
+                }
+            }
+        }
+    }
+    
+    @ViewBuilder
+    private var lastReadingTime: some View {
+        HStack(alignment: .center, spacing: 16) {
+            Image(systemName: "arrow.triangle.2.circlepath.circle.fill")
+                .scaleEffect(1.7, anchor: .leading)
+                .foregroundColor(glucoseTintColor)
+            HStack(alignment: .firstTextBaseline, spacing: 4) {
+                Text("\(viewModel.lastReadingMinutesFromNow)")
+                    .font(.title)
+                    .fontWeight(.heavy)
+                Text("min")
+                    .foregroundColor(.secondary)
+            }
+        }
+        .frame(height: 40.0)
+    }
+    
+    private var notificationSubSection: some View {
+        Section {
+            NavigationLink(destination: DemoPlaceHolderView(appName: appName)) {
+                Text("Notification Settings")
+            }
+        }
+    }
+    
+    private var settingsSubSection: some View {
+        Section {
+            NavigationLink(destination: MockCGMManagerControlsView(cgmManager: viewModel.cgmManager, displayGlucosePreference: displayGlucosePreference)) {
+                Text("Simulator Settings")
+            }
+        }
+    }
+    
+    @ViewBuilder
+    private var sensorSection: some View {
+        deviceDetailsSubSection
+
+        stopSensorSubSection
+    }
+    
+    private var deviceDetailsSubSection: some View {
+        Section(header: SectionHeader(label: "Sensor")) {
+            LabeledValueView(label: "Insertion Time", value: viewModel.sensorInsertionDateTimeString)
+            
+            LabeledValueView(label: "Sensor Expires", value: viewModel.sensorExpirationDateTimeString)
+        }
+    }
+    
+    private var stopSensorSubSection: some View {
+        Section {
+            NavigationLink(destination: DemoPlaceHolderView(appName: appName)) {
+                Text("Stop Sensor")
+                    .foregroundColor(guidanceColors.critical)
+            }
+        }
+    }
+
+    private var lastReadingSection: some View {
+        Section(header: SectionHeader(label: "Last Reading")) {
+            LabeledValueView(label: "Glucose", value: viewModel.lastGlucoseValueWithUnitFormatted)
+            LabeledValueView(label: "Time", value: viewModel.lastGlucoseDateFormatted)
+            LabeledValueView(label: "Trend", value: viewModel.lastGlucoseTrendFormatted)
+        }
+    }
+    
+    private var supportSection: some View {
+        Section(header: SectionHeader(label: "Support")) {
+            NavigationLink(destination: DemoPlaceHolderView(appName: appName)) {
+                Text("Get help with your CGM")
+            }
+        }
+    }
+    
+    private var doneButton: some View {
+        Button(LocalizedString("Done", comment: "Settings done button label"), action: dismiss)
+    }
+    
+    private func alert(for presentedAlert: PresentedAlert) -> SwiftUI.Alert {
+        switch presentedAlert {
+        case .suspendInsulinDeliveryError(let error):
+            return Alert(
+                title: Text("Failed to Suspend Insulin Delivery"),
+                message: Text(error.localizedDescription)
+            )
+        case .resumeInsulinDeliveryError(let error):
+            return Alert(
+                title: Text("Failed to Resume Insulin Delivery"),
+                message: Text(error.localizedDescription)
+            )
+        }
+    }
+}
+
+extension MockCGMManagerSettingsView.PresentedAlert: Identifiable {
+    var id: Int {
+        switch self {
+        case .resumeInsulinDeliveryError:
+            return 0
+        case .suspendInsulinDeliveryError:
+            return 1
+        }
+    }
+}
+
+struct MockCGMManagerSettingsView_Previews: PreviewProvider {
+    static var previews: some View {
+        MockCGMManagerSettingsView(cgmManager: MockCGMManager(), displayGlucosePreference: DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter), appName: "Loop", allowDebugFeatures: false)
+    }
+}
+

+ 37 - 0
Dependencies/LoopKit/MockKitUI/Views/MockPumpManagerControlsView.swift

@@ -0,0 +1,37 @@
+//
+//  MockPumpManagerControlsView.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import LoopKit
+import MockKit
+
+struct MockPumpManagerControlsView: UIViewControllerRepresentable {
+    private let pumpManager: MockPumpManager
+    private let supportedInsulinTypes: [InsulinType]
+
+    init(pumpManager: MockPumpManager, supportedInsulinTypes: [InsulinType]) {
+        self.pumpManager = pumpManager
+        self.supportedInsulinTypes = supportedInsulinTypes
+    }
+
+    final class Coordinator: NSObject {
+        private let parent: MockPumpManagerControlsView
+
+        init(_ parent: MockPumpManagerControlsView) {
+            self.parent = parent
+        }
+    }
+
+    func makeUIViewController(context: Context) -> UIViewController {
+        return MockPumpManagerSettingsViewController(pumpManager: pumpManager, supportedInsulinTypes: supportedInsulinTypes)
+    }
+
+    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+
+    func makeCoordinator() -> Coordinator { Coordinator(self) }
+}

+ 272 - 0
Dependencies/LoopKit/MockKitUI/Views/MockPumpManagerSettingsView.swift

@@ -0,0 +1,272 @@
+//
+//  MockPumpManagerSettingsView.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-05-18.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import LoopKit
+import LoopKitUI
+import MockKit
+
+struct MockPumpManagerSettingsView: View {
+    fileprivate enum PresentedAlert {
+        case resumeInsulinDeliveryError(Error)
+        case suspendInsulinDeliveryError(Error)
+    }
+    
+    @Environment(\.dismissAction) private var dismiss
+    @Environment(\.guidanceColors) private var guidanceColors
+    @Environment(\.insulinTintColor) private var insulinTintColor
+    @ObservedObject var viewModel: MockPumpManagerSettingsViewModel
+    
+    @State private var showSuspendOptions = false
+    @State private var presentedAlert: PresentedAlert?
+
+    private var supportedInsulinTypes: [InsulinType]
+    private var appName: String
+    private let allowDebugFeatures : Bool
+    private var title: String
+    
+    init(pumpManager: MockPumpManager, supportedInsulinTypes: [InsulinType], appName: String, allowDebugFeatures: Bool) {
+        viewModel = MockPumpManagerSettingsViewModel(pumpManager: pumpManager)
+        title = pumpManager.localizedTitle
+        self.supportedInsulinTypes = supportedInsulinTypes
+        self.appName = appName
+        self.allowDebugFeatures = allowDebugFeatures
+    }
+    
+    var body: some View {
+        List {
+            statusSection
+            
+            activitySection
+            
+            configurationSection
+            
+            supportSection
+        }
+        .insetGroupedListStyle()
+        .navigationBarItems(trailing: doneButton)
+        .navigationBarTitle(Text(title), displayMode: .large)
+        .alert(item: $presentedAlert, content: alert(for:))
+    }
+    
+    @ViewBuilder
+    private var statusSection: some View {
+        Section {
+            VStack(spacing: 8) {
+                pumpProgressView
+                    .openMockPumpSettingsOnLongPress(enabled: true, pumpManager: viewModel.pumpManager, supportedInsulinTypes: supportedInsulinTypes)
+                Divider()
+                insulinInfo
+            }
+        }
+    }
+    
+    private var pumpProgressView: some View {
+        HStack(alignment: .center, spacing: 16) {
+            pumpImage
+            expirationArea
+                .offset(y: -3)
+        }
+    }
+    
+    private var pumpImage: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 5)
+                .fill(Color(frameworkColor: "LightGrey")!)
+                .frame(width: 77, height: 76)
+            Image(frameworkImage: "Pump Simulator")
+                .resizable()
+                .aspectRatio(contentMode: ContentMode.fit)
+                .frame(maxHeight: 70)
+                .frame(width: 70)
+        }
+    }
+    
+    private var expirationArea: some View {
+        VStack(alignment: .leading) {
+            expirationText
+                .offset(y: 4)
+            expirationTime
+                .offset(y: 10)
+            progressBar
+        }
+    }
+    
+    private var expirationText: some View {
+        Text("Pump expires in ")
+            .font(.subheadline)
+            .foregroundColor(.secondary)
+    }
+    
+    private var expirationTime: some View {
+        HStack(alignment: .lastTextBaseline) {
+            Text("2")
+                .font(.system(size: 24, weight: .heavy, design: .default))
+            Text("days")
+                .font(.system(size: 15, weight: .regular, design: .default))
+                .foregroundColor(.secondary)
+                .offset(x: -3)
+        }
+    }
+    
+    private var progressBar: some View {
+        ProgressView(progress: viewModel.pumpExpirationPercentComplete)
+            .accentColor(insulinTintColor)
+    }
+    
+    var insulinInfo: some View {
+        InsulinStatusView(viewModel: viewModel)
+            .environment(\.guidanceColors, guidanceColors)
+            .environment(\.insulinTintColor, insulinTintColor)
+    }
+    
+    @ViewBuilder
+    private var activitySection: some View {
+
+        if (allowDebugFeatures) {
+            settingsSubSection
+        }
+
+        suspendResumeInsulinSubSection
+
+        deviceDetailsSubSection
+
+        replaceSystemComponentsSubSection
+    }
+    
+    private var suspendResumeInsulinSubSection: some View {
+        Section(header: SectionHeader(label: LocalizedString("Activity", comment: "Section header for the activity section"))) {
+            Button(action: suspendResumeTapped) {
+                HStack {
+                    Image(systemName: "pause.circle.fill")
+                        .foregroundColor(viewModel.isDeliverySuspended ? guidanceColors.warning : .accentColor)
+                    Text(viewModel.suspendResumeInsulinDeliveryLabel)
+                    Spacer()
+                    if viewModel.transitioningSuspendResumeInsulinDelivery {
+                        ActivityIndicator(isAnimating: .constant(true), style: .medium)
+                    }
+                }
+            }
+            .disabled(viewModel.transitioningSuspendResumeInsulinDelivery)
+            if viewModel.isDeliverySuspended {
+                LabeledValueView(label: LocalizedString("Suspended At", comment: "Label for suspended at field"),
+                                 value: viewModel.suspendedAtString)
+            }
+        }
+    }
+    
+    private func suspendResumeTapped() {
+        if viewModel.isDeliverySuspended {
+            viewModel.resumeDelivery() { error in
+                if let error = error {
+                    self.presentedAlert = .resumeInsulinDeliveryError(error)
+                }
+            }
+        } else {
+            viewModel.suspendDelivery() { error in
+                if let error = error {
+                    self.presentedAlert = .suspendInsulinDeliveryError(error)
+                }
+            }
+        }
+    }
+    
+    private var deviceDetailsSubSection: some View {
+        Section {
+            LabeledValueView(label: "Pump Paired", value: viewModel.lastPumpPairedDateTimeString)
+            
+            LabeledValueView(label: "Pump Expires", value: viewModel.pumpExpirationDateTimeString)
+            
+            NavigationLink(destination: DemoPlaceHolderView(appName: appName)) {
+                Text("Device Details")
+            }
+        }
+    }
+    
+    private var replaceSystemComponentsSubSection: some View {
+        Section {
+            NavigationLink(destination: DemoPlaceHolderView(appName: appName)) {
+                Text("Replace Pump")
+                    .foregroundColor(.accentColor)
+            }
+        }
+    }
+
+    private var settingsSubSection: some View {
+        Section {
+            NavigationLink(destination: MockPumpManagerControlsView(pumpManager: viewModel.pumpManager, supportedInsulinTypes: supportedInsulinTypes)) {
+                Text("Simulator Settings")
+            }
+        }
+    }
+
+    @ViewBuilder
+    private var configurationSection: some View {
+        notificationSubSection
+        
+        pumpTimeSubSection
+    }
+    
+    private var notificationSubSection: some View {
+        Section(header: SectionHeader(label: "Configuration")) {
+            NavigationLink(destination: DemoPlaceHolderView(appName: appName)) {
+                Text("Notification Settings")
+            }
+        }
+    }
+    
+    private var pumpTimeSubSection: some View {
+        Section {
+            TimeView(label: "Pump Time")
+        }
+    }
+    
+    private var supportSection: some View {
+        Section(header: SectionHeader(label: "Support")) {
+            NavigationLink(destination: DemoPlaceHolderView(appName: appName)) {
+                Text("Get help with your pump")
+            }
+        }
+    }
+    
+    private var doneButton: some View {
+        Button(LocalizedString("Done", comment: "Settings done button label"), action: dismiss)
+    }
+    
+    private func alert(for presentedAlert: PresentedAlert) -> SwiftUI.Alert {
+        switch presentedAlert {
+        case .suspendInsulinDeliveryError(let error):
+            return Alert(
+                title: Text("Failed to Suspend Insulin Delivery"),
+                message: Text(error.localizedDescription)
+            )
+        case .resumeInsulinDeliveryError(let error):
+            return Alert(
+                title: Text("Failed to Resume Insulin Delivery"),
+                message: Text(error.localizedDescription)
+            )
+        }
+    }
+}
+
+extension MockPumpManagerSettingsView.PresentedAlert: Identifiable {
+    var id: Int {
+        switch self {
+        case .resumeInsulinDeliveryError:
+            return 0
+        case .suspendInsulinDeliveryError:
+            return 1
+        }
+    }
+}
+
+struct MockPumpManagerSettingsView_Previews: PreviewProvider {
+    static var previews: some View {
+        MockPumpManagerSettingsView(pumpManager: MockPumpManager(), supportedInsulinTypes: [], appName: "Loop", allowDebugFeatures: false)
+    }
+}

+ 57 - 0
Dependencies/LoopKit/MockKitUI/Views/TimeView.swift

@@ -0,0 +1,57 @@
+//
+//  TimeView.swift
+//  MockKitUI
+//
+//  Created by Nathaniel Hamming on 2023-06-01.
+//  Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import SwiftUI
+import LoopKitUI
+
+struct TimeView: View {
+
+    @State private var currentDate = Date()
+
+    let timeOffset: TimeInterval
+
+    let timeZone: TimeZone
+    
+    let label: String
+
+    private let shortTimeFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.dateStyle = .none
+        formatter.timeStyle = .short
+        return formatter
+    }()
+
+    private var timeToDisplay: Date {
+        currentDate.addingTimeInterval(timeOffset)
+    }
+
+    private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
+
+    private var timeZoneString: String {
+        shortTimeFormatter.timeZone = timeZone
+        return shortTimeFormatter.string(from: timeToDisplay)
+    }
+    
+    init(timeOffset: TimeInterval = 0, timeZone: TimeZone = .current, label: String = "") {
+        self.timeOffset = timeOffset
+        self.timeZone = timeZone
+        self.label = label
+    }
+
+    var body: some View {
+        LabeledValueView(label: label, value: timeZoneString).onReceive(timer) { input in
+            currentDate = input
+        }
+    }
+}
+
+struct TimeView_Previews: PreviewProvider {
+    static var previews: some View {
+        TimeView(timeOffset: 0, timeZone: .current, label: "Current Time")
+    }
+}

+ 0 - 0
Dependencies/OmniBLE/Common/Bundle.swift


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio