Browse Source

Merge pull request #502 from nightscout/sync-trio-dev

v0.5.0 Public Beta Rollout
Deniz Cengiz 1 year ago
parent
commit
f445d78ac3
100 changed files with 8845 additions and 158 deletions
  1. 1 1
      .github/ISSUE_TEMPLATE/config.yml
  2. 3 3
      .github/workflows/add_identifiers.yml
  3. 1 1
      .github/workflows/add_to_project.yml
  4. 100 0
      .github/workflows/auto_version_dev.yml
  5. 6 6
      .github/workflows/build_trio.yml
  6. 2 2
      .github/workflows/create_certs.yml
  7. 1 1
      .github/workflows/stale_issues.yml
  8. 1 1
      .gitignore
  9. 3 0
      .gitmodules
  10. 1 1
      CGMBLEKit
  11. 1 1
      CODE_OF_CONDUCT.md
  12. 4 1
      Config.xcconfig
  13. 0 140
      Core_Data.xcdatamodeld/Core_Data.xcdatamodel/contents
  14. 1 0
      DanaKit
  15. 208 0
      Dependencies/G7SensorKit/G7SensorKitUI/Views/G7SettingsView.swift
  16. 55 0
      Dependencies/G7SensorKit/G7SensorKitUI/Views/G7StartupView.swift
  17. 117 0
      Dependencies/G7SensorKit/G7SensorKitUI/pl.lproj/Localizable.strings
  18. 117 0
      Dependencies/G7SensorKit/G7SensorKitUI/pt-BR.lproj/Localizable.strings
  19. 117 0
      Dependencies/G7SensorKit/G7SensorKitUI/pt-PT.lproj/Localizable.strings
  20. 118 0
      Dependencies/G7SensorKit/G7SensorKitUI/ro.lproj/Localizable.strings
  21. 117 0
      Dependencies/G7SensorKit/G7SensorKitUI/ru.lproj/Localizable.strings
  22. 117 0
      Dependencies/G7SensorKit/G7SensorKitUI/sk.lproj/Localizable.strings
  23. 117 0
      Dependencies/G7SensorKit/G7SensorKitUI/sv.lproj/Localizable.strings
  24. 117 0
      Dependencies/G7SensorKit/G7SensorKitUI/tr.lproj/Localizable.strings
  25. 117 0
      Dependencies/G7SensorKit/G7SensorKitUI/uk.lproj/Localizable.strings
  26. 117 0
      Dependencies/G7SensorKit/G7SensorKitUI/zh-Hans.lproj/Localizable.strings
  27. 87 0
      Dependencies/LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/LoopKit Example.xcscheme
  28. 76 0
      Dependencies/LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/Shared-watchOS.xcscheme
  29. 161 0
      Dependencies/LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/Shared.xcscheme
  30. 37 0
      Dependencies/LoopKit/LoopKit/DataOutputStream.swift
  31. 25 0
      Dependencies/LoopKit/LoopKit/DeviceManager/BolusActivationType.swift
  32. 38 0
      Dependencies/LoopKit/LoopKit/FavoriteFood/FavoriteFood.swift
  33. 23 0
      Dependencies/LoopKit/LoopKit/FavoriteFood/NewFavoriteFood.swift
  34. 62 0
      Dependencies/LoopKit/LoopKit/FavoriteFood/StoredFavoriteFood.swift
  35. 83 0
      Dependencies/LoopKit/LoopKit/GlucoseKit/CgmEvent.swift
  36. 229 0
      Dependencies/LoopKit/LoopKit/GlucoseKit/CgmEventStore.swift
  37. 57 0
      Dependencies/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift
  38. 576 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/DoseMath.swift
  39. 29 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/GlucosePredictionAlgorithm.swift
  40. 190 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopAlgorithm.swift
  41. 21 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopAlgorithmInput.swift
  42. 129 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopAlgorithmSettings.swift
  43. 94 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopPredictionInput.swift
  44. 49 0
      Dependencies/LoopKit/LoopKit/LoopAlgorithm/LoopPredictionOutput.swift
  45. 22 0
      Dependencies/LoopKit/LoopKit/Pluggable.swift
  46. 226 0
      Dependencies/LoopKit/LoopKit/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift
  47. 40 0
      Dependencies/LoopKit/LoopKit/RetrospectiveCorrection/RetrospectiveCorrection.swift
  48. 71 0
      Dependencies/LoopKit/LoopKit/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift
  49. 16 0
      Dependencies/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift
  50. 57 0
      Dependencies/LoopKit/LoopKit/Service/StatefulPluggable.swift
  51. 160 0
      Dependencies/LoopKit/LoopKitTests/Charts/ChartAxisValuesStaticGeneratorTests.swift
  52. 147 0
      Dependencies/LoopKit/LoopKitTests/Charts/PredictedGlucoseChartTests.swift
  53. 1491 0
      Dependencies/LoopKit/LoopKitTests/DoseMathTests.swift
  54. 16 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/far_future_high_bg_forecast.json
  55. 24 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/far_future_high_bg_forecast_after_6_hours.json
  56. 44 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/read_selected_basal_profile.json
  57. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_correct_low_at_min.json
  58. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_dropping_then_rising.json
  59. 4 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_flat_and_high.json
  60. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_high_and_falling.json
  61. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_high_and_rising.json
  62. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_in_range_and_rising.json
  63. 4 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_no_change_glucose.json
  64. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_high_end_in_range.json
  65. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_high_end_low.json
  66. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_low_end_high.json
  67. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_low_end_in_range.json
  68. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_start_very_low_end_high.json
  69. 7 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommend_temp_basal_very_low_end_in_range.json
  70. 43 0
      Dependencies/LoopKit/LoopKitTests/Fixtures/DoseMathTests/recommended_temp_start_low_end_just_above_range.json
  71. 73 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/AbsorptionTimePickerRow.swift
  72. 105 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/CarbQuantityRow.swift
  73. 150 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/DatePickerRow.swift
  74. 49 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/EmojiRow.swift
  75. 135 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/FoodTypeRow.swift
  76. 68 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/RowEmojiTextField.swift
  77. 84 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/RowTextfield.swift
  78. 55 0
      Dependencies/LoopKit/LoopKitUI/CarbKit/TextFieldRow.swift
  79. 105 0
      Dependencies/LoopKit/LoopKitUI/Charts/COBChart.swift
  80. 221 0
      Dependencies/LoopKit/LoopKitUI/Charts/CarbEffectChart.swift
  81. 26 0
      Dependencies/LoopKit/LoopKitUI/Charts/ChartConstants.swift
  82. 210 0
      Dependencies/LoopKit/LoopKitUI/Charts/DoseChart.swift
  83. 117 0
      Dependencies/LoopKit/LoopKitUI/Charts/IOBChart.swift
  84. 352 0
      Dependencies/LoopKit/LoopKitUI/Charts/PredictedGlucoseChart.swift
  85. 28 0
      Dependencies/LoopKit/LoopKitUI/Extensions/CGPoint.swift
  86. 104 0
      Dependencies/LoopKit/LoopKitUI/Extensions/ChartAxisValuesStaticGenerator.swift
  87. 144 0
      Dependencies/LoopKit/LoopKitUI/Extensions/ChartPoint.swift
  88. 73 0
      Dependencies/LoopKit/LoopKitUI/Extensions/CollectionType.swift
  89. 27 0
      Dependencies/LoopKit/LoopKitUI/Extensions/NumberFormatter+Charts.swift
  90. 56 0
      Dependencies/LoopKit/LoopKitUI/Models/ChartAxisValueDoubleLog.swift
  91. 58 0
      Dependencies/LoopKit/LoopKitUI/ViewModels/DisplayGlucosePreference.swift
  92. 122 0
      Dependencies/LoopKit/LoopKitUI/Views/ChartPointsContextFillLayer.swift
  93. 54 0
      Dependencies/LoopKit/LoopKitUI/Views/ChartPointsScatterDownTrianglesLayer.swift
  94. 117 0
      Dependencies/LoopKit/LoopKitUI/Views/ChartPointsTouchHighlightLayerViewCache.swift
  95. 52 0
      Dependencies/LoopKit/LoopKitUI/Views/DateAndDurationTableViewCell.swift
  96. 74 0
      Dependencies/LoopKit/LoopKitUI/Views/DateAndDurationTableViewCell.xib
  97. 47 0
      Dependencies/LoopKit/LoopKitUI/Views/DecimalTextFieldTableViewCell.swift
  98. 50 0
      Dependencies/LoopKit/LoopKitUI/Views/DemoPlaceHolderView.swift
  99. 122 0
      Dependencies/LoopKit/LoopKitUI/Views/FavoriteFoodListRow.swift
  100. 0 0
      Dependencies/LoopKit/LoopKitUI/Views/ListButtonStyle.swift

+ 1 - 1
.github/ISSUE_TEMPLATE/config.yml

@@ -1,5 +1,5 @@
 blank_issues_enabled: false
 blank_issues_enabled: false
 contact_links:
 contact_links:
   - name: "🆘 Individual troubleshooting help: Please go to the Discord Trio Server"
   - name: "🆘 Individual troubleshooting help: Please go to the Discord Trio Server"
-    url: https://discord.com/invite/FnwFEFUwXE
+    url: https://discord.triodocs.org
     about: Are you having an issue with your individual setup? Please first go to the Discord Trio Server and post there, with details of your setup (App version, pump, CGM, and CGM app) and the issue you are observing
     about: Are you having an issue with your individual setup? Please first go to the Discord Trio Server and post there, with details of your setup (App version, pump, CGM, and CGM app) and the issue you are observing

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

@@ -8,7 +8,7 @@ jobs:
     name: Validate
     name: Validate
     uses: ./.github/workflows/validate_secrets.yml
     uses: ./.github/workflows/validate_secrets.yml
     secrets: inherit
     secrets: inherit
-  
+
   identifiers:
   identifiers:
     name: Add Identifiers
     name: Add Identifiers
     needs: validate
     needs: validate
@@ -17,7 +17,7 @@ jobs:
       # Checks-out the repo
       # Checks-out the repo
       - name: Checkout Repo
       - name: Checkout Repo
         uses: actions/checkout@v4
         uses: actions/checkout@v4
-      
+
       # Patch Fastlane Match to not print tables
       # Patch Fastlane Match to not print tables
       - name: Patch Match Tables
       - name: Patch Match Tables
         run: |
         run: |
@@ -36,7 +36,7 @@ jobs:
       # Sync the GitHub runner clock with the Windows time server (workaround as suggested in https://github.com/actions/runner/issues/2996)
       # Sync the GitHub runner clock with the Windows time server (workaround as suggested in https://github.com/actions/runner/issues/2996)
       - name: Sync clock
       - name: Sync clock
         run: sudo sntp -sS time.windows.com
         run: sudo sntp -sS time.windows.com
-      
+
       # Create or update identifiers for app
       # Create or update identifiers for app
       - name: Fastlane Provision
       - name: Fastlane Provision
         run: bundle exec fastlane identifiers
         run: bundle exec fastlane identifiers

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

@@ -1,4 +1,4 @@
-name: 8. DONT RUN Add bugs to bugs project
+name: zzz [DO NOT RUN] Add Bugs to Project 'Bugs'
 
 
 on:
 on:
   issues:
   issues:

+ 100 - 0
.github/workflows/auto_version_dev.yml

@@ -0,0 +1,100 @@
+# -----------------------------------------------------------------------------
+# Workflow: `auto_version_dev.yml`
+#
+# Description:
+# This GitHub Actions workflow automatically manages and increments the
+# `APP_DEV_VERSION` defined in `Config.xcconfig` on every push to `dev` branch.
+# This version is used for internal tracking and diagnostics (e.g. in
+# Crashlytics) and follows a 4-digit semantic versioning format:
+# `MAJOR.MINOR.PATCH.FEATURE`.
+#
+# Versioning Logic:
+# - Reads the base version from `APP_VERSION = x.y.z`
+# - Reads the last internal dev version from `APP_DEV_VERSION`
+#
+# Behavior:
+# - If `APP_DEV_VERSION` matches `APP_VERSION` (e.g. both are `0.5.0`),
+#   it assumes the first dev push after a release and sets `APP_DEV_VERSION`
+#   to `APP_VERSION.1` (e.g. `0.5.0.1`)
+# - If `APP_DEV_VERSION` is already in 4-digit form (e.g. `0.5.0.3`),
+#   it increments the fourth digit (e.g. → `0.5.0.4`)
+#
+# Example Progression:
+# - Release sets `APP_VERSION = 0.5.0`, `APP_DEV_VERSION = 0.5.0`
+# - First push to `dev`:      → `APP_DEV_VERSION = 0.5.0.1`
+# - Second push to `dev`:     → `APP_DEV_VERSION = 0.5.0.2`
+# - ...
+#
+# The updated value is committed and pushed back to the `dev` branch.
+#
+# Prerequisites:
+# - `APP_VERSION` must be present in `Config.xcconfig` in the form `x.y.z`
+# - `APP_DEV_VERSION` must either match `APP_VERSION` or be `x.y.z.w`
+# - GitHub Actions must have write permission to push to `dev`
+# - This workflow only runs when the repository owner is `nightscout`
+# -----------------------------------------------------------------------------
+
+name: zzz [DO NOT RUN] Bump APP_DEV_VERSION on dev push
+
+on:
+  push:
+    branches:
+      - dev
+
+jobs:
+  bump-dev-version:
+    if: github.repository_owner == 'nightscout'
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@v4
+
+      - name: Set up Git
+        run: |
+          git config --global user.name "github-actions[bot]"
+          git config --global user.email "github-actions[bot]@users.noreply.github.com"
+
+      - name: Bump APP_DEV_VERSION
+        run: |
+          FILE=Config.xcconfig
+
+          # Read current APP_VERSION
+          BASE_VERSION=$(grep '^APP_VERSION' "$FILE" | cut -d '=' -f2 | xargs)
+
+          # Read existing APP_DEV_VERSION, if any
+          DEV_LINE=$(grep '^APP_DEV_VERSION' "$FILE" || echo "")
+          if [ -z "$DEV_LINE" ]; then
+            CURRENT_DEV_VERSION="$BASE_VERSION"
+          else
+            CURRENT_DEV_VERSION=$(echo "$DEV_LINE" | cut -d '=' -f2 | xargs)
+          fi
+
+          echo "APP_VERSION       = $BASE_VERSION"
+          echo "APP_DEV_VERSION   = $CURRENT_DEV_VERSION"
+
+          # Decide next dev version
+          if [ "$CURRENT_DEV_VERSION" = "$BASE_VERSION" ]; then
+            # First post-release commit to dev → bump to .1
+            NEW_DEV_VERSION="${BASE_VERSION}.1"
+            if [ -z "$DEV_LINE" ]; then
+              echo "APP_DEV_VERSION = $NEW_DEV_VERSION" >> "$FILE"
+            else
+              sed -i -E "s|^APP_DEV_VERSION *= *.*|APP_DEV_VERSION = $NEW_DEV_VERSION|" "$FILE"
+            fi
+          else
+            # Already in .X form → bump last digit
+            IFS='.' read -r MAJOR MINOR PATCH FEATURE <<< "$CURRENT_DEV_VERSION"
+            FEATURE=$((FEATURE + 1))
+            NEW_DEV_VERSION="$MAJOR.$MINOR.$PATCH.$FEATURE"
+            sed -i -E "s|^APP_DEV_VERSION *= *.*|APP_DEV_VERSION = $NEW_DEV_VERSION|" "$FILE"
+          fi
+
+          echo "NEW APP_DEV_VERSION = $NEW_DEV_VERSION"
+          echo "NEW_DEV_VERSION=$NEW_DEV_VERSION" >> $GITHUB_ENV
+
+      - name: Commit and push updated dev version
+        run: |
+          git add Config.xcconfig
+          git commit -m "CI: Bump APP_DEV_VERSION to $NEW_DEV_VERSION"
+          git push

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

@@ -10,7 +10,7 @@ on:
     - cron: "0 8 * * 3" # Checks for updates at 08:00 UTC every Wednesday
     - cron: "0 8 * * 3" # Checks for updates at 08:00 UTC every Wednesday
     - cron: "0 6 1 * *" # Builds the app on the 1st of every month at 06:00 UTC
     - cron: "0 6 1 * *" # Builds the app on the 1st of every month at 06:00 UTC
 
 
-env:  
+env:
   UPSTREAM_REPO: nightscout/Trio
   UPSTREAM_REPO: nightscout/Trio
   UPSTREAM_BRANCH: ${{ github.ref_name }} # branch on upstream repository to sync from (replace with specific branch name if needed)
   UPSTREAM_BRANCH: ${{ github.ref_name }} # branch on upstream repository to sync from (replace with specific branch name if needed)
   TARGET_BRANCH: ${{ github.ref_name }} # target branch on fork to be kept in sync, and target branch on upstream to be kept alive (replace with specific branch name if needed)
   TARGET_BRANCH: ${{ github.ref_name }} # target branch on fork to be kept in sync, and target branch on upstream to be kept alive (replace with specific branch name if needed)
@@ -20,10 +20,10 @@ env:
 jobs:
 jobs:
   # Checks if Distribution certificate is present and valid, optionally nukes and
   # Checks if Distribution certificate is present and valid, optionally nukes and
   # creates new certs if the repository variable ENABLE_NUKE_CERTS == 'true'
   # creates new certs if the repository variable ENABLE_NUKE_CERTS == 'true'
-  check_certs: 
-      name: Check certificates
-      uses: ./.github/workflows/create_certs.yml
-      secrets: inherit
+  check_certs:
+    name: Check certificates
+    uses: ./.github/workflows/create_certs.yml
+    secrets: inherit
 
 
   # Checks if GH_PAT holds workflow permissions
   # Checks if GH_PAT holds workflow permissions
   # Checks for existence of alive branch; if non-existent creates it
   # Checks for existence of alive branch; if non-existent creates it
@@ -185,7 +185,7 @@ jobs:
           echo "Synchronizing your fork of <code>Trio</code> with the upstream repository <code>nightscout/Trio</code> will be skipped." >> $GITHUB_STEP_SUMMARY
           echo "Synchronizing your fork of <code>Trio</code> with the upstream repository <code>nightscout/Trio</code> will be skipped." >> $GITHUB_STEP_SUMMARY
           echo "If you want to enable automatic builds and updates for your Trio, please follow the instructions \
           echo "If you want to enable automatic builds and updates for your Trio, please follow the instructions \
               under the following path <code>Trio/fastlane/testflight.md</code>." >> $GITHUB_STEP_SUMMARY
               under the following path <code>Trio/fastlane/testflight.md</code>." >> $GITHUB_STEP_SUMMARY
-  
+
   # Builds Trio
   # Builds Trio
   build:
   build:
     name: Build
     name: Build

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

@@ -29,7 +29,7 @@ jobs:
       # Checks-out the repo
       # Checks-out the repo
       - name: Checkout Repo
       - name: Checkout Repo
         uses: actions/checkout@v4
         uses: actions/checkout@v4
-      
+
       # Patch Fastlane Match to not print tables
       # Patch Fastlane Match to not print tables
       - name: Patch Match Tables
       - name: Patch Match Tables
         run: |
         run: |
@@ -60,7 +60,7 @@ jobs:
         run: |
         run: |
           CERT_STATUS_FILE="${{ github.workspace }}/fastlane/new_certificate_needed.txt"
           CERT_STATUS_FILE="${{ github.workspace }}/fastlane/new_certificate_needed.txt"
           ENABLE_NUKE_CERTS=${{ vars.ENABLE_NUKE_CERTS }}
           ENABLE_NUKE_CERTS=${{ vars.ENABLE_NUKE_CERTS }}
-          
+
           if [ -f "$CERT_STATUS_FILE" ]; then
           if [ -f "$CERT_STATUS_FILE" ]; then
             CERT_STATUS=$(cat "$CERT_STATUS_FILE" | tr -d '\n' | tr -d '\r') # Read file content and strip newlines
             CERT_STATUS=$(cat "$CERT_STATUS_FILE" | tr -d '\n' | tr -d '\r') # Read file content and strip newlines
             echo "new_certificate_needed: $CERT_STATUS"
             echo "new_certificate_needed: $CERT_STATUS"

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

@@ -1,4 +1,4 @@
-name: 8. DONT RUN close inactive issues
+name: zzz [DO NOT RUN] Close Inactive Issues
 on:
 on:
   schedule:
   schedule:
     - cron: "30 1 * * *"
     - cron: "30 1 * * *"

+ 1 - 1
.gitignore

@@ -79,4 +79,4 @@ fastlane/screenshots
 fastlane/test_output
 fastlane/test_output
 fastlane/FastlaneRunner
 fastlane/FastlaneRunner
 
 
-ConfigOverride.xcconfig
+ConfigOverride.xcconfig

+ 3 - 0
.gitmodules

@@ -38,3 +38,6 @@
 	path = TidepoolService
 	path = TidepoolService
 	url = https://github.com/loopandlearn/TidepoolService.git
 	url = https://github.com/loopandlearn/TidepoolService.git
 	branch = trio
 	branch = trio
+[submodule "DanaKit"]
+	path = DanaKit
+	url = https://github.com/loopandlearn/DanaKit

+ 1 - 1
CGMBLEKit

@@ -1 +1 @@
-Subproject commit b786e8b5531cb08c259103c472dcd6a6752728f8
+Subproject commit cd8f6faec67b30231987b79daf0117dfcbb54741

+ 1 - 1
CODE_OF_CONDUCT.md

@@ -59,7 +59,7 @@ representative at an online or offline event.
 ## Enforcement
 ## Enforcement
 
 
 Instances of abusive, harassing, or otherwise unacceptable behavior may be
 Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported to the Discord server admins. Please join our [Discord server](http://discord.diy-trio.org) to contact
+reported to the Discord server admins. Please join our [Discord server](http://discord.triodocs.org) to contact
 them directly for any enforcement issues. All complaints will be reviewed and
 them directly for any enforcement issues. All complaints will be reviewed and
 investigated promptly and fairly.
 investigated promptly and fairly.
 
 

+ 4 - 1
Config.xcconfig

@@ -1,5 +1,6 @@
 APP_DISPLAY_NAME = Trio
 APP_DISPLAY_NAME = Trio
-APP_VERSION = 0.2.5
+APP_VERSION = 0.5.0
+APP_DEV_VERSION = 0.5.0
 APP_BUILD_NUMBER = 1
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 COPYRIGHT_NOTICE =
 DEVELOPER_TEAM = ##TEAM_ID##
 DEVELOPER_TEAM = ##TEAM_ID##
@@ -7,5 +8,7 @@ BUNDLE_IDENTIFIER = org.nightscout.$(DEVELOPMENT_TEAM).trio
 APP_ICON = trioBlack
 APP_ICON = trioBlack
 APP_URL_SCHEME = Trio
 APP_URL_SCHEME = Trio
 
 
+// Optional overrides
 #include? "../../ConfigOverride.xcconfig"
 #include? "../../ConfigOverride.xcconfig"
+#include? "../ConfigOverride.xcconfig"
 #include? "ConfigOverride.xcconfig"
 #include? "ConfigOverride.xcconfig"

+ 0 - 140
Core_Data.xcdatamodeld/Core_Data.xcdatamodel/contents

@@ -1,140 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22757" systemVersion="23E224" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
-    <entity name="BGaverages" representedClassName="BGaverages" syncable="YES" codeGenerationType="class">
-        <attribute name="average" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="average_1" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="average_7" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="average_30" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-    </entity>
-    <entity name="BGmedian" representedClassName="BGmedian" syncable="YES" codeGenerationType="class">
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="median" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="median_1" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="median_7" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="median_30" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-    </entity>
-    <entity name="Carbohydrates" representedClassName="Carbohydrates" syncable="YES" codeGenerationType="class">
-        <attribute name="carbs" optional="YES" attributeType="Decimal" defaultValueString="0"/>
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="enteredBy" optional="YES" attributeType="String"/>
-    </entity>
-    <entity name="Fat" representedClassName="Fat" syncable="YES" codeGenerationType="class">
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="enteredBy" optional="YES" attributeType="String"/>
-        <attribute name="fat" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-    </entity>
-    <entity name="HbA1c" representedClassName="HbA1c" syncable="YES" codeGenerationType="class">
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="hba1c" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="hba1c_1" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="hba1c_7" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="hba1c_30" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-    </entity>
-    <entity name="ImportError" representedClassName="ImportError" syncable="YES" codeGenerationType="class">
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="error" optional="YES" attributeType="String"/>
-    </entity>
-    <entity name="InsulinDistribution" representedClassName="InsulinDistribution" syncable="YES" codeGenerationType="class">
-        <attribute name="bolus" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="scheduledBasal" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="tempBasal" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <relationship name="insulin" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Oref0Suggestion" inverseName="computedInsulinDistribution" inverseEntity="Oref0Suggestion"/>
-    </entity>
-    <entity name="LoopStatRecord" representedClassName="LoopStatRecord" syncable="YES" codeGenerationType="class">
-        <attribute name="duration" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
-        <attribute name="end" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="interval" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
-        <attribute name="loopStatus" optional="YES" attributeType="String"/>
-        <attribute name="start" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-    </entity>
-    <entity name="Oref0Suggestion" representedClassName="Oref0Suggestion" syncable="YES" codeGenerationType="class">
-        <relationship name="computedInsulinDistribution" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InsulinDistribution" inverseName="insulin" inverseEntity="InsulinDistribution"/>
-        <relationship name="computedTDD" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TDD" inverseName="computed" inverseEntity="TDD"/>
-    </entity>
-    <entity name="Override" representedClassName="Override" syncable="YES" codeGenerationType="class">
-        <attribute name="advancedSettings" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
-        <attribute name="cr" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="duration" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
-        <attribute name="end" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="id" optional="YES" attributeType="String"/>
-        <attribute name="indefinite" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
-        <attribute name="isf" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
-        <attribute name="isfAndCr" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
-        <attribute name="isPreset" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
-        <attribute name="percentage" optional="YES" attributeType="Double" defaultValueString="100" usesScalarValueType="YES"/>
-        <attribute name="smbIsOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
-        <attribute name="smbIsScheduledOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
-        <attribute name="smbMinutes" optional="YES" attributeType="Decimal" defaultValueString="30"/>
-        <attribute name="start" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="target" optional="YES" attributeType="Decimal" defaultValueString="100"/>
-        <attribute name="uamMinutes" optional="YES" attributeType="Decimal" defaultValueString="30"/>
-    </entity>
-    <entity name="OverridePresets" representedClassName="OverridePresets" syncable="YES" codeGenerationType="class">
-        <attribute name="advancedSettings" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
-        <attribute name="cr" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="duration" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="end" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="id" optional="YES" attributeType="String"/>
-        <attribute name="indefinite" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
-        <attribute name="isf" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
-        <attribute name="isfAndCr" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
-        <attribute name="name" optional="YES" attributeType="String"/>
-        <attribute name="percentage" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
-        <attribute name="smbIsOff" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
-        <attribute name="smbIsScheduledOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
-        <attribute name="smbMinutes" optional="YES" attributeType="Decimal" defaultValueString="30"/>
-        <attribute name="start" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="target" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="uamMinutes" optional="YES" attributeType="Decimal" defaultValueString="30"/>
-    </entity>
-    <entity name="Presets" representedClassName="Presets" syncable="YES" codeGenerationType="class">
-        <attribute name="carbs" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="dish" optional="YES" attributeType="String"/>
-        <attribute name="fat" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="protein" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-    </entity>
-    <entity name="Protein" representedClassName="Protein" syncable="YES" codeGenerationType="class">
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="enteredBy" optional="YES" attributeType="String"/>
-        <attribute name="protein" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-    </entity>
-    <entity name="Readings" representedClassName="Readings" syncable="YES" codeGenerationType="class">
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="direction" optional="YES" attributeType="String"/>
-        <attribute name="glucose" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
-        <attribute name="id" optional="YES" attributeType="String"/>
-    </entity>
-    <entity name="StatsData" representedClassName="StatsData" syncable="YES" codeGenerationType="class">
-        <attribute name="lastrun" attributeType="Date" defaultDateTimeInterval="704497620" usesScalarValueType="NO"/>
-    </entity>
-    <entity name="Target" representedClassName="Target" syncable="YES" codeGenerationType="class">
-        <attribute name="current" optional="YES" attributeType="Decimal" defaultValueString="100"/>
-    </entity>
-    <entity name="TDD" representedClassName="TDD" syncable="YES" codeGenerationType="class">
-        <attribute name="tdd" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <relationship name="computed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Oref0Suggestion" inverseName="computedTDD" inverseEntity="Oref0Suggestion"/>
-    </entity>
-    <entity name="TempTargets" representedClassName="TempTargets" syncable="YES" codeGenerationType="class">
-        <attribute name="active" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="duration" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="hbt" optional="YES" attributeType="Double" defaultValueString="160" usesScalarValueType="YES"/>
-        <attribute name="id" optional="YES" attributeType="String"/>
-        <attribute name="startDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-    </entity>
-    <entity name="TempTargetsSlider" representedClassName="TempTargetsSlider" syncable="YES" codeGenerationType="class">
-        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
-        <attribute name="defaultHBT" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
-        <attribute name="duration" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
-        <attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="100" usesScalarValueType="YES"/>
-        <attribute name="hbt" optional="YES" attributeType="Double" defaultValueString="160" usesScalarValueType="YES"/>
-        <attribute name="id" optional="YES" attributeType="String" defaultValueString="empy"/>
-        <attribute name="isPreset" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
-    </entity>
-</model>

+ 1 - 0
DanaKit

@@ -0,0 +1 @@
+Subproject commit 89062b019687a61976a077293ee5a3928cf63900

+ 208 - 0
Dependencies/G7SensorKit/G7SensorKitUI/Views/G7SettingsView.swift

@@ -0,0 +1,208 @@
+//
+//  G7SettingsView.swift
+//  CGMBLEKitUI
+//
+//  Created by Pete Schwamb on 9/25/22.
+//  Copyright © 2022 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+import G7SensorKit
+import LoopKitUI
+
+struct G7SettingsView: View {
+
+    private var durationFormatter: RelativeDateTimeFormatter = {
+        let formatter = RelativeDateTimeFormatter()
+        formatter.unitsStyle = .full
+        return formatter
+    }()
+
+    @Environment(\.guidanceColors) private var guidanceColors
+    @Environment(\.glucoseTintColor) private var glucoseTintColor
+
+    var didFinish: (() -> Void)
+    var deleteCGM: (() -> Void)
+    @ObservedObject var viewModel: G7SettingsViewModel
+
+    @State private var showingDeletionSheet = false
+
+    init(didFinish: @escaping () -> Void, deleteCGM: @escaping () -> Void, viewModel: G7SettingsViewModel) {
+        self.didFinish = didFinish
+        self.deleteCGM = deleteCGM
+        self.viewModel = viewModel
+    }
+
+    private var timeFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+
+        formatter.dateStyle = .short
+        formatter.timeStyle = .short
+
+        return formatter
+    }()
+
+    var body: some View {
+        List {
+            Section() {
+                VStack {
+                    headerImage
+                    progressBar
+                }
+            }
+            if let activatedAt = viewModel.activatedAt {
+                HStack {
+                    Text(LocalizedString("Sensor Start", comment: "title for g7 settings row showing sensor start time"))
+                    Spacer()
+                    Text(timeFormatter.string(from: activatedAt))
+                        .foregroundColor(.secondary)
+                }
+                HStack {
+                    Text(LocalizedString("Sensor Expiration", comment: "title for g7 settings row showing sensor expiration time"))
+                    Spacer()
+                    Text(timeFormatter.string(from: activatedAt.addingTimeInterval(G7Sensor.lifetime)))
+                        .foregroundColor(.secondary)
+                }
+                HStack {
+                    Text(LocalizedString("Grace Period End", comment: "title for g7 settings row showing sensor grace period end time"))
+                    Spacer()
+                    Text(timeFormatter.string(from: activatedAt.addingTimeInterval(G7Sensor.lifetime + G7Sensor.gracePeriod)))
+                        .foregroundColor(.secondary)
+                }
+            }
+
+            Section(LocalizedString("Last Reading", comment: "")) {
+                LabeledValueView(label: LocalizedString("Glucose", comment: "Field label"),
+                                 value: viewModel.lastGlucoseString)
+                LabeledDateView(label: LocalizedString("Time", comment: "Field label"),
+                                date: viewModel.latestReadingTimestamp,
+                                dateFormatter: viewModel.dateFormatter)
+                LabeledValueView(label: LocalizedString("Trend", comment: "Field label"),
+                                 value: viewModel.lastGlucoseTrendString)
+            }
+
+            Section(LocalizedString("Bluetooth", comment: "")) {
+                if let name = viewModel.sensorName {
+                    HStack {
+                        Text(LocalizedString("Name", comment: "title for g7 settings row showing BLE Name"))
+                        Spacer()
+                        Text(name)
+                            .foregroundColor(.secondary)
+                    }
+                }
+                if viewModel.scanning {
+                    HStack {
+                        Text(LocalizedString("Scanning", comment: "title for g7 settings connection status when scanning"))
+                        Spacer()
+                        SwiftUI.ProgressView()
+                    }
+                } else {
+                    if viewModel.connected {
+                        Text(LocalizedString("Connected", comment: "title for g7 settings connection status when connected"))
+                    } else {
+                        HStack {
+                            Text(LocalizedString("Connecting", comment: "title for g7 settings connection status when connecting"))
+                            Spacer()
+                            SwiftUI.ProgressView()
+                        }
+                    }
+                }
+                if let lastConnect = viewModel.lastConnect {
+                    LabeledValueView(label: LocalizedString("Last Connect", comment: "title for g7 settings row showing sensor last connect time"),
+                                     value: timeFormatter.string(from: lastConnect))
+                }
+            }
+
+            Section(LocalizedString("Configuration", comment: "")) {
+                HStack {
+                    Toggle(LocalizedString("Upload Readings", comment: "title for g7 config settings to upload readings"), isOn: $viewModel.uploadReadings)
+                }
+            }
+
+            Section () {
+                if !self.viewModel.scanning {
+                    Button(LocalizedString("Scan for new sensor", comment: ""), action: {
+                        self.viewModel.scanForNewSensor()
+                    })
+                }
+
+                deleteCGMButton
+            }
+        }
+        .insetGroupedListStyle()
+        .navigationBarItems(trailing: doneButton)
+        .navigationBarTitle(LocalizedString("Dexcom G7", comment: "Navigation bar title for G7SettingsView"))
+    }
+
+    private var deleteCGMButton: some View {
+        Button(action: {
+            showingDeletionSheet = true
+        }, label: {
+            Text(LocalizedString("Delete CGM", comment: "Button label for removing CGM"))
+                .foregroundColor(.red)
+        }).actionSheet(isPresented: $showingDeletionSheet) {
+            ActionSheet(
+                title: Text("Are you sure you want to delete this CGM?"),
+                buttons: [
+                    .destructive(Text("Delete CGM")) {
+                        self.deleteCGM()
+                    },
+                    .cancel(),
+                ]
+            )
+        }
+    }
+
+    private var headerImage: some View {
+        VStack(alignment: .center) {
+            Image(frameworkImage: "g7")
+                .resizable()
+                .aspectRatio(contentMode: ContentMode.fit)
+                .frame(height: 150)
+                .padding(.horizontal)
+        }.frame(maxWidth: .infinity)
+    }
+
+    @ViewBuilder
+    private var progressBar: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            HStack(alignment: .firstTextBaseline) {
+                Text(viewModel.progressBarState.label)
+                    .font(.system(size: 17))
+                    .foregroundColor(color(for: viewModel.progressBarState.labelColor))
+
+                Spacer()
+                if let referenceDate = viewModel.progressReferenceDate {
+                    Text(durationFormatter.localizedString(for: referenceDate, relativeTo: Date()))
+                        .foregroundColor(.secondary)
+                }
+            }
+            ProgressView(value: viewModel.progressBarProgress)
+                .accentColor(color(for: viewModel.progressBarColorStyle))
+        }
+    }
+
+    private func color(for colorStyle: ColorStyle) -> Color {
+        switch colorStyle {
+        case .glucose:
+            return glucoseTintColor
+        case .warning:
+            return guidanceColors.warning
+        case .critical:
+            return guidanceColors.critical
+        case .normal:
+            return .primary
+        case .dimmed:
+            return .secondary
+        }
+    }
+
+
+    private var doneButton: some View {
+        Button("Done", action: {
+            self.didFinish()
+        })
+    }
+
+}

+ 55 - 0
Dependencies/G7SensorKit/G7SensorKitUI/Views/G7StartupView.swift

@@ -0,0 +1,55 @@
+//
+//  G7StartupView.swift
+//  CGMBLEKitUI
+//
+//  Created by Pete Schwamb on 9/24/22.
+//  Copyright © 2022 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+
+struct G7StartupView: View {
+    var didContinue: (() -> Void)?
+    var didCancel: (() -> Void)?
+
+    var body: some View {
+        VStack(alignment: .center, spacing: 20) {
+            Spacer()
+            Text(LocalizedString("Dexcom G7", comment: "Title on WelcomeView"))
+                .font(.largeTitle)
+                .fontWeight(.semibold)
+            VStack(alignment: .center) {
+                Image(frameworkImage: "g7")
+                    .resizable()
+                    .aspectRatio(contentMode: ContentMode.fit)
+                    .frame(height: 120)
+                    .padding(.horizontal)
+            }.frame(maxWidth: .infinity)
+            Text(LocalizedString("iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management.", comment: "Descriptive text on G7StartupView"))
+                .fixedSize(horizontal: false, vertical: true)
+                .foregroundColor(.secondary)
+            Spacer()
+            Button(action: { self.didContinue?() }) {
+                Text(LocalizedString("Continue", comment:"Button title for starting setup"))
+                    .actionButtonStyle(.primary)
+            }
+            Button(action: { self.didCancel?() } ) {
+                Text(LocalizedString("Cancel", comment: "Button text to cancel G7 setup")).padding(.top, 20)
+            }
+        }
+        .padding()
+        .environment(\.horizontalSizeClass, .compact)
+        .navigationBarTitle("")
+        .navigationBarHidden(true)
+    }
+}
+
+struct WelcomeView_Previews: PreviewProvider {
+    static var previews: some View {
+        NavigationView {
+            G7StartupView()
+        }
+        .previewDevice("iPod touch (7th generation)")
+    }
+}

+ 117 - 0
Dependencies/G7SensorKit/G7SensorKitUI/pl.lproj/Localizable.strings

@@ -0,0 +1,117 @@
+/* No glucose value representation (3 dashes for mg/dL) */
+"– – –" = "– – –";
+
+/* Format string for glucose trend per minute. (1: glucose value and unit) */
+"%@/min" = "%@/min";
+
+/* No comment provided by engineer. */
+"Are you sure you want to delete this CGM?" = "Are you sure you want to delete this CGM?";
+
+/* No comment provided by engineer. */
+"Bluetooth" = "Bluetooth";
+
+/* Button text to cancel G7 setup */
+"Cancel" = "Anuluj";
+
+/* No comment provided by engineer. */
+"Configuration" = "Configuration";
+
+/* title for g7 settings connection status when connected */
+"Connected" = "Połączono";
+
+/* title for g7 settings connection status when connecting */
+"Connecting" = "Łączenie";
+
+/* Button title for starting setup */
+"Continue" = "Kontynuuj";
+
+/* Button label for removing CGM */
+"Delete CGM" = "Delete CGM";
+
+/* Navigation bar title for G7SettingsView
+   Title on WelcomeView */
+"Dexcom G7" = "Dexcom G7";
+
+/* No comment provided by engineer. */
+"Done" = "Done";
+
+/* Field label */
+"Glucose" = "Glucose";
+
+/* title for g7 settings row showing sensor grace period end time */
+"Grace Period End" = "Grace Period End";
+
+/* G7 Progress bar label when sensor grace period progress showing */
+"Grace period remaining" = "Grace period remaining";
+
+/* String displayed instead of a glucose value above the CGM range */
+"HIGH" = "HIGH";
+
+/* title for g7 settings row showing sensor last connect time */
+"Last Connect" = "Last Connect";
+
+/* No comment provided by engineer. */
+"Last Reading" = "Last Reading";
+
+/* Descriptive text on G7StartupView */
+"iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management." = "iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management.";
+
+/* String displayed instead of a glucose value below the CGM range */
+"LOW" = "LOW";
+
+/* title for g7 settings row showing BLE Name */
+"Name" = "Name";
+
+/* No comment provided by engineer. */
+"Scan for new sensor" = "Scan for new sensor";
+
+/* title for g7 settings connection status when scanning */
+"Scanning" = "Scanning";
+
+/* G7 Status highlight text for searching for sensor */
+"Searching for\nSensor" = "Searching for\nSensor";
+
+/* G7 Progress bar label when searching for sensor */
+"Searching for sensor" = "Searching for sensor";
+
+/* G7 Status highlight text for sensor expired */
+"Sensor\nExpired" = "Sensor\nExpired";
+
+/* G7 Status highlight text for sensor failed */
+"Sensor\nFailed" = "Sensor\nFailed";
+
+/* G7 Status highlight text for sensor error */
+"Sensor\nIssue" = "Sensor\nIssue";
+
+/* G7 Status highlight text for sensor warmup */
+"Sensor\nWarmup" = "Sensor\nWarmup";
+
+/* title for g7 settings row showing sensor expiration time */
+"Sensor Expiration" = "Sensor Expiration";
+
+/* G7 Progress bar label when sensor expired */
+"Sensor expired" = "Sensor expired";
+
+/* G7 Progress bar label when sensor lifetime progress showing */
+"Sensor expires" = "Sensor expires";
+
+/* G7 Progress bar label when sensor failed */
+"Sensor failed" = "Sensor failed";
+
+/* title for g7 settings row showing sensor start time */
+"Sensor Start" = "Start sensor";
+
+/* G7 Status highlight text for signal loss */
+"Signal\nLoss" = "Signal\nLoss";
+
+/* Field label */
+"Time" = "Czas";
+
+/* Field label */
+"Trend" = "Trend";
+
+/* title for g7 config settings to upload readings */
+"Upload Readings" = "Upload Readings";
+
+/* G7 Progress bar label when sensor in warmup */
+"Warmup completes" = "Warmup completes";

+ 117 - 0
Dependencies/G7SensorKit/G7SensorKitUI/pt-BR.lproj/Localizable.strings

@@ -0,0 +1,117 @@
+/* No glucose value representation (3 dashes for mg/dL) */
+"– – –" = "– – –";
+
+/* Format string for glucose trend per minute. (1: glucose value and unit) */
+"%@/min" = "%@/min";
+
+/* No comment provided by engineer. */
+"Are you sure you want to delete this CGM?" = "Are you sure you want to delete this CGM?";
+
+/* No comment provided by engineer. */
+"Bluetooth" = "Bluetooth";
+
+/* Button text to cancel G7 setup */
+"Cancel" = "Cancelar";
+
+/* No comment provided by engineer. */
+"Configuration" = "Ajustes";
+
+/* title for g7 settings connection status when connected */
+"Connected" = "Conectado";
+
+/* title for g7 settings connection status when connecting */
+"Connecting" = "Conectando";
+
+/* Button title for starting setup */
+"Continue" = "Continuar";
+
+/* Button label for removing CGM */
+"Delete CGM" = "Delete CGM";
+
+/* Navigation bar title for G7SettingsView
+   Title on WelcomeView */
+"Dexcom G7" = "Dexcom G7";
+
+/* No comment provided by engineer. */
+"Done" = "OK";
+
+/* Field label */
+"Glucose" = "Glicose";
+
+/* title for g7 settings row showing sensor grace period end time */
+"Grace Period End" = "Grace Period End";
+
+/* G7 Progress bar label when sensor grace period progress showing */
+"Grace period remaining" = "Grace period remaining";
+
+/* String displayed instead of a glucose value above the CGM range */
+"HIGH" = "HIGH";
+
+/* title for g7 settings row showing sensor last connect time */
+"Last Connect" = "Last Connect";
+
+/* No comment provided by engineer. */
+"Last Reading" = "Last Reading";
+
+/* Descriptive text on G7StartupView */
+"iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management." = "iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management.";
+
+/* String displayed instead of a glucose value below the CGM range */
+"LOW" = "LOW";
+
+/* title for g7 settings row showing BLE Name */
+"Name" = "Nome";
+
+/* No comment provided by engineer. */
+"Scan for new sensor" = "Scan for new sensor";
+
+/* title for g7 settings connection status when scanning */
+"Scanning" = "Scanning";
+
+/* G7 Status highlight text for searching for sensor */
+"Searching for\nSensor" = "Searching for\nSensor";
+
+/* G7 Progress bar label when searching for sensor */
+"Searching for sensor" = "Searching for sensor";
+
+/* G7 Status highlight text for sensor expired */
+"Sensor\nExpired" = "Sensor\nExpired";
+
+/* G7 Status highlight text for sensor failed */
+"Sensor\nFailed" = "Sensor\nFailed";
+
+/* G7 Status highlight text for sensor error */
+"Sensor\nIssue" = "Sensor\nIssue";
+
+/* G7 Status highlight text for sensor warmup */
+"Sensor\nWarmup" = "Sensor\nWarmup";
+
+/* title for g7 settings row showing sensor expiration time */
+"Sensor Expiration" = "Sensor Expiration";
+
+/* G7 Progress bar label when sensor expired */
+"Sensor expired" = "Sensor expired";
+
+/* G7 Progress bar label when sensor lifetime progress showing */
+"Sensor expires" = "Sensor expires";
+
+/* G7 Progress bar label when sensor failed */
+"Sensor failed" = "Sensor failed";
+
+/* title for g7 settings row showing sensor start time */
+"Sensor Start" = "Start sensor";
+
+/* G7 Status highlight text for signal loss */
+"Signal\nLoss" = "Signal\nLoss";
+
+/* Field label */
+"Time" = "Hora";
+
+/* Field label */
+"Trend" = "Trend";
+
+/* title for g7 config settings to upload readings */
+"Upload Readings" = "Upload Readings";
+
+/* G7 Progress bar label when sensor in warmup */
+"Warmup completes" = "Warmup completes";

+ 117 - 0
Dependencies/G7SensorKit/G7SensorKitUI/pt-PT.lproj/Localizable.strings

@@ -0,0 +1,117 @@
+/* No glucose value representation (3 dashes for mg/dL) */
+"– – –" = "– – –";
+
+/* Format string for glucose trend per minute. (1: glucose value and unit) */
+"%@/min" = "%@/min";
+
+/* No comment provided by engineer. */
+"Are you sure you want to delete this CGM?" = "Are you sure you want to delete this CGM?";
+
+/* No comment provided by engineer. */
+"Bluetooth" = "Bluetooth";
+
+/* Button text to cancel G7 setup */
+"Cancel" = "Cancelar";
+
+/* No comment provided by engineer. */
+"Configuration" = "Ajustes";
+
+/* title for g7 settings connection status when connected */
+"Connected" = "Connected";
+
+/* title for g7 settings connection status when connecting */
+"Connecting" = "Connecting";
+
+/* Button title for starting setup */
+"Continue" = "Continue";
+
+/* Button label for removing CGM */
+"Delete CGM" = "Delete CGM";
+
+/* Navigation bar title for G7SettingsView
+   Title on WelcomeView */
+"Dexcom G7" = "Dexcom G7";
+
+/* No comment provided by engineer. */
+"Done" = "OK";
+
+/* Field label */
+"Glucose" = "Glucose";
+
+/* title for g7 settings row showing sensor grace period end time */
+"Grace Period End" = "Grace Period End";
+
+/* G7 Progress bar label when sensor grace period progress showing */
+"Grace period remaining" = "Grace period remaining";
+
+/* String displayed instead of a glucose value above the CGM range */
+"HIGH" = "HIGH";
+
+/* title for g7 settings row showing sensor last connect time */
+"Last Connect" = "Last Connect";
+
+/* No comment provided by engineer. */
+"Last Reading" = "Last Reading";
+
+/* Descriptive text on G7StartupView */
+"iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management." = "iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management.";
+
+/* String displayed instead of a glucose value below the CGM range */
+"LOW" = "LOW";
+
+/* title for g7 settings row showing BLE Name */
+"Name" = "Nome";
+
+/* No comment provided by engineer. */
+"Scan for new sensor" = "Scan for new sensor";
+
+/* title for g7 settings connection status when scanning */
+"Scanning" = "Scanning";
+
+/* G7 Status highlight text for searching for sensor */
+"Searching for\nSensor" = "Searching for\nSensor";
+
+/* G7 Progress bar label when searching for sensor */
+"Searching for sensor" = "Searching for sensor";
+
+/* G7 Status highlight text for sensor expired */
+"Sensor\nExpired" = "Sensor\nExpired";
+
+/* G7 Status highlight text for sensor failed */
+"Sensor\nFailed" = "Sensor\nFailed";
+
+/* G7 Status highlight text for sensor error */
+"Sensor\nIssue" = "Sensor\nIssue";
+
+/* G7 Status highlight text for sensor warmup */
+"Sensor\nWarmup" = "Sensor\nWarmup";
+
+/* title for g7 settings row showing sensor expiration time */
+"Sensor Expiration" = "Sensor Expiration";
+
+/* G7 Progress bar label when sensor expired */
+"Sensor expired" = "Sensor expired";
+
+/* G7 Progress bar label when sensor lifetime progress showing */
+"Sensor expires" = "Sensor expires";
+
+/* G7 Progress bar label when sensor failed */
+"Sensor failed" = "Sensor failed";
+
+/* title for g7 settings row showing sensor start time */
+"Sensor Start" = "Start sensor";
+
+/* G7 Status highlight text for signal loss */
+"Signal\nLoss" = "Signal\nLoss";
+
+/* Field label */
+"Time" = "Hora";
+
+/* Field label */
+"Trend" = "Trend";
+
+/* title for g7 config settings to upload readings */
+"Upload Readings" = "Upload Readings";
+
+/* G7 Progress bar label when sensor in warmup */
+"Warmup completes" = "Warmup completes";

+ 118 - 0
Dependencies/G7SensorKit/G7SensorKitUI/ro.lproj/Localizable.strings

@@ -0,0 +1,118 @@
+/* No glucose value representation (3 dashes for mg/dL) */
+"– – –" = "– – –";
+
+/* Format string for glucose trend per minute. (1: glucose value and unit) */
+"%@/min" = "/min";
+
+/* No comment provided by engineer. */
+"Are you sure you want to delete this CGM?" = "Sunteți sigur că doriți să ștergeți acest CGM?";
+
+/* No comment provided by engineer. */
+"Bluetooth" = "Bluetooth";
+
+/* Button text to cancel G7 setup */
+"Cancel" = "Renunță";
+
+/* No comment provided by engineer. */
+"Configuration" = "Configurare";
+
+/* title for g7 settings connection status when connected */
+"Connected" = "Conectat";
+
+/* title for g7 settings connection status when connecting */
+"Connecting" = "Conectare";
+
+/* Button title for starting setup */
+"Continue" = "Continuă";
+
+/* Button label for removing CGM */
+"Delete CGM" = "Ștergeți CGM";
+
+/* Navigation bar title for G7SettingsView
+   Title on WelcomeView */
+"Dexcom G7" = "Dexcom G7";
+
+/* No comment provided by engineer. */
+"Done" = "Realizat";
+
+/* Field label */
+"Glucose" = "Glucoza";
+
+/* title for g7 settings row showing sensor grace period end time */
+"Grace Period End" = "Sfârșitul perioadei de grație";
+
+/* G7 Progress bar label when sensor grace period progress showing */
+"Grace period remaining" = "Perioada de grație rămasă";
+
+/* String displayed instead of a glucose value above the CGM range */
+"HIGH" = "HIPER";
+
+/* title for g7 settings row showing sensor last connect time */
+"Last Connect" = "Ultima conectare";
+
+/* No comment provided by engineer. */
+"Last Reading" = "Ultima citire";
+
+/* Descriptive text on G7StartupView */
+"iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management." = "Loop poate citi datele G7 CGM, dar pentru cuplare, calibrare și alte activități de gestionare a senzorului, va trebui să folosiți aplicația Dexcom G7.";
+
+/* String displayed instead of a glucose value below the CGM range */
+"LOW" = "HIPO";
+
+/* title for g7 settings row showing BLE Name */
+"Name" = "Nume";
+
+/* No comment provided by engineer. */
+"Scan for new sensor" = "Scanați pentru un senzor nou";
+
+/* title for g7 settings connection status when scanning */
+"Scanning" = "Scanare";
+
+/* G7 Status highlight text for searching for sensor */
+"Searching for\nSensor" = "Detectarea senzorului";
+
+/* G7 Progress bar label when searching for sensor */
+"Searching for sensor" = "Detectarea senzorului";
+
+/* G7 Status highlight text for sensor expired */
+"Sensor\nExpired" = "Senzorul a expirat";
+
+/* G7 Status highlight text for sensor failed */
+"Sensor\nFailed" = "Senzorul a eșuat";
+
+/* G7 Status highlight text for sensor error */
+"Sensor\nIssue" = "Problemă cu senzorul";
+
+/* G7 Status highlight text for sensor warmup */
+"Sensor\nWarmup" = "Senzorul se încălzește";
+
+/* title for g7 settings row showing sensor expiration time */
+"Sensor Expiration" = "Expirarea senzorului";
+
+/* G7 Progress bar label when sensor expired */
+"Sensor expired" = "Senzorul a expirat";
+
+/* G7 Progress bar label when sensor lifetime progress showing */
+"Sensor expires" = "Senzorul expiră";
+
+/* G7 Progress bar label when sensor failed */
+"Sensor failed" = "Senzorul a eșuat";
+
+/* title for g7 settings row showing sensor start time */
+"Sensor Start" = "Pornirea senzorului";
+
+/* G7 Status highlight text for signal loss */
+"Signal\nLoss" = "Pierdere de semnal";
+
+/* Field label */
+"Time" = "Timp";
+
+/* Field label */
+"Trend" = "Tendinţă";
+
+/* title for g7 config settings to upload readings */
+"Upload Readings" = "Urcă citirile de glicemie";
+
+/* G7 Progress bar label when sensor in warmup */
+"Warmup completes" = "Încălzirea s-a încheiat";
+

+ 117 - 0
Dependencies/G7SensorKit/G7SensorKitUI/ru.lproj/Localizable.strings

@@ -0,0 +1,117 @@
+/* No glucose value representation (3 dashes for mg/dL) */
+"– – –" = "– – –";
+
+/* Format string for glucose trend per minute. (1: glucose value and unit) */
+"%@/min" = "%@/мин";
+
+/* No comment provided by engineer. */
+"Are you sure you want to delete this CGM?" = "Вы уверены, что хотите удалить текущий CGM?";
+
+/* No comment provided by engineer. */
+"Bluetooth" = "Bluetooth";
+
+/* Button text to cancel G7 setup */
+"Cancel" = "Отмена";
+
+/* No comment provided by engineer. */
+"Configuration" = "Конфигурация";
+
+/* title for g7 settings connection status when connected */
+"Connected" = "Подключено";
+
+/* title for g7 settings connection status when connecting */
+"Connecting" = "Подключение";
+
+/* Button title for starting setup */
+"Continue" = "Продолжить";
+
+/* Button label for removing CGM */
+"Delete CGM" = "Удалить CGM";
+
+/* Navigation bar title for G7SettingsView
+   Title on WelcomeView */
+"Dexcom G7" = "Dexcom G7";
+
+/* No comment provided by engineer. */
+"Done" = "Готово";
+
+/* Field label */
+"Glucose" = "Глюкоза";
+
+/* title for g7 settings row showing sensor grace period end time */
+"Grace Period End" = "Период отсрочки";
+
+/* G7 Progress bar label when sensor grace period progress showing */
+"Grace period remaining" = "Оставшийся период отсрочки";
+
+/* String displayed instead of a glucose value above the CGM range */
+"HIGH" = "ВЫСОКИЙ";
+
+/* title for g7 settings row showing sensor last connect time */
+"Last Connect" = "Последнее подключение";
+
+/* No comment provided by engineer. */
+"Last Reading" = "Последнее считывание";
+
+/* Descriptive text on G7StartupView */
+"iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management." = "iAPS может считывать G7 CGM данные, но Вы все равно должны использовать Dexcom G7 App для сопряжения, калибровки и управления датчиком.";
+
+/* String displayed instead of a glucose value below the CGM range */
+"LOW" = "НИЗКИЙ";
+
+/* title for g7 settings row showing BLE Name */
+"Name" = "Название";
+
+/* No comment provided by engineer. */
+"Scan for new sensor" = "Сканирование нового датчика";
+
+/* title for g7 settings connection status when scanning */
+"Scanning" = "Сканирование";
+
+/* G7 Status highlight text for searching for sensor */
+"Searching for\nSensor" = "Поиск\nДатчика";
+
+/* G7 Progress bar label when searching for sensor */
+"Searching for sensor" = "Поиск датчика";
+
+/* G7 Status highlight text for sensor expired */
+"Sensor\nExpired" = "Датчик\nИстек";
+
+/* G7 Status highlight text for sensor failed */
+"Sensor\nFailed" = "Датчик\nСбой";
+
+/* G7 Status highlight text for sensor error */
+"Sensor\nIssue" = "Датчик\nПроблема";
+
+/* G7 Status highlight text for sensor warmup */
+"Sensor\nWarmup" = "Датчик\nПрогрев";
+
+/* title for g7 settings row showing sensor expiration time */
+"Sensor Expiration" = "Датчик истекает";
+
+/* G7 Progress bar label when sensor expired */
+"Sensor expired" = "Срок действия датчика истек";
+
+/* G7 Progress bar label when sensor lifetime progress showing */
+"Sensor expires" = "Датчик заканчивается";
+
+/* G7 Progress bar label when sensor failed */
+"Sensor failed" = "Сбой датчика";
+
+/* title for g7 settings row showing sensor start time */
+"Sensor Start" = "Запуск датчика";
+
+/* G7 Status highlight text for signal loss */
+"Signal\nLoss" = "Сигнал\nПотерян";
+
+/* Field label */
+"Time" = "Время";
+
+/* Field label */
+"Trend" = "Тенденция";
+
+/* title for g7 config settings to upload readings */
+"Upload Readings" = "Выгружать данные";
+
+/* G7 Progress bar label when sensor in warmup */
+"Warmup completes" = "Прогрев завершается";

+ 117 - 0
Dependencies/G7SensorKit/G7SensorKitUI/sk.lproj/Localizable.strings

@@ -0,0 +1,117 @@
+/* No glucose value representation (3 dashes for mg/dL) */
+"– – –" = "– – –";
+
+/* Format string for glucose trend per minute. (1: glucose value and unit) */
+"%@/min" = "%@/min";
+
+/* No comment provided by engineer. */
+"Are you sure you want to delete this CGM?" = "Are you sure you want to delete this CGM?";
+
+/* No comment provided by engineer. */
+"Bluetooth" = "Bluetooth";
+
+/* Button text to cancel G7 setup */
+"Cancel" = "Cancel";
+
+/* No comment provided by engineer. */
+"Configuration" = "Configuration";
+
+/* title for g7 settings connection status when connected */
+"Connected" = "Pripojené";
+
+/* title for g7 settings connection status when connecting */
+"Connecting" = "Pripája sa";
+
+/* Button title for starting setup */
+"Continue" = "Pokračovať";
+
+/* Button label for removing CGM */
+"Delete CGM" = "Delete CGM";
+
+/* Navigation bar title for G7SettingsView
+   Title on WelcomeView */
+"Dexcom G7" = "Dexcom G7";
+
+/* No comment provided by engineer. */
+"Done" = "Done";
+
+/* Field label */
+"Glucose" = "Glucose";
+
+/* title for g7 settings row showing sensor grace period end time */
+"Grace Period End" = "Grace Period End";
+
+/* G7 Progress bar label when sensor grace period progress showing */
+"Grace period remaining" = "Grace period remaining";
+
+/* String displayed instead of a glucose value above the CGM range */
+"HIGH" = "HIGH";
+
+/* title for g7 settings row showing sensor last connect time */
+"Last Connect" = "Last Connect";
+
+/* No comment provided by engineer. */
+"Last Reading" = "Last Reading";
+
+/* Descriptive text on G7StartupView */
+"iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management." = "iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management.";
+
+/* String displayed instead of a glucose value below the CGM range */
+"LOW" = "LOW";
+
+/* title for g7 settings row showing BLE Name */
+"Name" = "Name";
+
+/* No comment provided by engineer. */
+"Scan for new sensor" = "Scan for new sensor";
+
+/* title for g7 settings connection status when scanning */
+"Scanning" = "Scanning";
+
+/* G7 Status highlight text for searching for sensor */
+"Searching for\nSensor" = "Searching for\nSensor";
+
+/* G7 Progress bar label when searching for sensor */
+"Searching for sensor" = "Searching for sensor";
+
+/* G7 Status highlight text for sensor expired */
+"Sensor\nExpired" = "Sensor\nExpired";
+
+/* G7 Status highlight text for sensor failed */
+"Sensor\nFailed" = "Sensor\nFailed";
+
+/* G7 Status highlight text for sensor error */
+"Sensor\nIssue" = "Sensor\nIssue";
+
+/* G7 Status highlight text for sensor warmup */
+"Sensor\nWarmup" = "Sensor\nWarmup";
+
+/* title for g7 settings row showing sensor expiration time */
+"Sensor Expiration" = "Sensor Expiration";
+
+/* G7 Progress bar label when sensor expired */
+"Sensor expired" = "Sensor expired";
+
+/* G7 Progress bar label when sensor lifetime progress showing */
+"Sensor expires" = "Sensor expires";
+
+/* G7 Progress bar label when sensor failed */
+"Sensor failed" = "Sensor failed";
+
+/* title for g7 settings row showing sensor start time */
+"Sensor Start" = "Start sensor";
+
+/* G7 Status highlight text for signal loss */
+"Signal\nLoss" = "Signal\nLoss";
+
+/* Field label */
+"Time" = "Time";
+
+/* Field label */
+"Trend" = "Trend";
+
+/* title for g7 config settings to upload readings */
+"Upload Readings" = "Upload Readings";
+
+/* G7 Progress bar label when sensor in warmup */
+"Warmup completes" = "Warmup completes";

+ 117 - 0
Dependencies/G7SensorKit/G7SensorKitUI/sv.lproj/Localizable.strings

@@ -0,0 +1,117 @@
+/* No glucose value representation (3 dashes for mg/dL) */
+"– – –" = "– – –";
+
+/* Format string for glucose trend per minute. (1: glucose value and unit) */
+"%@/min" = "%@/min";
+
+/* No comment provided by engineer. */
+"Are you sure you want to delete this CGM?" = "År du säker på att du vill ta bort denna CGM?";
+
+/* No comment provided by engineer. */
+"Bluetooth" = "Bluetooth";
+
+/* Button text to cancel G7 setup */
+"Cancel" = "Avbryt";
+
+/* No comment provided by engineer. */
+"Configuration" = "Konfiguration";
+
+/* title for g7 settings connection status when connected */
+"Connected" = "Ansluten";
+
+/* title for g7 settings connection status when connecting */
+"Connecting" = "Ansluter";
+
+/* Button title for starting setup */
+"Continue" = "Fortsätt";
+
+/* Button label for removing CGM */
+"Delete CGM" = "Radera CGM";
+
+/* Navigation bar title for G7SettingsView
+   Title on WelcomeView */
+"Dexcom G7" = "Dexcom G7";
+
+/* No comment provided by engineer. */
+"Done" = "Klar";
+
+/* Field label */
+"Glucose" = "Glukos";
+
+/* title for g7 settings row showing sensor grace period end time */
+"Grace Period End" = "Reservperiod slutar";
+
+/* G7 Progress bar label when sensor grace period progress showing */
+"Grace period remaining" = "Tid kvar av reservtid";
+
+/* String displayed instead of a glucose value above the CGM range */
+"HIGH" = "HÖGT";
+
+/* title for g7 settings row showing sensor last connect time */
+"Last Connect" = "Senaste anslutning";
+
+/* No comment provided by engineer. */
+"Last Reading" = "Senaste avläsning";
+
+/* Descriptive text on G7StartupView */
+"iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management." = "iAPS kan läsa G7 CGM-värden, men du måste alltjämt använda Dexcom G7-appen för parkoppling, kalibrering samt hantering av sensorn.";
+
+/* String displayed instead of a glucose value below the CGM range */
+"LOW" = "LÅGT";
+
+/* title for g7 settings row showing BLE Name */
+"Name" = "Namn";
+
+/* No comment provided by engineer. */
+"Scan for new sensor" = "Skanna efter ny sensor";
+
+/* title for g7 settings connection status when scanning */
+"Scanning" = "Skannar";
+
+/* G7 Status highlight text for searching for sensor */
+"Searching for\nSensor" = "Söker efter\nSensor";
+
+/* G7 Progress bar label when searching for sensor */
+"Searching for sensor" = "Söker efter sensor";
+
+/* G7 Status highlight text for sensor expired */
+"Sensor\nExpired" = "Sensor\nUtgått";
+
+/* G7 Status highlight text for sensor failed */
+"Sensor\nFailed" = "Sensor\nmisslyckades";
+
+/* G7 Status highlight text for sensor error */
+"Sensor\nIssue" = "Sensorproblem";
+
+/* G7 Status highlight text for sensor warmup */
+"Sensor\nWarmup" = "Sensor\nUppvärmning";
+
+/* title for g7 settings row showing sensor expiration time */
+"Sensor Expiration" = "Sensorns utgångsdatum";
+
+/* G7 Progress bar label when sensor expired */
+"Sensor expired" = "Sensorns livslängd är slut";
+
+/* G7 Progress bar label when sensor lifetime progress showing */
+"Sensor expires" = "Sensorn går ut";
+
+/* G7 Progress bar label when sensor failed */
+"Sensor failed" = "Sensorn misslyckades";
+
+/* title for g7 settings row showing sensor start time */
+"Sensor Start" = "Starta Sensor";
+
+/* G7 Status highlight text for signal loss */
+"Signal\nLoss" = "Signal-\nförlust";
+
+/* Field label */
+"Time" = "Tid";
+
+/* Field label */
+"Trend" = "Trend";
+
+/* title for g7 config settings to upload readings */
+"Upload Readings" = "Ladda upp blodsocker";
+
+/* G7 Progress bar label when sensor in warmup */
+"Warmup completes" = "Uppvärming av sensor";

+ 117 - 0
Dependencies/G7SensorKit/G7SensorKitUI/tr.lproj/Localizable.strings

@@ -0,0 +1,117 @@
+/* No glucose value representation (3 dashes for mg/dL) */
+"– – –" = "– – –";
+
+/* Format string for glucose trend per minute. (1: glucose value and unit) */
+"%@/min" = "%@/dak";
+
+/* No comment provided by engineer. */
+"Are you sure you want to delete this CGM?" = "Are you sure you want to delete this CGM?";
+
+/* No comment provided by engineer. */
+"Bluetooth" = "Bluetooth";
+
+/* Button text to cancel G7 setup */
+"Cancel" = "Vazgeç";
+
+/* No comment provided by engineer. */
+"Configuration" = "Yapılandırma";
+
+/* title for g7 settings connection status when connected */
+"Connected" = "Bağlandı";
+
+/* title for g7 settings connection status when connecting */
+"Connecting" = "Bağlanıyor";
+
+/* Button title for starting setup */
+"Continue" = "Devam et";
+
+/* Button label for removing CGM */
+"Delete CGM" = "CGM'i Sil";
+
+/* Navigation bar title for G7SettingsView
+   Title on WelcomeView */
+"Dexcom G7" = "Dexcom G7";
+
+/* No comment provided by engineer. */
+"Done" = "Tamam";
+
+/* Field label */
+"Glucose" = "Glikoz";
+
+/* title for g7 settings row showing sensor grace period end time */
+"Grace Period End" = "Yetkisiz Kullanım Sonu";
+
+/* G7 Progress bar label when sensor grace period progress showing */
+"Grace period remaining" = "Kalan ek süre";
+
+/* String displayed instead of a glucose value above the CGM range */
+"HIGH" = "YÜKSEK";
+
+/* title for g7 settings row showing sensor last connect time */
+"Last Connect" = "Son Bağlantı";
+
+/* No comment provided by engineer. */
+"Last Reading" = "Son Okuma Değeri";
+
+/* Descriptive text on G7StartupView */
+"iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management." = "iAPS, G7 CGM verilerini okuyabilir ancak yine de eşleştirme, kalibrasyon ve diğer sensör yönetimi için Dexcom G7 Uygulamasını kullanmanız gerekir.";
+
+/* String displayed instead of a glucose value below the CGM range */
+"LOW" = "DÜŞÜK";
+
+/* title for g7 settings row showing BLE Name */
+"Name" = "İsim";
+
+/* No comment provided by engineer. */
+"Scan for new sensor" = "Yeni sensör için tara";
+
+/* title for g7 settings connection status when scanning */
+"Scanning" = "Taranıyor";
+
+/* G7 Status highlight text for searching for sensor */
+"Searching for\nSensor" = "Sensör\nAranıyor";
+
+/* G7 Progress bar label when searching for sensor */
+"Searching for sensor" = "Sensör aranıyor";
+
+/* G7 Status highlight text for sensor expired */
+"Sensor\nExpired" = "Sensör\nSüresi Doldu";
+
+/* G7 Status highlight text for sensor failed */
+"Sensor\nFailed" = "Sensör\nArızalı";
+
+/* G7 Status highlight text for sensor error */
+"Sensor\nIssue" = "Sensör\nSorunu";
+
+/* G7 Status highlight text for sensor warmup */
+"Sensor\nWarmup" = "Sensör\nIsınıyor";
+
+/* title for g7 settings row showing sensor expiration time */
+"Sensor Expiration" = "Sensör Süre Sonu";
+
+/* G7 Progress bar label when sensor expired */
+"Sensor expired" = "Sensör süresi doldu";
+
+/* G7 Progress bar label when sensor lifetime progress showing */
+"Sensor expires" = "Sensör süresi doluyor";
+
+/* G7 Progress bar label when sensor failed */
+"Sensor failed" = "Sensör arızalı";
+
+/* title for g7 settings row showing sensor start time */
+"Sensor Start" = "Start sensor";
+
+/* G7 Status highlight text for signal loss */
+"Signal\nLoss" = "Sinyal\nKaybı";
+
+/* Field label */
+"Time" = "Saat";
+
+/* Field label */
+"Trend" = "Eğilim";
+
+/* title for g7 config settings to upload readings */
+"Upload Readings" = "Okumaları Yükle";
+
+/* G7 Progress bar label when sensor in warmup */
+"Warmup completes" = "Isınma tamamlandı";

+ 117 - 0
Dependencies/G7SensorKit/G7SensorKitUI/uk.lproj/Localizable.strings

@@ -0,0 +1,117 @@
+/* No glucose value representation (3 dashes for mg/dL) */
+"– – –" = "– – –";
+
+/* Format string for glucose trend per minute. (1: glucose value and unit) */
+"%@/min" = "%@/хв";
+
+/* No comment provided by engineer. */
+"Are you sure you want to delete this CGM?" = "Ви впевнені, що хочете видалити цей CGM?";
+
+/* No comment provided by engineer. */
+"Bluetooth" = "Bluetooth";
+
+/* Button text to cancel G7 setup */
+"Cancel" = "Відмінити";
+
+/* No comment provided by engineer. */
+"Configuration" = "Налаштування";
+
+/* title for g7 settings connection status when connected */
+"Connected" = "Під'єднаний";
+
+/* title for g7 settings connection status when connecting */
+"Connecting" = "Під'єднання";
+
+/* Button title for starting setup */
+"Continue" = "Продовжити";
+
+/* Button label for removing CGM */
+"Delete CGM" = "Видалити CGM";
+
+/* Navigation bar title for G7SettingsView
+   Title on WelcomeView */
+"Dexcom G7" = "Dexcom G7";
+
+/* No comment provided by engineer. */
+"Done" = "Готово";
+
+/* Field label */
+"Glucose" = "Глюкоза";
+
+/* title for g7 settings row showing sensor grace period end time */
+"Grace Period End" = "Час до блокування";
+
+/* G7 Progress bar label when sensor grace period progress showing */
+"Grace period remaining" = "Період витонченості, що залишився";
+
+/* String displayed instead of a glucose value above the CGM range */
+"HIGH" = "ВИСОКИЙ";
+
+/* title for g7 settings row showing sensor last connect time */
+"Last Connect" = "Останнє підключення";
+
+/* No comment provided by engineer. */
+"Last Reading" = "Останнє читання";
+
+/* Descriptive text on G7StartupView */
+"iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management." = "iAPS може читати дані G7 CGM, але ви все одно повинні використовувати додаток Dexcom G7 для парування, калібрування та іншого управління сенсором.";
+
+/* String displayed instead of a glucose value below the CGM range */
+"LOW" = "НИЗЬКИЙ";
+
+/* title for g7 settings row showing BLE Name */
+"Name" = "Ім’я";
+
+/* No comment provided by engineer. */
+"Scan for new sensor" = "Сканувати новий Сенсор";
+
+/* title for g7 settings connection status when scanning */
+"Scanning" = "Сканування";
+
+/* G7 Status highlight text for searching for sensor */
+"Searching for\nSensor" = "Пошук\nСенсору";
+
+/* G7 Progress bar label when searching for sensor */
+"Searching for sensor" = "Пошук Сенсору";
+
+/* G7 Status highlight text for sensor expired */
+"Sensor\nExpired" = "Сенсор\nЗакінчився";
+
+/* G7 Status highlight text for sensor failed */
+"Sensor\nFailed" = "Сенсори\nНе вдалося";
+
+/* G7 Status highlight text for sensor error */
+"Sensor\nIssue" = "Сенсор\nПроблема";
+
+/* G7 Status highlight text for sensor warmup */
+"Sensor\nWarmup" = "Сенсор\nПрогрів";
+
+/* title for g7 settings row showing sensor expiration time */
+"Sensor Expiration" = "Термін дії Сенсору";
+
+/* G7 Progress bar label when sensor expired */
+"Sensor expired" = "Термін Сенсору закінчився";
+
+/* G7 Progress bar label when sensor lifetime progress showing */
+"Sensor expires" = "Сенсор закінчується";
+
+/* G7 Progress bar label when sensor failed */
+"Sensor failed" = "Не вдалося встановити Сенсор";
+
+/* title for g7 settings row showing sensor start time */
+"Sensor Start" = "Запустити сенсор";
+
+/* G7 Status highlight text for signal loss */
+"Signal\nLoss" = "Сигнал\nВтрата";
+
+/* Field label */
+"Time" = "Час";
+
+/* Field label */
+"Trend" = "Тренди";
+
+/* title for g7 config settings to upload readings */
+"Upload Readings" = "Вивантажити читання";
+
+/* G7 Progress bar label when sensor in warmup */
+"Warmup completes" = "Прогрів виконано";

+ 117 - 0
Dependencies/G7SensorKit/G7SensorKitUI/zh-Hans.lproj/Localizable.strings

@@ -0,0 +1,117 @@
+/* No glucose value representation (3 dashes for mg/dL) */
+"– – –" = "– – –";
+
+/* Format string for glucose trend per minute. (1: glucose value and unit) */
+"%@/min" = "%@/min";
+
+/* No comment provided by engineer. */
+"Are you sure you want to delete this CGM?" = "Are you sure you want to delete this CGM?";
+
+/* No comment provided by engineer. */
+"Bluetooth" = "Bluetooth";
+
+/* Button text to cancel G7 setup */
+"Cancel" = "取消";
+
+/* No comment provided by engineer. */
+"Configuration" = "配置";
+
+/* title for g7 settings connection status when connected */
+"Connected" = "已连接";
+
+/* title for g7 settings connection status when connecting */
+"Connecting" = "正在连接";
+
+/* Button title for starting setup */
+"Continue" = "继续";
+
+/* Button label for removing CGM */
+"Delete CGM" = "删除CGM数据源";
+
+/* Navigation bar title for G7SettingsView
+   Title on WelcomeView */
+"Dexcom G7" = "Dexcom G7";
+
+/* No comment provided by engineer. */
+"Done" = "完成";
+
+/* Field label */
+"Glucose" = "葡萄糖";
+
+/* title for g7 settings row showing sensor grace period end time */
+"Grace Period End" = "Grace Period End";
+
+/* G7 Progress bar label when sensor grace period progress showing */
+"Grace period remaining" = "Grace period remaining";
+
+/* String displayed instead of a glucose value above the CGM range */
+"HIGH" = "HIGH";
+
+/* title for g7 settings row showing sensor last connect time */
+"Last Connect" = "Last Connect";
+
+/* No comment provided by engineer. */
+"Last Reading" = "Last Reading";
+
+/* Descriptive text on G7StartupView */
+"iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management." = "iAPS can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management.";
+
+/* String displayed instead of a glucose value below the CGM range */
+"LOW" = "LOW";
+
+/* title for g7 settings row showing BLE Name */
+"Name" = "设备名称";
+
+/* No comment provided by engineer. */
+"Scan for new sensor" = "Scan for new sensor";
+
+/* title for g7 settings connection status when scanning */
+"Scanning" = "Scanning";
+
+/* G7 Status highlight text for searching for sensor */
+"Searching for\nSensor" = "Searching for\nSensor";
+
+/* G7 Progress bar label when searching for sensor */
+"Searching for sensor" = "Searching for sensor";
+
+/* G7 Status highlight text for sensor expired */
+"Sensor\nExpired" = "Sensor\nExpired";
+
+/* G7 Status highlight text for sensor failed */
+"Sensor\nFailed" = "Sensor\nFailed";
+
+/* G7 Status highlight text for sensor error */
+"Sensor\nIssue" = "Sensor\nIssue";
+
+/* G7 Status highlight text for sensor warmup */
+"Sensor\nWarmup" = "Sensor\nWarmup";
+
+/* title for g7 settings row showing sensor expiration time */
+"Sensor Expiration" = "Sensor Expiration";
+
+/* G7 Progress bar label when sensor expired */
+"Sensor expired" = "Sensor expired";
+
+/* G7 Progress bar label when sensor lifetime progress showing */
+"Sensor expires" = "Sensor expires";
+
+/* G7 Progress bar label when sensor failed */
+"Sensor failed" = "Sensor failed";
+
+/* title for g7 settings row showing sensor start time */
+"Sensor Start" = "Start sensor";
+
+/* G7 Status highlight text for signal loss */
+"Signal\nLoss" = "Signal\nLoss";
+
+/* Field label */
+"Time" = "时间";
+
+/* Field label */
+"Trend" = "Trend";
+
+/* title for g7 config settings to upload readings */
+"Upload Readings" = "Upload Readings";
+
+/* G7 Progress bar label when sensor in warmup */
+"Warmup completes" = "Warmup completes";

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

File diff suppressed because it is too large
+ 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)
+    }
+}

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


Some files were not shown because too many files changed in this diff