Просмотр исходного кода

Merge pull request #30 from polscm32/forecast-chart

Forecast chart
polscm32 1 год назад
Родитель
Сommit
3246be1543
46 измененных файлов с 2404 добавлено и 1095 удалено
  1. 17 5
      .github/workflows/add_identifiers.yml
  2. 179 147
      .github/workflows/build_trio.yml
  3. 16 5
      .github/workflows/create_certs.yml
  4. 10 7
      .github/workflows/validate_secrets.yml
  5. 20 0
      FreeAPS.xcodeproj/project.pbxproj
  6. 1 1
      FreeAPS/Resources/javascript/bundle/autosens.js
  7. 1 1
      FreeAPS/Resources/javascript/bundle/autotune-prep.js
  8. 1 1
      FreeAPS/Resources/javascript/bundle/iob.js
  9. 1 1
      FreeAPS/Resources/javascript/bundle/meal.js
  10. 1 1
      FreeAPS/Resources/javascript/bundle/profile.js
  11. 1 0
      FreeAPS/Resources/json/defaults/freeaps/freeaps_settings.json
  12. 31 8
      FreeAPS/Sources/APS/APSManager.swift
  13. 103 37
      FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift
  14. 2 0
      FreeAPS/Sources/APS/Storage/CarbsStorage.swift
  15. 19 10
      FreeAPS/Sources/APS/Storage/DeterminationStorage.swift
  16. 6 0
      FreeAPS/Sources/APS/Storage/GlucoseStorage.swift
  17. 8 0
      FreeAPS/Sources/Helpers/Rounding.swift
  18. 28 5
      FreeAPS/Sources/Models/BloodGlucose.swift
  19. 5 0
      FreeAPS/Sources/Models/FreeAPSSettings.swift
  20. 8 8
      FreeAPS/Sources/Modules/AutotuneConfig/View/AutotuneConfigRootView.swift
  21. 5 1
      FreeAPS/Sources/Modules/Bolus/BolusDataFlow.swift
  22. 26 4
      FreeAPS/Sources/Modules/Bolus/BolusProvider.swift
  23. 278 183
      FreeAPS/Sources/Modules/Bolus/BolusStateModel.swift
  24. 119 0
      FreeAPS/Sources/Modules/Bolus/View/AddMealPresetView.swift
  25. 86 207
      FreeAPS/Sources/Modules/Bolus/View/BolusRootView.swift
  26. 255 0
      FreeAPS/Sources/Modules/Bolus/View/ForeCastChart.swift
  27. 333 0
      FreeAPS/Sources/Modules/Bolus/View/MealPresetView.swift
  28. 5 0
      FreeAPS/Sources/Modules/DataTable/DataTableStateModel.swift
  29. 28 17
      FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift
  30. 181 159
      FreeAPS/Sources/Modules/Home/HomeStateModel.swift
  31. 308 89
      FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift
  32. 9 14
      FreeAPS/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
  33. 36 24
      FreeAPS/Sources/Modules/Home/View/HomeRootView.swift
  34. 3 14
      FreeAPS/Sources/Modules/OverrideConfig/View/OverrideRootView.swift
  35. 2 0
      FreeAPS/Sources/Modules/StatConfig/StatConfigStateModel.swift
  36. 1 0
      FreeAPS/Sources/Modules/StatConfig/View/StatConfigRootView.swift
  37. 3 3
      FreeAPS/Sources/Services/LiveActivity/LiveActitiyAttributes.swift
  38. 8 20
      FreeAPS/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift
  39. 103 95
      FreeAPS/Sources/Services/WatchManager/WatchManager.swift
  40. 28 2
      FreeAPS/Sources/Views/TextFieldWithToolBar.swift
  41. 64 9
      LiveActivity/LiveActivity.swift
  42. 20 0
      Model/CoreDataStack.swift
  43. 17 0
      Model/Helper/CarbsGlucose+helper.swift
  44. 9 0
      Model/Helper/NSPredicates.swift
  45. 4 1
      oref0_source_version.txt
  46. 15 16
      trio-oref/lib/profile/index.js

+ 17 - 5
.github/workflows/add_identifiers.yml

@@ -10,12 +10,13 @@ jobs:
     secrets: inherit
 
   identifiers:
+    name: Add Identifiers
     needs: validate
-    runs-on: macos-13
+    runs-on: macos-14
     steps:
-      # Uncomment to manually select Xcode version if needed
-      #- name: Select Xcode version
-      #  run: "sudo xcode-select --switch /Applications/Xcode_15.0.1.app/Contents/Developer"
+      # Uncomment to manually select latest Xcode if needed
+      #- name: Select Latest Xcode
+      #  run: "sudo xcode-select --switch /Applications/Xcode_13.0.app/Contents/Developer"
 
       # Checks-out the repo
       - name: Checkout Repo
@@ -23,12 +24,23 @@ jobs:
 
       # Patch Fastlane Match to not print tables
       - name: Patch Match Tables
-        run: find /usr/local/lib/ruby/gems -name table_printer.rb | xargs sed -i "" "/puts(Terminal::Table.new(params))/d"
+        run: |
+          TABLE_PRINTER_PATH=$(ruby -e 'puts Gem::Specification.find_by_name("fastlane").gem_dir')/match/lib/match/table_printer.rb
+          if [ -f "$TABLE_PRINTER_PATH" ]; then
+            sed -i "" "/puts(Terminal::Table.new(params))/d" "$TABLE_PRINTER_PATH"
+          else
+            echo "table_printer.rb not found"
+            exit 1
+          fi
 
       # Install project dependencies
       - name: Install Project Dependencies
         run: bundle install
 
+      # Sync the GitHub runner clock with the Windows time server (workaround as suggested in https://github.com/actions/runner/issues/2996)
+      - name: Sync clock
+        run: sudo sntp -sS time.windows.com
+
       # Create or update identifiers for app
       - name: Fastlane Provision
         run: bundle exec fastlane identifiers

+ 179 - 147
.github/workflows/build_trio.yml

@@ -2,20 +2,20 @@ name: 4. Build Trio
 run-name: Build Trio (${{ github.ref_name }})
 on:
   workflow_dispatch:
-  
+
   ## Remove the "#" sign from the beginning of the line below to get automated builds on push (code changes in your repository)
   #push:
-  
+
   schedule:
-    #- cron: '30 04 1 * *' # Runs at 04:30 UTC on the 1st every month
-    - 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 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
 
 env:  
   UPSTREAM_REPO: nightscout/Trio
   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)
-  ALIVE_BRANCH: alive
+  ALIVE_BRANCH_MAIN: alive-main
+  ALIVE_BRANCH_DEV: alive-dev
 
 jobs:
   validate:
@@ -33,151 +33,172 @@ jobs:
       contents: write
     outputs:
       WORKFLOW_PERMISSION: ${{ steps.workflow-permission.outputs.has_permission }}
-    
+
     steps:
-    - name: Check for workflow permissions
-      id: workflow-permission
-      env: 
-        TOKEN_TO_CHECK: ${{ secrets.GH_PAT }}
-      run: |
-        PERMISSIONS=$(curl -sS -f -I -H "Authorization: token ${{ env.TOKEN_TO_CHECK }}" https://api.github.com | grep ^x-oauth-scopes: | cut -d' ' -f2-);
-        
-        if [[ $PERMISSIONS =~ "workflow" || $PERMISSIONS == "" ]]; then
-          echo "GH_PAT holds workflow permissions or is fine-grained PAT."
-          echo "has_permission=true" >> $GITHUB_OUTPUT # Set WORKFLOW_PERMISSION to false.
-        else 
-          echo "GH_PAT lacks workflow permissions."
-          echo "Automated build features will be skipped!"
-          echo "has_permission=false" >> $GITHUB_OUTPUT # Set WORKFLOW_PERMISSION to false.
-        fi
-    
-    - name: Check for alive branch
-      if: steps.workflow-permission.outputs.has_permission == 'true'
-      env:
-        GITHUB_TOKEN: ${{ secrets.GH_PAT }}
-      run: |
-        if [[ "$(gh api -H "Accept: application/vnd.github+json" /repos/${{ github.repository }}/branches | jq --raw-output 'any(.name=="alive")')" == "true" ]]; then
-          echo "Branch 'alive' exists."
-          echo "ALIVE_BRANCH_EXISTS=true" >> $GITHUB_ENV # Set ALIVE_BRANCH_EXISTS to true
-        else
-          echo "Branch 'alive' does not exist."
-          echo "ALIVE_BRANCH_EXISTS=false" >> $GITHUB_ENV # Set ALIVE_BRANCH_EXISTS to false
-        fi
-    
-    - name: Create alive branch
-      if: env.ALIVE_BRANCH_EXISTS == 'false'
-      env:
-        GITHUB_TOKEN: ${{ secrets.GH_PAT }}
-      run: |
-        # get ref for nightscout/Trio:dev
-        response=$(curl --request GET \
-                          --url "https://api.github.com/repos/${{ env.UPSTREAM_REPO }}/git/refs/heads/dev" \
-                          --header "Authorization: Bearer $GITHUB_TOKEN" \
-                          --silent)
-        echo "API Response: $response"
-        SHA=$(echo "$response" | jq -r '.object.sha')
-        if [ "$SHA" = "null" ]; then
-            echo "Error: Unable to retrieve SHA for the dev branch."
-            exit 1
-        fi
-        echo "SHA of dev branch: $SHA";
-        
-        # Create alive branch based on nightscout/Trio:dev
-        gh api \
-          --method POST \
-          -H "Authorization: token $GITHUB_TOKEN" \
-          -H "Accept: application/vnd.github.v3+json" \
-          /repos/${{ github.repository }}/git/refs \
-          -f ref='refs/heads/alive' \
-          -f sha=$SHA
-  
+      - name: Check for workflow permissions
+        id: workflow-permission
+        env:
+          TOKEN_TO_CHECK: ${{ secrets.GH_PAT }}
+        run: |
+          PERMISSIONS=$(curl -sS -f -I -H "Authorization: token ${{ env.TOKEN_TO_CHECK }}" https://api.github.com | grep ^x-oauth-scopes: | cut -d' ' -f2-);
+
+          if [[ $PERMISSIONS =~ "workflow" || $PERMISSIONS == "" ]]; then
+            echo "GH_PAT holds workflow permissions or is fine-grained PAT."
+            echo "has_permission=true" >> $GITHUB_OUTPUT # Set WORKFLOW_PERMISSION to false.
+          else 
+            echo "GH_PAT lacks workflow permissions."
+            echo "Automated build features will be skipped!"
+            echo "has_permission=false" >> $GITHUB_OUTPUT # Set WORKFLOW_PERMISSION to false.
+          fi
+
+      - name: Check for alive branches
+        if: steps.workflow-permission.outputs.has_permission == 'true'
+        env:
+          GITHUB_TOKEN: ${{ secrets.GH_PAT }}
+        run: |
+          if [[ $(gh api -H "Accept: application/vnd.github+json" /repos/${{ github.repository_owner }}/Trio/branches | jq --raw-output '[.[] | select(.name == "alive-main" or .name == "alive-dev")] | length > 0') == "true" ]]; then
+            echo "Branches 'alive-main' or 'alive-dev' exist."
+            echo "ALIVE_BRANCH_EXISTS=true" >> $GITHUB_ENV
+          else
+            echo "Branches 'alive-main' and 'alive-dev' do not exist."
+            echo "ALIVE_BRANCH_EXISTS=false" >> $GITHUB_ENV
+          fi
+
+      - name: Create alive branches
+        if: env.ALIVE_BRANCH_EXISTS == 'false'
+        env:
+          GITHUB_TOKEN: ${{ secrets.GH_PAT }}
+        run: |
+          # Get ref for UPSTREAM_REPO:main
+          SHA_MAIN=$(curl -sS -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/repos/${{ env.UPSTREAM_REPO }}/git/refs/heads/main | jq -r '.object.sha')
+
+          # Get ref for UPSTREAM_REPO:dev
+          SHA_DEV=$(curl -sS -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/repos/${{ env.UPSTREAM_REPO }}/git/refs/heads/dev | jq -r '.object.sha')
+
+          # Create alive-main branch in Trio fork based on UPSTREAM_REPO:main
+          gh api \
+            --method POST \
+            -H "Authorization: token $GITHUB_TOKEN" \
+            -H "Accept: application/vnd.github.v3+json" \
+            /repos/${{ github.repository_owner }}/Trio/git/refs \
+            -f ref='refs/heads/alive-main' \
+            -f sha=$SHA_MAIN
+
+          # Create alive-dev branch in Trio fork based on UPSTREAM_REPO:dev
+          gh api \
+            --method POST \
+            -H "Authorization: token $GITHUB_TOKEN" \
+            -H "Accept: application/vnd.github.v3+json" \
+            /repos/${{ github.repository_owner }}/Trio/git/refs \
+            -f ref='refs/heads/alive-dev' \
+            -f sha=$SHA_DEV
+
   # Checks for changes in upstream repository; if changes exist prompts sync for build
   # Performs keepalive to avoid stale fork
   check_latest_from_upstream:
     needs: [validate, check_alive_and_permissions]
     runs-on: ubuntu-latest
     name: Check upstream and keep alive
-    outputs: 
+    outputs:
       NEW_COMMITS: ${{ steps.sync.outputs.has_new_commits }}
-    
+      ABORT_SYNC: ${{ steps.check_branch.outputs.ABORT_SYNC }}
+
     steps:
-    - name: Checkout target repo
-      if: |
-        needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
-        (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false')
-      uses: actions/checkout@v4
-      with:
-        token: ${{ secrets.GH_PAT }}
-        ref: alive
-    
-    - name: Sync upstream changes
-      if: | # do not run the upstream sync action on the upstream repository
-        needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
-        vars.SCHEDULED_SYNC != 'false' && github.repository_owner != 'nightscout'
-      id: sync
-      uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1
-      with:
-        target_sync_branch: ${{ env.ALIVE_BRANCH }}
-        shallow_since: 6 months ago
-        target_repo_token: ${{ secrets.GH_PAT }}
-        upstream_sync_branch: ${{ env.UPSTREAM_BRANCH }}
-        upstream_sync_repo: ${{ env.UPSTREAM_REPO }}
-    
-    # Display a sample message based on the sync output var 'has_new_commits'
-    - name: New commits found
-      if: |
-        needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
-        vars.SCHEDULED_SYNC != 'false' && steps.sync.outputs.has_new_commits == 'true'
-      run: echo "New commits were found to sync."
-    
-    - name: No new commits
-      if: |
-        needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' && 
-        vars.SCHEDULED_SYNC != 'false' && steps.sync.outputs.has_new_commits == 'false'
-      run: echo "There were no new commits."
-    
-    - name: Show value of 'has_new_commits'
-      if: needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' && vars.SCHEDULED_SYNC != 'false'
-      run: |
-        echo ${{ steps.sync.outputs.has_new_commits }}
-        echo "NEW_COMMITS=${{ steps.sync.outputs.has_new_commits }}" >> $GITHUB_OUTPUT
-    
-    # Keep repository "alive": add empty commits to ALIVE_BRANCH after "time_elapsed" days of inactivity to avoid inactivation of scheduled workflows
-    - name: Keep alive
-      if: |
-        needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
-        (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false')
-      uses: gautamkrishnar/keepalive-workflow@v1 # using the workflow with default settings
-      with:
-        time_elapsed: 20 # Time elapsed from the previous commit to trigger a new automated commit (in days)
-    
-    - name: Show scheduled build configuration message
-      if: needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION != 'true'
-      run: |
-        echo "### :calendar: Scheduled Sync and Build Disabled :mobile_phone_off:" >> $GITHUB_STEP_SUMMARY
-        echo "You have not yet configured the scheduled sync and build for Trio's browser build." >> $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 \
+      - name: Check if running on main or dev branch
+        if: |
+          needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
+          (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false')
+        id: check_branch
+        run: |
+          if [ "${GITHUB_REF##*/}" = "main" ]; then
+            echo "Running on main branch"
+            echo "ALIVE_BRANCH=${ALIVE_BRANCH_MAIN}" >> $GITHUB_OUTPUT
+            echo "ABORT_SYNC=false" >> $GITHUB_OUTPUT
+          elif [ "${GITHUB_REF##*/}" = "dev" ]; then
+            echo "Running on dev branch"
+            echo "ALIVE_BRANCH=${ALIVE_BRANCH_DEV}" >> $GITHUB_OUTPUT
+            echo "ABORT_SYNC=false" >> $GITHUB_OUTPUT
+          else
+            echo "Not running on main or dev branch"
+            echo "ABORT_SYNC=true" >> $GITHUB_OUTPUT
+          fi
+
+      - name: Checkout target repo
+        if: |
+          needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
+          (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false')
+        uses: actions/checkout@v4
+        with:
+          token: ${{ secrets.GH_PAT }}
+          ref: ${{ steps.check_branch.outputs.ALIVE_BRANCH }}
+
+      - name: Sync upstream changes
+        if: | # do not run the upstream sync action on the upstream repository
+          needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
+          vars.SCHEDULED_SYNC != 'false' && github.repository_owner != 'nightscout' && steps.check_branch.outputs.ABORT_SYNC == 'false'
+        id: sync
+        uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1
+        with:
+          target_sync_branch: ${{ steps.check_branch.outputs.ALIVE_BRANCH }}
+          shallow_since: 6 months ago
+          target_repo_token: ${{ secrets.GH_PAT }}
+          upstream_sync_branch: ${{ env.UPSTREAM_BRANCH }}
+          upstream_sync_repo: ${{ env.UPSTREAM_REPO }}
+
+      # Display a sample message based on the sync output var 'has_new_commits'
+      - name: New commits found
+        if: |
+          needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
+          vars.SCHEDULED_SYNC != 'false' && steps.sync.outputs.has_new_commits == 'true'
+        run: echo "New commits were found to sync."
+
+      - name: No new commits
+        if: |
+          needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' && 
+          vars.SCHEDULED_SYNC != 'false' && steps.sync.outputs.has_new_commits == 'false'
+        run: echo "There were no new commits."
+
+      - name: Show value of 'has_new_commits'
+        if: needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' && vars.SCHEDULED_SYNC != 'false' && steps.check_branch.outputs.ABORT_SYNC == 'false'
+        run: |
+          echo ${{ steps.sync.outputs.has_new_commits }}
+          echo "NEW_COMMITS=${{ steps.sync.outputs.has_new_commits }}" >> $GITHUB_OUTPUT
+
+      # Keep repository "alive": add empty commits to ALIVE_BRANCH after "time_elapsed" days of inactivity to avoid inactivation of scheduled workflows
+      - name: Keep alive
+        if: |
+          needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
+          (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false')
+        uses: gautamkrishnar/keepalive-workflow@v1 # using the workflow with default settings
+        with:
+          time_elapsed: 20 # Time elapsed from the previous commit to trigger a new automated commit (in days)
+
+      - name: Show scheduled build configuration message
+        if: needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION != 'true'
+        run: |
+          echo "### :calendar: Scheduled Sync and Build Disabled :mobile_phone_off:" >> $GITHUB_STEP_SUMMARY
+          echo "You have not yet configured the scheduled sync and build for Trio's browser build." >> $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 \
               under the following path <code>Trio/fastlane/testflight.md</code>." >> $GITHUB_STEP_SUMMARY
-   
   
   # Builds Trio
   build:
     name: Build
     needs: [validate, check_alive_and_permissions, check_latest_from_upstream]
-    runs-on: macos-13
+    runs-on: macos-14
     permissions:
       contents: write
-    if: | # runs if started manually, or if sync schedule is set and enabled and scheduled on the first Saturday each month, or if sync schedule is set and enabled and new commits were found
-        github.event_name == 'workflow_dispatch' ||
-        (needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
-          (vars.SCHEDULED_BUILD != 'false' && github.event.schedule == '0 6 1 * *') ||
-          (vars.SCHEDULED_SYNC != 'false' && needs.check_latest_from_upstream.outputs.NEW_COMMITS == 'true' )
-        )
+    if:
+      | # runs if started manually, or if sync schedule is set and enabled and scheduled on the first Saturday each month, or if sync schedule is set and enabled and new commits were found
+      github.event_name == 'workflow_dispatch' ||
+      (needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
+        (vars.SCHEDULED_BUILD != 'false' && github.event.schedule == '0 6 1 * *') ||
+        (vars.SCHEDULED_SYNC != 'false' && needs.check_latest_from_upstream.outputs.NEW_COMMITS == 'true' )
+      )
     steps:
-      # Uncomment to manually select Xcode version if needed
-      #- name: Select Xcode version
-      #  run: "sudo xcode-select --switch /Applications/Xcode_15.0.1.app/Contents/Developer"
+      - name: Select Xcode version
+        run: "sudo xcode-select --switch /Applications/Xcode_15.4.app/Contents/Developer"
       
       - name: Checkout Repo for syncing
         if: |
@@ -186,12 +207,12 @@ jobs:
         uses: actions/checkout@v4
         with:
           token: ${{ secrets.GH_PAT }}
-          ref: ${{ env.TARGET_BRANCH }} 
-      
+          ref: ${{ env.TARGET_BRANCH }}
+
       - name: Sync upstream changes
         if: | # do not run the upstream sync action on the upstream repository
           needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
-          vars.SCHEDULED_SYNC != 'false' && github.repository_owner != 'nightscout'
+          vars.SCHEDULED_SYNC != 'false' && github.repository_owner != 'nightscout' && needs.check_latest_from_upstream.outputs.ABORT_SYNC == 'false'
         id: sync
         uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1
         with:
@@ -200,24 +221,24 @@ jobs:
           target_repo_token: ${{ secrets.GH_PAT }}
           upstream_sync_branch: ${{ env.UPSTREAM_BRANCH }}
           upstream_sync_repo: ${{ env.UPSTREAM_REPO }}
-      
+
       # Display a sample message based on the sync output var 'has_new_commits'
       - name: New commits found
         if: |
           needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
-          vars.SCHEDULED_SYNC != 'false' && steps.sync.outputs.has_new_commits == 'true'
+          vars.SCHEDULED_SYNC != 'false' && steps.sync.outputs.has_new_commits == 'true' && needs.check_latest_from_upstream.outputs.ABORT_SYNC == 'false'
         run: echo "New commits were found to sync."
-    
+
       - name: No new commits
         if: |
           needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' && 
-          vars.SCHEDULED_SYNC != 'false' && steps.sync.outputs.has_new_commits == 'false'
+          vars.SCHEDULED_SYNC != 'false' && steps.sync.outputs.has_new_commits == 'false' && needs.check_latest_from_upstream.outputs.ABORT_SYNC == 'false'
         run: echo "There were no new commits."
-      
+
       - name: Show value of 'has_new_commits'
         if: |
           needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true'
-          && vars.SCHEDULED_SYNC != 'false'
+          && vars.SCHEDULED_SYNC != 'false' && needs.check_latest_from_upstream.outputs.ABORT_SYNC == 'false'
         run: |
           echo ${{ steps.sync.outputs.has_new_commits }}
           echo "NEW_COMMITS=${{ steps.sync.outputs.has_new_commits }}" >> $GITHUB_OUTPUT
@@ -231,12 +252,23 @@ jobs:
 
       # Patch Fastlane Match to not print tables
       - name: Patch Match Tables
-        run: find /usr/local/lib/ruby/gems -name table_printer.rb | xargs sed -i "" "/puts(Terminal::Table.new(params))/d"
-      
+        run: |
+          TABLE_PRINTER_PATH=$(ruby -e 'puts Gem::Specification.find_by_name("fastlane").gem_dir')/match/lib/match/table_printer.rb
+          if [ -f "$TABLE_PRINTER_PATH" ]; then
+            sed -i "" "/puts(Terminal::Table.new(params))/d" "$TABLE_PRINTER_PATH"
+          else
+            echo "table_printer.rb not found"
+            exit 1
+          fi
+
       # Install project dependencies
-      - name: Install project dependencies
+      - name: Install Project Dependencies
         run: bundle install
-      
+
+      # Sync the GitHub runner clock with the Windows time server (workaround as suggested in https://github.com/actions/runner/issues/2996)
+      - name: Sync clock
+        run: sudo sntp -sS time.windows.com
+
       # Build signed Trio IPA file
       - name: Fastlane Build & Archive
         run: bundle exec fastlane build_trio
@@ -247,7 +279,7 @@ jobs:
           FASTLANE_ISSUER_ID: ${{ secrets.FASTLANE_ISSUER_ID }}
           FASTLANE_KEY: ${{ secrets.FASTLANE_KEY }}
           MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
-      
+
       # Upload to TestFlight
       - name: Fastlane upload to TestFlight
         run: bundle exec fastlane release

+ 16 - 5
.github/workflows/create_certs.yml

@@ -12,11 +12,11 @@ jobs:
   certificates:
     name: Create Certificates
     needs: validate
-    runs-on: macos-13
+    runs-on: macos-14
     steps:
-      # Uncomment to manually select Xcode version if needed
-      - name: Select Xcode version
-        run: "sudo xcode-select --switch /Applications/Xcode_15.0.1.app/Contents/Developer"
+      # Uncomment to manually select latest Xcode if needed
+      #- name: Select Latest Xcode
+      #  run: "sudo xcode-select --switch /Applications/Xcode_13.0.app/Contents/Developer"
 
       # Checks-out the repo
       - name: Checkout Repo
@@ -24,12 +24,23 @@ jobs:
 
       # Patch Fastlane Match to not print tables
       - name: Patch Match Tables
-        run: find /usr/local/lib/ruby/gems -name table_printer.rb | xargs sed -i "" "/puts(Terminal::Table.new(params))/d"
+        run: |
+          TABLE_PRINTER_PATH=$(ruby -e 'puts Gem::Specification.find_by_name("fastlane").gem_dir')/match/lib/match/table_printer.rb
+          if [ -f "$TABLE_PRINTER_PATH" ]; then
+            sed -i "" "/puts(Terminal::Table.new(params))/d" "$TABLE_PRINTER_PATH"
+          else
+            echo "table_printer.rb not found"
+            exit 1
+          fi
 
       # Install project dependencies
       - name: Install Project Dependencies
         run: bundle install
 
+      # Sync the GitHub runner clock with the Windows time server (workaround as suggested in https://github.com/actions/runner/issues/2996)
+      - name: Sync clock
+        run: sudo sntp -sS time.windows.com
+
       # Create or update certificates for app
       - name: Create Certificates
         run: bundle exec fastlane certs

+ 10 - 7
.github/workflows/validate_secrets.yml

@@ -5,7 +5,7 @@ on: [workflow_call, workflow_dispatch]
 jobs:
   validate-access-token:
     name: Access
-    runs-on: macos-13
+    runs-on: macos-14
     env:
       GH_PAT: ${{ secrets.GH_PAT }}
       GH_TOKEN: ${{ secrets.GH_PAT }}
@@ -74,7 +74,7 @@ jobs:
   validate-match-secrets:
     name: Match-Secrets
     needs: validate-access-token
-    runs-on: macos-13
+    runs-on: macos-14
     env:
       GH_TOKEN: ${{ secrets.GH_PAT }}
     steps:
@@ -112,7 +112,7 @@ jobs:
   validate-fastlane-secrets:
     name: Fastlane
     needs: [validate-access-token, validate-match-secrets]
-    runs-on: macos-13
+    runs-on: macos-14
     env:
       GH_PAT: ${{ secrets.GH_PAT }}
       GH_TOKEN: ${{ secrets.GH_PAT }}
@@ -125,10 +125,13 @@ jobs:
       - name: Checkout Repo
         uses: actions/checkout@v4
 
-      # Install project dependencies
       - name: Install Project Dependencies
         run: bundle install
 
+      # Sync the GitHub runner clock with the Windows time server (workaround as suggested in https://github.com/actions/runner/issues/2996)
+      - name: Sync clock
+        run: sudo sntp -sS time.windows.com
+
       - name: Validate Fastlane Secrets
         run: |
           # Validate Fastlane Secrets
@@ -165,13 +168,13 @@ jobs:
             [ -z "$FASTLANE_KEY"       ] && echo "::error::The FASTLANE_KEY secret is unset or empty. Set it and try again."
           elif [ ${#FASTLANE_KEY_ID} -ne 10 ]; then
             failed=true
-            echo "::error::The FASTLANE_KEY_ID secret is set but has wrong length. Verify that you copied it correctly from the 'Keys' tab at https://appstoreconnect.apple.com/access/api and try again."
+            echo "::error::The FASTLANE_KEY_ID secret is set but has wrong length. Verify that you copied it correctly from the 'Keys' tab at https://appstoreconnect.apple.com/access/integrations/api and try again."
           elif ! [[ $FASTLANE_KEY_ID =~ $FASTLANE_KEY_ID_PATTERN ]]; then
             failed=true
-            echo "::error::The FASTLANE_KEY_ID secret is set but invalid. Verify that you copied it correctly from the 'Keys' tab at https://appstoreconnect.apple.com/access/api and try again."
+            echo "::error::The FASTLANE_KEY_ID secret is set but invalid. Verify that you copied it correctly from the 'Keys' tab at https://appstoreconnect.apple.com/access/integrations/api and try again."
           elif ! [[ $FASTLANE_ISSUER_ID =~ $FASTLANE_ISSUER_ID_PATTERN ]]; then
             failed=true
-            echo "::error::The FASTLANE_ISSUER_ID secret is set but invalid. Verify that you copied it correctly from the 'Keys' tab at https://appstoreconnect.apple.com/access/api and try again."
+            echo "::error::The FASTLANE_ISSUER_ID secret is set but invalid. Verify that you copied it correctly from the 'Keys' tab at https://appstoreconnect.apple.com/access/integrations/api and try again."
           elif ! echo "$FASTLANE_KEY" | openssl pkcs8 -nocrypt >/dev/null; then
             failed=true
             echo "::error::The FASTLANE_KEY secret is set but invalid. Verify that you copied it correctly from the API Key file (*.p8) you downloaded and try again."

+ 20 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -297,6 +297,7 @@
 		B958F1B72BA0711600484851 /* MKRingProgressView in Frameworks */ = {isa = PBXBuildFile; productRef = B958F1B62BA0711600484851 /* MKRingProgressView */; };
 		B9CAAEFC2AE70836000F68BC /* branch.txt in Resources */ = {isa = PBXBuildFile; fileRef = B9CAAEFB2AE70836000F68BC /* branch.txt */; };
 		BA00D96F7B2FF169A06FB530 /* CGMStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C018D1680307A31C9ED7120 /* CGMStateModel.swift */; };
+		BD0B2EF32C5998E600B3298F /* MealPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0B2EF22C5998E600B3298F /* MealPresetView.swift */; };
 		BD1661312B82ADAB00256551 /* CustomProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1661302B82ADAB00256551 /* CustomProgressView.swift */; };
 		BD2B464E0745FBE7B79913F4 /* NightscoutConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */; };
 		BD2FF1A02AE29D43005D1C5D /* CheckboxToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2FF19F2AE29D43005D1C5D /* CheckboxToggleStyle.swift */; };
@@ -307,6 +308,8 @@
 		BD7DA9A92AE06E9200601B20 /* BolusCalculatorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9A82AE06E9200601B20 /* BolusCalculatorStateModel.swift */; };
 		BD7DA9AC2AE06EB900601B20 /* BolusCalculatorConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9AB2AE06EB900601B20 /* BolusCalculatorConfigRootView.swift */; };
 		BDB3C1192C03DD1000CEEAA1 /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */; };
+		BDB899882C564509006F3298 /* ForeCastChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB899872C564509006F3298 /* ForeCastChart.swift */; };
+		BDB8998A2C565D0C006F3298 /* CarbsGlucose+helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */; };
 		BDBAACFA2C2D439700370AAE /* OverrideData.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAACF92C2D439700370AAE /* OverrideData.swift */; };
 		BDC2EA452C3043B000E5BBD0 /* OverrideStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */; };
 		BDC2EA472C3045AD00E5BBD0 /* Override.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC2EA462C3045AD00E5BBD0 /* Override.swift */; };
@@ -320,6 +323,7 @@
 		BDF530D82B40F8AC002CAF43 /* LockScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDF530D72B40F8AC002CAF43 /* LockScreenView.swift */; };
 		BDFD165A2AE40438007F0DDA /* BolusRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFD16592AE40438007F0DDA /* BolusRootView.swift */; };
 		BF1667ADE69E4B5B111CECAE /* ManualTempBasalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680C4420C9A345D46D90D06C /* ManualTempBasalProvider.swift */; };
+		C20BC6CE2C66FBFD002BC1C6 /* Rounding.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20BC6CD2C66FBFD002BC1C6 /* Rounding.swift */; };
 		C967DACD3B1E638F8B43BE06 /* ManualTempBasalStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */; };
 		CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */; };
 		CC41E29A2B1E1F460070974F /* HistoryLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC41E2992B1E1F460070974F /* HistoryLayout.swift */; };
@@ -431,6 +435,7 @@
 		DDD1631A2C4C695E00CD525A /* EditOverrideForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163192C4C695E00CD525A /* EditOverrideForm.swift */; };
 		DDD1631C2C4C697400CD525A /* AddOverrideForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD1631B2C4C697400CD525A /* AddOverrideForm.swift */; };
 		DDD1631F2C4C6F6900CD525A /* TrioCoreDataPersistentContainer.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DDD1631D2C4C6F6900CD525A /* TrioCoreDataPersistentContainer.xcdatamodeld */; };
+		DDF847E62C5D66490049BB3B /* AddMealPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF847E52C5D66490049BB3B /* AddMealPresetView.swift */; };
 		E00EEC0327368630002FF094 /* ServiceAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00EEBFD27368630002FF094 /* ServiceAssembly.swift */; };
 		E00EEC0427368630002FF094 /* SecurityAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00EEBFE27368630002FF094 /* SecurityAssembly.swift */; };
 		E00EEC0527368630002FF094 /* StorageAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00EEBFF27368630002FF094 /* StorageAssembly.swift */; };
@@ -897,6 +902,7 @@
 		B9B5C0607505A38F256BF99A /* CGMDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CGMDataFlow.swift; sourceTree = "<group>"; };
 		B9CAAEFB2AE70836000F68BC /* branch.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = branch.txt; sourceTree = SOURCE_ROOT; };
 		BA49538D56989D8DA6FCF538 /* TargetsEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorDataFlow.swift; sourceTree = "<group>"; };
+		BD0B2EF22C5998E600B3298F /* MealPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPresetView.swift; sourceTree = "<group>"; };
 		BD1661302B82ADAB00256551 /* CustomProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProgressView.swift; sourceTree = "<group>"; };
 		BD1CF8B72C1A4A8400CB930A /* ConfigOverride.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ConfigOverride.xcconfig; sourceTree = "<group>"; };
 		BD2FF19F2AE29D43005D1C5D /* CheckboxToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxToggleStyle.swift; sourceTree = "<group>"; };
@@ -907,6 +913,8 @@
 		BD7DA9A82AE06E9200601B20 /* BolusCalculatorStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorStateModel.swift; sourceTree = "<group>"; };
 		BD7DA9AB2AE06EB900601B20 /* BolusCalculatorConfigRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorConfigRootView.swift; sourceTree = "<group>"; };
 		BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtension.swift; sourceTree = "<group>"; };
+		BDB899872C564509006F3298 /* ForeCastChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForeCastChart.swift; sourceTree = "<group>"; };
+		BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarbsGlucose+helper.swift"; sourceTree = "<group>"; };
 		BDBAACF92C2D439700370AAE /* OverrideData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideData.swift; sourceTree = "<group>"; };
 		BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorage.swift; sourceTree = "<group>"; };
 		BDC2EA462C3045AD00E5BBD0 /* Override.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Override.swift; sourceTree = "<group>"; };
@@ -921,6 +929,7 @@
 		BDFD16592AE40438007F0DDA /* BolusRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusRootView.swift; sourceTree = "<group>"; };
 		BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorRootView.swift; sourceTree = "<group>"; };
 		C19984D62EFC0035A9E9644D /* BolusProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BolusProvider.swift; sourceTree = "<group>"; };
+		C20BC6CD2C66FBFD002BC1C6 /* Rounding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Rounding.swift; sourceTree = "<group>"; };
 		C377490C77661D75E8C50649 /* ManualTempBasalRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalRootView.swift; sourceTree = "<group>"; };
 		C8D1A7CA8C10C4403D4BBFA7 /* BolusDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BolusDataFlow.swift; sourceTree = "<group>"; };
 		CC41E2992B1E1F460070974F /* HistoryLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryLayout.swift; sourceTree = "<group>"; };
@@ -1033,6 +1042,7 @@
 		DDD163192C4C695E00CD525A /* EditOverrideForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditOverrideForm.swift; sourceTree = "<group>"; };
 		DDD1631B2C4C697400CD525A /* AddOverrideForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddOverrideForm.swift; sourceTree = "<group>"; };
 		DDD1631E2C4C6F6900CD525A /* TrioCoreDataPersistentContainer.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = TrioCoreDataPersistentContainer.xcdatamodel; sourceTree = "<group>"; };
+		DDF847E52C5D66490049BB3B /* AddMealPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMealPresetView.swift; sourceTree = "<group>"; };
 		E00EEBFD27368630002FF094 /* ServiceAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceAssembly.swift; sourceTree = "<group>"; };
 		E00EEBFE27368630002FF094 /* SecurityAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecurityAssembly.swift; sourceTree = "<group>"; };
 		E00EEBFF27368630002FF094 /* StorageAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageAssembly.swift; sourceTree = "<group>"; };
@@ -1834,6 +1844,7 @@
 				BD1661302B82ADAB00256551 /* CustomProgressView.swift */,
 				581516A32BCED84A00BF67D7 /* DebuggingIdentifiers.swift */,
 				DD1DB7CB2BECCA1F0048B367 /* BuildDetails.swift */,
+				C20BC6CD2C66FBFD002BC1C6 /* Rounding.swift */,
 			);
 			path = Helpers;
 			sourceTree = "<group>";
@@ -2109,6 +2120,7 @@
 				582FAE422C05102C00D1C13F /* CoreDataError.swift */,
 				BDF34EBD2C0A31D000D51995 /* CustomNotification.swift */,
 				BDCD47AE2C1F3F1700F8BCD5 /* OverrideStored+helper.swift */,
+				BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */,
 			);
 			path = Helper;
 			sourceTree = "<group>";
@@ -2231,6 +2243,9 @@
 			children = (
 				BDFD16592AE40438007F0DDA /* BolusRootView.swift */,
 				58237D9D2BCF0A6B00A47A79 /* PopupView.swift */,
+				BDB899872C564509006F3298 /* ForeCastChart.swift */,
+				BD0B2EF22C5998E600B3298F /* MealPresetView.swift */,
+				DDF847E52C5D66490049BB3B /* AddMealPresetView.swift */,
 			);
 			path = View;
 			sourceTree = "<group>";
@@ -3008,6 +3023,7 @@
 				DD57C4BD2C4C7103001A5B28 /* PumpEventStored+CoreDataProperties.swift in Sources */,
 				DD57C4BE2C4C7103001A5B28 /* TempBasalStored+CoreDataClass.swift in Sources */,
 				DD57C4BF2C4C7103001A5B28 /* TempBasalStored+CoreDataProperties.swift in Sources */,
+				BD0B2EF32C5998E600B3298F /* MealPresetView.swift in Sources */,
 				DD57C4C02C4C7103001A5B28 /* TempTargetsSlider+CoreDataClass.swift in Sources */,
 				DD57C4C12C4C7103001A5B28 /* TempTargetsSlider+CoreDataProperties.swift in Sources */,
 				DD57C4C22C4C7103001A5B28 /* Forecast+CoreDataClass.swift in Sources */,
@@ -3028,6 +3044,7 @@
 				DD57C4D12C4C7103001A5B28 /* ImportError+CoreDataProperties.swift in Sources */,
 				DD57C4D22C4C7103001A5B28 /* StatsData+CoreDataClass.swift in Sources */,
 				DD57C4D32C4C7103001A5B28 /* StatsData+CoreDataProperties.swift in Sources */,
+				DDF847E62C5D66490049BB3B /* AddMealPresetView.swift in Sources */,
 				3811DEAE25C9D88300A708ED /* Cache.swift in Sources */,
 				383420D625FFE38C002D46C1 /* LoopView.swift in Sources */,
 				3811DEAD25C9D88300A708ED /* UserDefaults+Cache.swift in Sources */,
@@ -3077,9 +3094,11 @@
 				6B1A8D2E2B156EEF00E76752 /* LiveActivityBridge.swift in Sources */,
 				581516A92BCEEDF800BF67D7 /* NSPredicates.swift in Sources */,
 				38DAB28A260D349500F74C1A /* FetchGlucoseManager.swift in Sources */,
+				BDB8998A2C565D0C006F3298 /* CarbsGlucose+helper.swift in Sources */,
 				38F37828261260DC009DB701 /* Color+Extensions.swift in Sources */,
 				BDCD47AF2C1F3F1700F8BCD5 /* OverrideStored+helper.swift in Sources */,
 				3811DE3F25C9D4A100A708ED /* SettingsStateModel.swift in Sources */,
+				C20BC6CE2C66FBFD002BC1C6 /* Rounding.swift in Sources */,
 				CE7CA3582A064E2F004BE681 /* ListStateView.swift in Sources */,
 				193F6CDD2A512C8F001240FD /* Loops.swift in Sources */,
 				38B4F3CB25E502E200E76A18 /* WeakObjectSet.swift in Sources */,
@@ -3107,6 +3126,7 @@
 				CE7CA3532A064973004BE681 /* tempPresetIntent.swift in Sources */,
 				D6DEC113821A7F1056C4AA1E /* NightscoutConfigDataFlow.swift in Sources */,
 				38E98A3025F52FF700C0CED0 /* Config.swift in Sources */,
+				BDB899882C564509006F3298 /* ForeCastChart.swift in Sources */,
 				110AEDE32C5193D200615CC9 /* BolusIntent.swift in Sources */,
 				DDD1631A2C4C695E00CD525A /* EditOverrideForm.swift in Sources */,
 				CE1856F72ADC4869007E39C7 /* CarbPresetIntentRequest.swift in Sources */,

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Resources/javascript/bundle/autosens.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Resources/javascript/bundle/autotune-prep.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Resources/javascript/bundle/iob.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Resources/javascript/bundle/meal.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Resources/javascript/bundle/profile.js


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

@@ -43,6 +43,7 @@
   "yGridLines" : true,
   "oneDimensionalGraph" : false,
   "rulerMarks" : true,
+  "displayForecastsAsLines": false,
   "maxCarbs": 250,
   "maxFat": 250,
   "maxProtein": 250,

+ 31 - 8
FreeAPS/Sources/APS/APSManager.swift

@@ -24,6 +24,7 @@ protocol APSManager {
     func makeProfiles() async throws -> Bool
     func determineBasal() async -> Bool
     func determineBasalSync() async
+    func simulateDetermineBasal(carbs: Decimal, iob: Decimal) async -> Determination?
     func roundBolus(amount: Decimal) -> Decimal
     var lastError: CurrentValueSubject<Error?, Never> { get }
     func cancelBolus() async
@@ -323,9 +324,9 @@ final class BaseAPSManager: APSManager, Injectable {
         return nil
     }
 
-    func autosens() async throws -> Bool {
-        guard let autosens = await storage.retrieveAsync(OpenAPS.Settings.autosense, as: Autosens.self),
-              (autosens.timestamp ?? .distantPast).addingTimeInterval(30.minutes.timeInterval) > Date()
+    func autosense() async throws -> Bool {
+        guard let autosense = await storage.retrieveAsync(OpenAPS.Settings.autosense, as: Autosens.self),
+              (autosense.timestamp ?? .distantPast).addingTimeInterval(30.minutes.timeInterval) > Date()
         else {
             let result = try await openAPS.autosense()
             return result != nil
@@ -372,11 +373,17 @@ final class BaseAPSManager: APSManager, Injectable {
 
         do {
             let now = Date()
-            let temp = await fetchCurrentTempBasal(date: now)
-            _ = try await makeProfiles()
-            _ = try await autosens()
-            _ = try await dailyAutotune()
-            let determination = try await openAPS.determineBasal(currentTemp: temp, clock: now)
+
+            // Start fetching asynchronously
+            let (currentTemp, profiles, autosense, dailyAutotune) = try await (
+                fetchCurrentTempBasal(date: now),
+                makeProfiles(),
+                autosense(),
+                dailyAutotune()
+            )
+
+            // Determine basal using the fetched temp and current time
+            let determination = try await openAPS.determineBasal(currentTemp: currentTemp, clock: now)
 
             if let determination = determination {
                 DispatchQueue.main.async {
@@ -398,6 +405,18 @@ final class BaseAPSManager: APSManager, Injectable {
         _ = await determineBasal()
     }
 
+    func simulateDetermineBasal(carbs: Decimal, iob: Decimal) async -> Determination? {
+        do {
+            let temp = await fetchCurrentTempBasal(date: Date.now)
+            return try await openAPS.determineBasal(currentTemp: temp, clock: Date(), carbs: carbs, iob: iob, simulation: true)
+        } catch {
+            debugPrint(
+                "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error occurred in invokeDummyDetermineBasalSync: \(error)"
+            )
+            return nil
+        }
+    }
+
     func makeProfiles() async throws -> Bool {
         let tunedProfile = await openAPS.makeProfiles(useAutotune: settings.useAutotune)
         if let basalProfile = tunedProfile?.basalProfile {
@@ -420,6 +439,10 @@ final class BaseAPSManager: APSManager, Injectable {
     private var bolusReporter: DoseProgressReporter?
 
     func enactBolus(amount: Double, isSMB: Bool) async {
+        if amount <= 0 {
+            return
+        }
+
         if let error = verifyStatus() {
             processError(error)
             processQueue.async {

+ 103 - 37
FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift

@@ -150,7 +150,7 @@ final class OpenAPS {
         }
     }
 
-    private func fetchAndProcessCarbs() async -> String {
+    private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil) async -> String {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,
@@ -163,10 +163,40 @@ final class OpenAPS {
             return ""
         }
 
-        // convert to JSON
-        return await context.perform {
-            return self.jsonConverter.convertToJSON(carbResults)
+        let json = await context.perform {
+            var jsonArray = self.jsonConverter.convertToJSON(carbResults)
+
+            if let additionalCarbs = additionalCarbs {
+                let additionalEntry = [
+                    "carbs": Double(additionalCarbs),
+                    "actualDate": ISO8601DateFormatter().string(from: Date()),
+                    "id": UUID().uuidString,
+                    "note": NSNull(),
+                    "protein": 0,
+                    "created_at": ISO8601DateFormatter().string(from: Date()),
+                    "isFPU": false,
+                    "fat": 0,
+                    "enteredBy": "Trio"
+                ] as [String: Any]
+
+                // Assuming jsonArray is a String, convert it to a list of dictionaries first
+                if let jsonData = jsonArray.data(using: .utf8) {
+                    var jsonList = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [[String: Any]]
+                    jsonList?.append(additionalEntry)
+
+                    // Convert back to JSON string
+                    if let updatedJsonData = try? JSONSerialization
+                        .data(withJSONObject: jsonList ?? [], options: .prettyPrinted)
+                    {
+                        jsonArray = String(data: updatedJsonData, encoding: .utf8) ?? jsonArray
+                    }
+                }
+            }
+
+            return jsonArray
         }
+
+        return json
     }
 
     private func fetchPumpHistoryObjectIDs() async -> [NSManagedObjectID]? {
@@ -188,32 +218,19 @@ final class OpenAPS {
         }
     }
 
-    private func parsePumpHistory(_ pumpHistoryObjectIDs: [NSManagedObjectID]) async -> String {
+    private func parsePumpHistory(_ pumpHistoryObjectIDs: [NSManagedObjectID], iob: Decimal? = nil) async -> String {
         // Return an empty JSON object if the list of object IDs is empty
         guard !pumpHistoryObjectIDs.isEmpty else { return "{}" }
 
         // Execute all operations on the background context
         return await context.perform {
-            // Load the pump events from the object IDs
-            let pumpHistory: [PumpEventStored] = pumpHistoryObjectIDs
-                .compactMap { self.context.object(with: $0) as? PumpEventStored }
-
-            // Create the DTOs
-            let dtos: [PumpEventDTO] = pumpHistory.flatMap { event -> [PumpEventDTO] in
-                var eventDTOs: [PumpEventDTO] = []
-                if let bolusDTO = event.toBolusDTOEnum() {
-                    eventDTOs.append(bolusDTO)
-                }
+            // Load and map pump events to DTOs
+            var dtos = self.loadAndMapPumpEvents(pumpHistoryObjectIDs)
 
-                if let tempBasalDurationDTO = event.toTempBasalDurationDTOEnum() {
-                    eventDTOs.append(tempBasalDurationDTO)
-                }
-
-                if let tempBasalDTO = event.toTempBasalDTOEnum() {
-                    eventDTOs.append(tempBasalDTO)
-                }
-
-                return eventDTOs
+            // Optionally add the IOB as a DTO
+            if let iob = iob {
+                let iobDTO = self.createIOBDTO(iob: iob)
+                dtos.insert(iobDTO, at: 0)
             }
 
             // Convert the DTOs to JSON
@@ -221,18 +238,64 @@ final class OpenAPS {
         }
     }
 
-    func determineBasal(currentTemp: TempBasal, clock: Date = Date()) async throws -> Determination? {
-        debug(.openAPS, "Start determineBasal")
+    private func loadAndMapPumpEvents(_ pumpHistoryObjectIDs: [NSManagedObjectID]) -> [PumpEventDTO] {
+        // Load the pump events from the object IDs
+        let pumpHistory: [PumpEventStored] = pumpHistoryObjectIDs
+            .compactMap { self.context.object(with: $0) as? PumpEventStored }
+
+        // Create the DTOs
+        let dtos: [PumpEventDTO] = pumpHistory.flatMap { event -> [PumpEventDTO] in
+            var eventDTOs: [PumpEventDTO] = []
+            if let bolusDTO = event.toBolusDTOEnum() {
+                eventDTOs.append(bolusDTO)
+            }
+            if let tempBasalDTO = event.toTempBasalDTOEnum() {
+                eventDTOs.append(tempBasalDTO)
+            }
+            if let tempBasalDurationDTO = event.toTempBasalDurationDTOEnum() {
+                eventDTOs.append(tempBasalDurationDTO)
+            }
+            return eventDTOs
+        }
+        return dtos
+    }
 
-        // clock
-        self.storage.save(clock, as: Monitor.clock)
+    private func createIOBDTO(iob: Decimal) -> PumpEventDTO {
+        let oneSecondAgo = Calendar.current
+            .date(
+                byAdding: .second,
+                value: -1,
+                to: Date()
+            )! // adding -1s to the current Date ensures that oref actually uses the mock entry to calculate iob and not guard it away
+        let dateFormatted = PumpEventStored.dateFormatter.string(from: oneSecondAgo)
+
+        let bolusDTO = BolusDTO(
+            id: UUID().uuidString,
+            timestamp: dateFormatted,
+            amount: Double(iob),
+            isExternal: false,
+            isSMB: true,
+            duration: 0,
+            _type: "Bolus"
+        )
+        return .bolus(bolusDTO)
+    }
+
+    func determineBasal(
+        currentTemp: TempBasal,
+        clock: Date = Date(),
+        carbs: Decimal? = nil,
+        iob: Decimal? = nil,
+        simulation: Bool = false
+    ) async throws -> Determination? {
+        debug(.openAPS, "Start determineBasal")
 
         // temp_basal
         let tempBasal = currentTemp.rawJSON
 
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
-        async let carbs = fetchAndProcessCarbs()
+        async let carbs = fetchAndProcessCarbs(additionalCarbs: carbs ?? 0)
         async let glucose = fetchAndProcessGlucose()
         async let oref2 = oref2()
         async let profileAsync = loadFileFromStorageAsync(name: Settings.profile)
@@ -253,7 +316,7 @@ final class OpenAPS {
             reservoir,
             preferences
         ) = await (
-            parsePumpHistory(await pumpHistoryObjectIDs),
+            parsePumpHistory(await pumpHistoryObjectIDs, iob: iob),
             carbs,
             glucose,
             oref2,
@@ -264,8 +327,7 @@ final class OpenAPS {
             preferencesAsync
         )
 
-        // TODO: - Save and fetch profile/basalProfile in/from UserDefaults!
-        // Meal
+        // Meal calculation
         let meal = try await self.meal(
             pumphistory: pumpHistoryJSON,
             profile: profile,
@@ -275,7 +337,7 @@ final class OpenAPS {
             glucose: glucoseAsJSON
         )
 
-        // IOB
+        // IOB calculation
         let iob = try await self.iob(
             pumphistory: pumpHistoryJSON,
             profile: profile,
@@ -284,7 +346,9 @@ final class OpenAPS {
         )
 
         // TODO: refactor this to core data
-        storage.save(iob, as: Monitor.iob)
+        if !simulation {
+            storage.save(iob, as: Monitor.iob)
+        }
 
         // Determine basal
         let orefDetermination = try await determineBasal(
@@ -309,8 +373,10 @@ final class OpenAPS {
             // AAPS does it the same way! we'll follow their example!
             determination.timestamp = deliverAt
 
-            // save to core data asynchronously
-            await processDetermination(determination)
+            if !simulation {
+                // save to core data asynchronously
+                await processDetermination(determination)
+            }
 
             return determination
         } else {
@@ -546,7 +612,7 @@ final class OpenAPS {
         debug(.openAPS, "AUTOSENS: \(autosenseResult)")
         if var autosens = Autosens(from: autosenseResult) {
             autosens.timestamp = Date()
-            storage.save(autosens, as: Settings.autosense)
+            await storage.saveAsync(autosens, as: Settings.autosense)
 
             return autosens
         } else {

+ 2 - 0
FreeAPS/Sources/APS/Storage/CarbsStorage.swift

@@ -156,6 +156,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
             newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0))
             newItem.protein = Double(truncating: NSDecimalNumber(decimal: entry.protein ?? 0))
+            newItem.note = entry.note
             newItem.id = UUID()
             newItem.isFPU = false
             newItem.isUploadedToNS = false
@@ -264,6 +265,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                     enteredBy: CarbsEntry.manual,
                     bolus: nil,
                     insulin: nil,
+                    notes: result.note,
                     carbs: Decimal(result.carbs),
                     fat: Decimal(result.fat),
                     protein: Decimal(result.protein),

+ 19 - 10
FreeAPS/Sources/APS/Storage/DeterminationStorage.swift

@@ -78,25 +78,32 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
     }
 
     // Convert NSSet to array of Ints for Predictions
-    func parseForecastValues(ofType _: String, from determinationID: NSManagedObjectID) async -> [Int]? {
+    func parseForecastValues(ofType type: String, from determinationID: NSManagedObjectID) async -> [Int]? {
         let forecastIDs = await getForecastIDs(for: determinationID, in: backgroundContext)
 
         var forecastValuesList: [Int] = []
 
         for forecastID in forecastIDs {
-            let forecastValueIDs = await getForecastValueIDs(for: forecastID, in: backgroundContext)
-
             await backgroundContext.perform {
-                for forecastValueID in forecastValueIDs {
-                    if let forecastValue = try? self.backgroundContext.existingObject(with: forecastValueID) as? ForecastValue {
-                        let forecastValueInt = Int(forecastValue.value)
-                        forecastValuesList.append(forecastValueInt)
+                if let forecast = try? self.backgroundContext.existingObject(with: forecastID) as? Forecast {
+                    // Filter the forecast based on the type
+                    if forecast.type == type {
+                        let forecastValueIDs = forecast.forecastValues?.sorted(by: { $0.index < $1.index }).map(\.objectID) ?? []
+
+                        for forecastValueID in forecastValueIDs {
+                            if let forecastValue = try? self.backgroundContext
+                                .existingObject(with: forecastValueID) as? ForecastValue
+                            {
+                                let forecastValueInt = Int(forecastValue.value)
+                                forecastValuesList.append(forecastValueInt)
+                            }
+                        }
                     }
                 }
             }
         }
 
-        return forecastValuesList
+        return forecastValuesList.isEmpty ? nil : forecastValuesList
     }
 
     func getOrefDeterminationNotYetUploadedToNightscout(_ determinationIds: [NSManagedObjectID]) async -> Determination? {
@@ -155,8 +162,10 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
                     )
                 }
             } catch {
-                debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch managed object with error: \(error.localizedDescription)")
-             }
+                debugPrint(
+                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch managed object with error: \(error.localizedDescription)"
+                )
+            }
 
             return result
         }

+ 6 - 0
FreeAPS/Sources/APS/Storage/GlucoseStorage.swift

@@ -7,6 +7,7 @@ import Swinject
 
 protocol GlucoseStorage {
     func storeGlucose(_ glucose: [BloodGlucose])
+    func isGlucoseDataFresh(_ glucoseDate: Date?) -> Bool
     func syncDate() -> Date
     func filterTooFrequentGlucose(_ glucose: [BloodGlucose], at: Date) -> [BloodGlucose]
     func lastGlucoseDate() -> Date
@@ -158,6 +159,11 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         }
     }
 
+    func isGlucoseDataFresh(_ glucoseDate: Date?) -> Bool {
+        guard let glucoseDate = glucoseDate else { return false }
+        return glucoseDate > Date().addingTimeInterval(-6 * 60)
+    }
+
     func syncDate() -> Date {
         let fr = GlucoseStored.fetchRequest()
         fr.predicate = NSPredicate.predicateForOneDayAgo

+ 8 - 0
FreeAPS/Sources/Helpers/Rounding.swift

@@ -0,0 +1,8 @@
+import Foundation
+
+func rounded(_ value: Decimal, scale: Int, roundingMode: NSDecimalNumber.RoundingMode) -> Decimal {
+    var result = Decimal()
+    var toRound = value
+    NSDecimalRound(&result, &toRound, scale, roundingMode)
+    return result
+}

+ 28 - 5
FreeAPS/Sources/Models/BloodGlucose.swift

@@ -107,28 +107,51 @@ enum GlucoseUnits: String, JSON, Equatable {
 
 extension Int {
     var asMmolL: Decimal {
-        Decimal(self) * GlucoseUnits.exchangeRate
+        FreeAPS.rounded(Decimal(self) * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain)
+    }
+
+    var formattedAsMmolL: String {
+        NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
     }
 }
 
 extension Decimal {
     var asMmolL: Decimal {
-        self * GlucoseUnits.exchangeRate
+        FreeAPS.rounded(self * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain)
     }
 
     var asMgdL: Decimal {
-        self / GlucoseUnits.exchangeRate
+        FreeAPS.rounded(self / GlucoseUnits.exchangeRate, scale: 0, roundingMode: .plain)
+    }
+
+    var formattedAsMmolL: String {
+        NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
     }
 }
 
 extension Double {
     var asMmolL: Decimal {
-        Decimal(self) * GlucoseUnits.exchangeRate
+        FreeAPS.rounded(Decimal(self) * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain)
     }
 
     var asMgdL: Decimal {
-        Decimal(self) / GlucoseUnits.exchangeRate
+        FreeAPS.rounded(Decimal(self) / GlucoseUnits.exchangeRate, scale: 0, roundingMode: .plain)
     }
+
+    var formattedAsMmolL: String {
+        NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
+    }
+}
+
+extension NumberFormatter {
+    static let glucoseFormatter: NumberFormatter = {
+        let formatter = NumberFormatter()
+        formatter.locale = Locale.current
+        formatter.numberStyle = .decimal
+        formatter.minimumFractionDigits = 1
+        formatter.maximumFractionDigits = 1
+        return formatter
+    }()
 }
 
 extension BloodGlucose: SavitzkyGolaySmoothable {

+ 5 - 0
FreeAPS/Sources/Models/FreeAPSSettings.swift

@@ -59,6 +59,7 @@ struct FreeAPSSettings: JSON, Equatable {
     var yGridLines: Bool = true
     var oneDimensionalGraph: Bool = false
     var rulerMarks: Bool = true
+    var displayForecastsAsLines: Bool = false
     var maxCarbs: Decimal = 250
     var maxFat: Decimal = 250
     var maxProtein: Decimal = 250
@@ -281,6 +282,10 @@ extension FreeAPSSettings: Decodable {
             settings.rulerMarks = rulerMarks
         }
 
+        if let displayForecastsAsLines = try? container.decode(Bool.self, forKey: .displayForecastsAsLines) {
+            settings.displayForecastsAsLines = displayForecastsAsLines
+        }
+
         if let overrideHbA1cUnit = try? container.decode(Bool.self, forKey: .overrideHbA1cUnit) {
             settings.overrideHbA1cUnit = overrideHbA1cUnit
         }

+ 8 - 8
FreeAPS/Sources/Modules/AutotuneConfig/View/AutotuneConfigRootView.swift

@@ -118,14 +118,14 @@ extension AutotuneConfig {
                             .foregroundColor(.red)
                     }
 
-                    Section {
-                        Button {
-                            replaceAlert = true
-                        }
-                        label: { Text("Save as your Normal Basal Rates") }
-                    } header: {
-                        Text("Replace Normal Basal")
-                    }
+                    /* Section {
+                         Button {
+                             replaceAlert = true
+                         }
+                         label: { Text("Save as your Normal Basal Rates") }
+                     } header: {
+                         Text("Replace Normal Basal")
+                     } */
                 }
             }
             .scrollContentBackground(.hidden).background(color)

+ 5 - 1
FreeAPS/Sources/Modules/Bolus/BolusDataFlow.swift

@@ -3,5 +3,9 @@ enum Bolus {
 }
 
 protocol BolusProvider: Provider {
-    func pumpSettings() -> PumpSettings
+    func getPumpSettings() async -> PumpSettings
+    func getBasalProfile() async -> [BasalProfileEntry]
+    func getCarbRatios() async -> CarbRatios
+    func getBGTarget() async -> BGTargets
+    func getISFValues() async -> InsulinSensitivities
 }

+ 26 - 4
FreeAPS/Sources/Modules/Bolus/BolusProvider.swift

@@ -1,15 +1,37 @@
 extension Bolus {
     final class Provider: BaseProvider, BolusProvider {
-        func pumpSettings() -> PumpSettings {
-            storage.retrieve(OpenAPS.Settings.settings, as: PumpSettings.self)
+        func getPumpSettings() async -> PumpSettings {
+            await storage.retrieveAsync(OpenAPS.Settings.settings, as: PumpSettings.self)
                 ?? PumpSettings(from: OpenAPS.defaults(for: OpenAPS.Settings.settings))
                 ?? PumpSettings(insulinActionCurve: 6, maxBolus: 10, maxBasal: 2)
         }
 
-        func getProfile() -> [BasalProfileEntry] {
-            storage.retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
+        func getBasalProfile() async -> [BasalProfileEntry] {
+            await storage.retrieveAsync(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
                 ?? [BasalProfileEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.basalProfile))
                 ?? []
         }
+
+        func getCarbRatios() async -> CarbRatios {
+            await storage.retrieveAsync(OpenAPS.Settings.carbRatios, as: CarbRatios.self)
+                ?? CarbRatios(from: OpenAPS.defaults(for: OpenAPS.Settings.carbRatios))
+                ?? CarbRatios(units: .grams, schedule: [])
+        }
+
+        func getBGTarget() async -> BGTargets {
+            await storage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
+                ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
+                ?? BGTargets(units: .mgdL, userPrefferedUnits: .mgdL, targets: [])
+        }
+
+        func getISFValues() async -> InsulinSensitivities {
+            await storage.retrieveAsync(OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self)
+                ?? InsulinSensitivities(from: OpenAPS.defaults(for: OpenAPS.Settings.insulinSensitivities))
+                ?? InsulinSensitivities(
+                    units: .mgdL,
+                    userPrefferedUnits: .mgdL,
+                    sensitivities: []
+                )
+        }
     }
 }

+ 278 - 183
FreeAPS/Sources/Modules/Bolus/BolusStateModel.swift

@@ -17,6 +17,9 @@ extension Bolus {
         @Injected() var glucoseStorage: GlucoseStorage!
         @Injected() var determinationStorage: DeterminationStorage!
 
+        @Published var lowGlucose: Decimal = 70
+        @Published var highGlucose: Decimal = 180
+
         @Published var predictions: Predictions?
         @Published var amount: Decimal = 0
         @Published var insulinRecommended: Decimal = 0
@@ -66,6 +69,10 @@ extension Bolus {
         @Published var displayPresets: Bool = true
 
         @Published var currentBasal: Decimal = 0
+        @Published var currentCarbRatio: Decimal = 0
+        @Published var currentBGTarget: Decimal = 0
+        @Published var currentISF: Decimal = 0
+
         @Published var sweetMeals: Bool = false
         @Published var sweetMealFactor: Decimal = 0
         @Published var useSuperBolus: Bool = false
@@ -96,10 +103,20 @@ extension Bolus {
         @Published var showInfo: Bool = false
         @Published var glucoseFromPersistence: [GlucoseStored] = []
         @Published var determination: [OrefDetermination] = []
+        @Published var preprocessedData: [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)] = []
+        @Published var predictionsForChart: Predictions?
+        @Published var simulatedDetermination: Determination?
+        @Published var determinationObjectIDs: [NSManagedObjectID] = []
+
+        @Published var minForecast: [Int] = []
+        @Published var maxForecast: [Int] = []
+        @Published var minCount: Int = 12 // count of Forecasts drawn in 5 min distances, i.e. 12 means a min of 1 hour
+        @Published var displayForecastsAsLines: Bool = false
+        @Published var smooth: Bool = false
 
         let now = Date.now
 
-        let context = CoreDataStack.shared.persistentContainer.viewContext
+        let viewContext = CoreDataStack.shared.persistentContainer.viewContext
         let backgroundContext = CoreDataStack.shared.newTaskContext()
 
         private var coreDataObserver: CoreDataObserver?
@@ -110,16 +127,24 @@ extension Bolus {
             setupGlucoseNotification()
             coreDataObserver = CoreDataObserver()
             registerHandlers()
-
             setupGlucoseArray()
-            setupDeterminationsArray()
+
+            Task {
+                async let getAllSettingsDefaults: () = getAllSettingsValues()
+                async let setupDeterminations: () = setupDeterminationsArray()
+
+                await getAllSettingsDefaults
+                await setupDeterminations
+
+                // Determination has updated, so we can use this to draw the initial Forecast Chart
+                let forecastData = await mapForecastsForChart()
+                await updateForecasts(with: forecastData)
+            }
 
             broadcaster.register(DeterminationObserver.self, observer: self)
             broadcaster.register(BolusFailureObserver.self, observer: self)
             units = settingsManager.settings.units
             percentage = settingsManager.settings.insulinReqPercentage
-            maxBolus = provider.pumpSettings().maxBolus
-            // added
             fraction = settings.settings.overrideFactor
             useCalc = settings.settings.useCalc
             fattyMeals = settings.settings.fattyMeals
@@ -128,11 +153,17 @@ extension Bolus {
             sweetMealFactor = settings.settings.sweetMealFactor
             displayPresets = settings.settings.displayPresets
 
+            displayForecastsAsLines = settings.settings.displayForecastsAsLines
+
+            lowGlucose = units == .mgdL ? settingsManager.settings.low : settingsManager.settings.low.asMmolL
+            highGlucose = units == .mgdL ? settingsManager.settings.high : settingsManager.settings.high.asMmolL
+
             maxCarbs = settings.settings.maxCarbs
             maxFat = settings.settings.maxFat
             maxProtein = settings.settings.maxProtein
             skipBolus = settingsManager.settings.skipBolusScreenAfterCarbs
             useFPUconversion = settingsManager.settings.useFPUconversion
+            smooth = settingsManager.settings.smoothGlucose
 
             if waitForSuggestionInitial {
                 Task {
@@ -148,47 +179,103 @@ extension Bolus {
 
         // MARK: - Basal
 
-        func getCurrentBasal() {
-            let basalEntries = provider.getProfile()
+        private enum SettingType {
+            case basal
+            case carbRatio
+            case bgTarget
+            case isf
+        }
+
+        func getAllSettingsValues() async {
+            await withTaskGroup(of: Void.self) { group in
+                group.addTask {
+                    await self.getCurrentSettingValue(for: .basal)
+                }
+                group.addTask {
+                    await self.getCurrentSettingValue(for: .carbRatio)
+                }
+                group.addTask {
+                    await self.getCurrentSettingValue(for: .bgTarget)
+                }
+                group.addTask {
+                    await self.getCurrentSettingValue(for: .isf)
+                }
+                group.addTask {
+                    let getMaxBolus = await self.provider.getPumpSettings().maxBolus
+                    await MainActor.run {
+                        self.maxBolus = getMaxBolus
+                    }
+                }
+            }
+        }
+
+        private func getCurrentSettingValue(for type: SettingType) async {
             let now = Date()
             let calendar = Calendar.current
             let dateFormatter = DateFormatter()
             dateFormatter.dateFormat = "HH:mm:ss"
             dateFormatter.timeZone = TimeZone.current
 
-            for (index, entry) in basalEntries.enumerated() {
+            let entries: [(start: String, value: Decimal)]
+
+            switch type {
+            case .basal:
+                let basalEntries = await provider.getBasalProfile()
+                entries = basalEntries.map { ($0.start, $0.rate) }
+            case .carbRatio:
+                let carbRatios = await provider.getCarbRatios()
+                entries = carbRatios.schedule.map { ($0.start, $0.ratio) }
+            case .bgTarget:
+                let bgTargets = await provider.getBGTarget()
+                entries = bgTargets.targets.map { ($0.start, $0.low) }
+            case .isf:
+                let isfValues = await provider.getISFValues()
+                entries = isfValues.sensitivities.map { ($0.start, $0.sensitivity) }
+            }
+
+            for (index, entry) in entries.enumerated() {
                 guard let entryTime = dateFormatter.date(from: entry.start) else {
                     print("Invalid entry start time: \(entry.start)")
                     continue
                 }
 
-                // Combine the current date with the time from entry.start
+                let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
                 let entryStartTime = calendar.date(
-                    bySettingHour: calendar.component(.hour, from: entryTime),
-                    minute: calendar.component(.minute, from: entryTime),
-                    second: calendar.component(.second, from: entryTime),
+                    bySettingHour: entryComponents.hour!,
+                    minute: entryComponents.minute!,
+                    second: entryComponents.second!,
                     of: now
                 )!
 
                 let entryEndTime: Date
-                if index < basalEntries.count - 1,
-                   let nextEntryTime = dateFormatter.date(from: basalEntries[index + 1].start)
+                if index < entries.count - 1,
+                   let nextEntryTime = dateFormatter.date(from: entries[index + 1].start)
                 {
-                    let nextEntryStartTime = calendar.date(
-                        bySettingHour: calendar.component(.hour, from: nextEntryTime),
-                        minute: calendar.component(.minute, from: nextEntryTime),
-                        second: calendar.component(.second, from: nextEntryTime),
+                    let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
+                    entryEndTime = calendar.date(
+                        bySettingHour: nextEntryComponents.hour!,
+                        minute: nextEntryComponents.minute!,
+                        second: nextEntryComponents.second!,
                         of: now
                     )!
-                    entryEndTime = nextEntryStartTime
                 } else {
-                    // If it's the last entry, use the same start time plus one day as the end time
                     entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
                 }
 
                 if now >= entryStartTime, now < entryEndTime {
-                    currentBasal = entry.rate
-                    break
+                    await MainActor.run {
+                        switch type {
+                        case .basal:
+                            currentBasal = entry.value
+                        case .carbRatio:
+                            currentCarbRatio = entry.value
+                        case .bgTarget:
+                            currentBGTarget = entry.value
+                        case .isf:
+                            currentISF = entry.value
+                        }
+                    }
+                    return
                 }
             }
         }
@@ -197,11 +284,7 @@ extension Bolus {
 
         /// Calculate insulin recommendation
         func calculateInsulin() -> Decimal {
-            // ensure that isf is in mg/dL
-            var conversion: Decimal {
-                units == .mmolL ? 0.0555 : 1
-            }
-            let isfForCalculation = isf / conversion
+            let isfForCalculation = units == .mmolL ? isf.asMgdL : isf
 
             // insulin needed for the current blood glucose
             targetDifference = currentBG - target
@@ -281,9 +364,10 @@ extension Bolus {
                 await saveMeal()
 
                 // if glucose data is stale end the custom loading animation by hiding the modal
-//                guard glucoseOfLast20Min.first?.date ?? now >= Date().addingTimeInterval(-12.minutes.timeInterval) else {
-//                    return hideModal()
-//                }
+                guard glucoseStorage.isGlucoseDataFresh(glucoseFromPersistence.first?.date) else {
+                    waitForSuggestion = false
+                    return hideModal()
+                }
             }
         }
 
@@ -327,29 +411,6 @@ extension Bolus {
             }
         }
 
-        private func savePumpInsulin(amount _: Decimal) {
-            context.perform {
-                // create pump event
-                let newPumpEvent = PumpEventStored(context: self.context)
-                newPumpEvent.timestamp = Date()
-                newPumpEvent.type = PumpEvent.bolus.rawValue
-
-                // create bolus entry and specify relationship to pump event
-                let newBolusEntry = BolusStored(context: self.context)
-                newBolusEntry.pumpEvent = newPumpEvent
-                newBolusEntry.amount = self.amount as NSDecimalNumber
-                newBolusEntry.isExternal = false
-                newBolusEntry.isSMB = false
-
-                do {
-                    guard self.context.hasChanges else { return }
-                    try self.context.save()
-                } catch {
-                    print(error.localizedDescription)
-                }
-            }
-        }
-
         // MARK: - EXTERNAL INSULIN
 
         func addExternalInsulin() async {
@@ -389,11 +450,11 @@ extension Bolus {
             guard carbs > 0 || fat > 0 || protein > 0 else { return }
 
             await MainActor.run {
-                   self.carbs = min(self.carbs, self.maxCarbs)
-                    self.fat = min(self.fat, self.maxFat)
-                    self.protein = min(self.protein, self.maxProtein)
-                   self.id_ = UUID().uuidString
-               }
+                self.carbs = min(self.carbs, self.maxCarbs)
+                self.fat = min(self.fat, self.maxFat)
+                self.protein = min(self.protein, self.maxProtein)
+                self.id_ = UUID().uuidString
+            }
 
             let carbsToStore = [CarbsEntry(
                 id: id_,
@@ -420,11 +481,11 @@ extension Bolus {
 
         func deletePreset() {
             if selection != nil {
-                context.delete(selection!)
+                viewContext.delete(selection!)
 
                 do {
-                    guard context.hasChanges else { return }
-                    try context.save()
+                    guard viewContext.hasChanges else { return }
+                    try viewContext.save()
                 } catch {
                     print(error.localizedDescription)
                 }
@@ -456,79 +517,6 @@ extension Bolus {
         func addToSummation() {
             summation.append(selection?.dish ?? "")
         }
-
-        func waitersNotepad() -> String {
-            var filteredArray = summation.filter { !$0.isEmpty }
-
-            if carbs == 0, protein == 0, fat == 0 {
-                filteredArray = []
-            }
-
-            guard filteredArray != [] else {
-                return ""
-            }
-            var carbs_: Decimal = 0.0
-            var fat_: Decimal = 0.0
-            var protein_: Decimal = 0.0
-            var presetArray = [MealPresetStored]()
-
-            context.performAndWait {
-                let requestPresets = MealPresetStored.fetchRequest() as NSFetchRequest<MealPresetStored>
-                try? presetArray = context.fetch(requestPresets)
-            }
-            var waitersNotepad = [String]()
-            var stringValue = ""
-
-            for each in filteredArray {
-                let countedSet = NSCountedSet(array: filteredArray)
-                let count = countedSet.count(for: each)
-                if each != stringValue {
-                    waitersNotepad.append("\(count) \(each)")
-                }
-                stringValue = each
-
-                for sel in presetArray {
-                    if sel.dish == each {
-                        carbs_ += (sel.carbs)! as Decimal
-                        fat_ += (sel.fat)! as Decimal
-                        protein_ += (sel.protein)! as Decimal
-                        break
-                    }
-                }
-            }
-            let extracarbs = carbs - carbs_
-            let extraFat = fat - fat_
-            let extraProtein = protein - protein_
-            var addedString = ""
-
-            if extracarbs > 0, filteredArray.isNotEmpty {
-                addedString += "Additional carbs: \(extracarbs) ,"
-            } else if extracarbs < 0 { addedString += "Removed carbs: \(extracarbs) " }
-
-            if extraFat > 0, filteredArray.isNotEmpty {
-                addedString += "Additional fat: \(extraFat) ,"
-            } else if extraFat < 0 { addedString += "Removed fat: \(extraFat) ," }
-
-            if extraProtein > 0, filteredArray.isNotEmpty {
-                addedString += "Additional protein: \(extraProtein) ,"
-            } else if extraProtein < 0 { addedString += "Removed protein: \(extraProtein) ," }
-
-            if addedString != "" {
-                waitersNotepad.append(addedString)
-            }
-            var waitersNotepadString = ""
-
-            if waitersNotepad.count == 1 {
-                waitersNotepadString = waitersNotepad[0]
-            } else if waitersNotepad.count > 1 {
-                for each in waitersNotepad {
-                    if each != waitersNotepad.last {
-                        waitersNotepadString += " " + each + ","
-                    } else { waitersNotepadString += " " + each }
-                }
-            }
-            return waitersNotepadString
-        }
     }
 }
 
@@ -556,7 +544,10 @@ extension Bolus.StateModel {
     private func registerHandlers() {
         coreDataObserver?.registerHandler(for: "OrefDetermination") { [weak self] in
             guard let self = self else { return }
-            self.setupDeterminationsArray()
+            Task {
+                await self.setupDeterminationsArray()
+                await self.updateForecasts()
+            }
         }
 
         // Due to the Batch insert this only is used for observing Deletion of Glucose entries
@@ -588,7 +579,8 @@ extension Bolus.StateModel {
     private func setupGlucoseArray() {
         Task {
             let ids = await self.fetchGlucose()
-            await updateGlucoseArray(with: ids)
+            let glucoseObjects: [GlucoseStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateGlucoseArray(with: glucoseObjects)
         }
     }
 
@@ -596,10 +588,10 @@ extension Bolus.StateModel {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: backgroundContext,
-            predicate: NSPredicate.predicateFor30MinAgo,
+            predicate: NSPredicate.glucose,
             key: "date",
             ascending: false,
-            fetchLimit: 3
+            fetchLimit: 288
         )
 
         guard let fetchedResults = results as? [GlucoseStored] else { return [] }
@@ -609,61 +601,164 @@ extension Bolus.StateModel {
         }
     }
 
-    @MainActor private func updateGlucoseArray(with IDs: [NSManagedObjectID]) {
-        do {
-            let glucoseObjects = try IDs.compactMap { id in
-                try context.existingObject(with: id) as? GlucoseStored
-            }
-            glucoseFromPersistence = glucoseObjects
+    @MainActor private func updateGlucoseArray(with objects: [GlucoseStored]) {
+        glucoseFromPersistence = objects
 
-            let lastGlucose = glucoseFromPersistence.first?.glucose ?? 0
-            let thirdLastGlucose = glucoseFromPersistence.last?.glucose ?? 0
-            let delta = Decimal(lastGlucose) - Decimal(thirdLastGlucose)
+        let lastGlucose = glucoseFromPersistence.first?.glucose ?? 0
+        let thirdLastGlucose = glucoseFromPersistence.dropFirst(2).first?.glucose ?? 0
+        let delta = Decimal(lastGlucose) - Decimal(thirdLastGlucose)
 
-            currentBG = Decimal(lastGlucose)
-            deltaBG = delta
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the glucose array: \(error.localizedDescription)"
-            )
-        }
+        currentBG = Decimal(lastGlucose)
+        deltaBG = delta
     }
 
     // Determinations
-    private func setupDeterminationsArray() {
-        Task {
-            let ids = await determinationStorage.fetchLastDeterminationObjectID(
-                predicate: NSPredicate.predicateFor30MinAgoForDetermination
-            )
-            await updateDeterminationsArray(with: ids)
+    private func setupDeterminationsArray() async {
+        // Fetch object IDs on a background thread
+        let fetchedObjectIDs = await determinationStorage.fetchLastDeterminationObjectID(
+            predicate: NSPredicate.predicateFor30MinAgoForDetermination
+        )
+
+        // Update determinationObjectIDs on the main thread
+        await MainActor.run {
+            determinationObjectIDs = fetchedObjectIDs
         }
+
+        let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
+            .getNSManagedObject(with: determinationObjectIDs, context: viewContext)
+
+        await updateDeterminationsArray(with: determinationObjects)
     }
 
-    @MainActor private func updateDeterminationsArray(with IDs: [NSManagedObjectID]) {
-        do {
-            let determinationObjects = try IDs.compactMap { id in
-                try context.existingObject(with: id) as? OrefDetermination
+    private func mapForecastsForChart() async -> Determination? {
+        let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
+            .getNSManagedObject(with: determinationObjectIDs, context: backgroundContext)
+
+        return await backgroundContext.perform {
+            guard let determinationObject = determinationObjects.first else {
+                return nil
             }
-            guard let mostRecentDetermination = determinationObjects.first else { return }
-            determination = determinationObjects
-
-            // setup vars for bolus calculation
-            insulinRequired = (mostRecentDetermination.insulinReq ?? 0) as Decimal
-            evBG = (mostRecentDetermination.eventualBG ?? 0) as Decimal
-            insulin = (mostRecentDetermination.insulinForManualBolus ?? 0) as Decimal
-            target = (mostRecentDetermination.currentTarget ?? 100) as Decimal
-            isf = (mostRecentDetermination.insulinSensitivity ?? 0) as Decimal
-            cob = mostRecentDetermination.cob as Int16
-            iob = (mostRecentDetermination.iob ?? 0) as Decimal
-            basal = (mostRecentDetermination.tempBasal ?? 0) as Decimal
-            carbRatio = (mostRecentDetermination.carbRatio ?? 0) as Decimal
-
-            getCurrentBasal()
-            insulinCalculated = calculateInsulin()
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the determinations array: \(error.localizedDescription)"
+
+            let eventualBG = determinationObject.eventualBG?.intValue
+
+            let forecastsSet = determinationObject.forecasts as? Set<Forecast> ?? []
+            let predictions = Predictions(
+                iob: forecastsSet.extractValues(for: "iob"),
+                zt: forecastsSet.extractValues(for: "zt"),
+                cob: forecastsSet.extractValues(for: "cob"),
+                uam: forecastsSet.extractValues(for: "uam")
             )
+
+            return Determination(
+                id: UUID(),
+                reason: "",
+                units: 0,
+                insulinReq: 0,
+                eventualBG: eventualBG,
+                sensitivityRatio: 0,
+                rate: 0,
+                duration: 0,
+                iob: 0,
+                cob: 0,
+                predictions: predictions.isEmpty ? nil : predictions,
+                carbsReq: 0,
+                temp: nil,
+                bg: 0,
+                reservoir: 0,
+                isf: 0,
+                tdd: 0,
+                insulin: nil,
+                current_target: 0,
+                insulinForManualBolus: 0,
+                manualBolusErrorString: 0,
+                minDelta: 0,
+                expectedDelta: 0,
+                minGuardBG: 0,
+                minPredBG: 0,
+                threshold: 0,
+                carbRatio: 0,
+                received: false
+            )
+        }
+    }
+
+    @MainActor private func updateDeterminationsArray(with objects: [OrefDetermination]) {
+        guard let mostRecentDetermination = objects.first else { return }
+        determination = objects
+
+        // setup vars for bolus calculation
+        insulinRequired = (mostRecentDetermination.insulinReq ?? 0) as Decimal
+        evBG = (mostRecentDetermination.eventualBG ?? 0) as Decimal
+        insulin = (mostRecentDetermination.insulinForManualBolus ?? 0) as Decimal
+        target = (mostRecentDetermination.currentTarget ?? currentBGTarget as NSDecimalNumber) as Decimal
+        isf = (mostRecentDetermination.insulinSensitivity ?? currentISF as NSDecimalNumber) as Decimal
+        cob = mostRecentDetermination.cob as Int16
+        iob = (mostRecentDetermination.iob ?? 0) as Decimal
+        basal = (mostRecentDetermination.tempBasal ?? 0) as Decimal
+        carbRatio = (mostRecentDetermination.carbRatio ?? currentCarbRatio as NSDecimalNumber) as Decimal
+        insulinCalculated = calculateInsulin()
+    }
+}
+
+extension Bolus.StateModel {
+    @MainActor func updateForecasts(with forecastData: Determination? = nil) async {
+        if let forecastData = forecastData {
+            simulatedDetermination = forecastData
+        } else {
+            simulatedDetermination = await Task.detached { [self] in
+                await apsManager.simulateDetermineBasal(carbs: carbs, iob: amount)
+            }.value
         }
+
+        predictionsForChart = simulatedDetermination?.predictions
+
+        let nonEmptyArrays = [
+            predictionsForChart?.iob,
+            predictionsForChart?.zt,
+            predictionsForChart?.cob,
+            predictionsForChart?.uam
+        ]
+        .compactMap { $0 }
+        .filter { !$0.isEmpty }
+
+        guard !nonEmptyArrays.isEmpty else {
+            minForecast = []
+            maxForecast = []
+            return
+        }
+
+        minCount = max(12, nonEmptyArrays.map(\.count).min() ?? 0)
+        guard minCount > 0 else { return }
+
+        let (minResult, maxResult) = await Task.detached {
+            let minForecast = (0 ..< self.minCount).map { index in
+                nonEmptyArrays.compactMap { $0.indices.contains(index) ? $0[index] : nil }.min() ?? 0
+            }
+
+            let maxForecast = (0 ..< self.minCount).map { index in
+                nonEmptyArrays.compactMap { $0.indices.contains(index) ? $0[index] : nil }.max() ?? 0
+            }
+
+            return (minForecast, maxForecast)
+        }.value
+
+        minForecast = minResult
+        maxForecast = maxResult
+    }
+}
+
+private extension Set where Element == Forecast {
+    func extractValues(for type: String) -> [Int]? {
+        let values = first { $0.type == type }?
+            .forecastValues?
+            .sorted { $0.index < $1.index }
+            .compactMap { Int($0.value) }
+        return values?.isEmpty ?? true ? nil : values
+    }
+}
+
+private extension Predictions {
+    var isEmpty: Bool {
+        iob == nil && zt == nil && cob == nil && uam == nil
     }
 }

+ 119 - 0
FreeAPS/Sources/Modules/Bolus/View/AddMealPresetView.swift

@@ -0,0 +1,119 @@
+import CoreData
+import Foundation
+import SwiftUI
+
+struct AddMealPresetView: View {
+    @Binding var dish: String
+    @Binding var presetCarbs: Decimal
+    @Binding var presetFat: Decimal
+    @Binding var presetProtein: Decimal
+    var onSave: () -> Void
+    var onCancel: () -> Void
+
+    @Environment(\.colorScheme) private var colorScheme
+    private var color: LinearGradient {
+        colorScheme == .dark ? LinearGradient(
+            gradient: Gradient(colors: [
+                Color.bgDarkBlue,
+                Color.bgDarkerDarkBlue
+            ]),
+            startPoint: .top,
+            endPoint: .bottom
+        )
+            :
+            LinearGradient(
+                gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
+                startPoint: .top,
+                endPoint: .bottom
+            )
+    }
+
+    private var mealFormatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.maximumFractionDigits = 1
+        return formatter
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section {
+                    TextField("Name Of Dish", text: $dish)
+                } header: {
+                    Text("New Preset")
+                }
+                .listRowBackground(Color.chart)
+
+                Section {
+                    carbsTextField()
+                    proteinAndFat()
+                }
+                .listRowBackground(Color.chart)
+
+                savePresetButton
+            }
+            .scrollContentBackground(.hidden).background(color)
+            .navigationTitle("Add Meal Preset")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar(content: {
+                ToolbarItem(placement: .topBarLeading) {
+                    Button {
+                        onCancel()
+                    } label: {
+                        Text("Cancel")
+                    }
+                }
+            })
+        }
+    }
+
+    @ViewBuilder private func carbsTextField() -> some View {
+        HStack {
+            Text("Carbs").fontWeight(.semibold)
+            Spacer()
+            TextFieldWithToolBar(
+                text: $presetCarbs,
+                placeholder: "0",
+                keyboardType: .numberPad,
+                numberFormatter: mealFormatter
+            )
+            Text("g").foregroundColor(.secondary)
+        }
+    }
+
+    @ViewBuilder private func proteinAndFat() -> some View {
+        HStack {
+            Text("Fat").foregroundColor(.orange)
+            Spacer()
+            TextFieldWithToolBar(text: $presetFat, placeholder: "0", keyboardType: .numberPad, numberFormatter: mealFormatter)
+            Text("g").foregroundColor(.secondary)
+        }
+        HStack {
+            Text("Protein").foregroundColor(.red)
+            Spacer()
+            TextFieldWithToolBar(
+                text: $presetProtein,
+                placeholder: "0",
+                keyboardType: .numberPad,
+                numberFormatter: mealFormatter
+            )
+            Text("g").foregroundColor(.secondary)
+        }
+    }
+
+    private var savePresetButton: some View {
+        Button {
+            onSave()
+        }
+        label: {
+            Text("Save")
+                .font(.headline)
+                .foregroundStyle(Color.white)
+                .frame(maxWidth: .infinity, alignment: .center)
+        }
+        .listRowBackground(Color(.systemBlue))
+        .shadow(radius: 3)
+        .clipShape(RoundedRectangle(cornerRadius: 8))
+    }
+}

+ 86 - 207
FreeAPS/Sources/Modules/Bolus/View/BolusRootView.swift

@@ -10,6 +10,7 @@ extension Bolus {
             case carbs
             case fat
             case protein
+            case bolus
         }
 
         @FocusState private var focusedField: FocusedField?
@@ -18,17 +19,12 @@ extension Bolus {
 
         @StateObject var state = StateModel()
 
-        @State private var showAlert = false
+        @State private var showPresetSheet = false
         @State private var autofocus: Bool = true
         @State private var calculatorDetent = PresentationDetent.medium
         @State private var pushed: Bool = false
-        @State private var isPromptPresented: Bool = false
-        @State private var dish: String = ""
-        @State private var saved: Bool = false
         @State private var debounce: DispatchWorkItem?
 
-        @Environment(\.managedObjectContext) var moc
-
         private enum Config {
             static let dividerHeight: CGFloat = 2
             static let spacing: CGFloat = 3
@@ -36,11 +32,6 @@ extension Bolus {
 
         @Environment(\.colorScheme) var colorScheme
 
-        @FetchRequest(
-            entity: MealPresetStored.entity(),
-            sortDescriptors: [NSSortDescriptor(key: "dish", ascending: true)]
-        ) var carbPresets: FetchedResults<MealPresetStored>
-
         private var formatter: NumberFormatter {
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
@@ -87,179 +78,32 @@ extension Bolus {
                 )
         }
 
-        private var empty: Bool {
-            state.useFPUconversion ? (state.carbs <= 0 && state.fat <= 0 && state.protein <= 0) : (state.carbs <= 0)
-        }
-
         /// Handles macro input (carb, fat, protein) in a debounced fashion.
         func handleDebouncedInput() {
             debounce?.cancel()
             debounce = DispatchWorkItem { [self] in
                 state.insulinCalculated = state.calculateInsulin()
+                Task {
+                    await state.updateForecasts()
+                }
             }
             if let debounce = debounce {
                 DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: debounce)
             }
         }
 
-        private var presetPopover: some View {
-            Form {
-                Section {
-                    TextField("Name Of Dish", text: $dish)
-                    Button {
-                        saved = true
-                        if dish != "", saved {
-                            let preset = MealPresetStored(context: moc)
-                            preset.dish = dish
-                            preset.fat = state.fat as NSDecimalNumber
-                            preset.protein = state.protein as NSDecimalNumber
-                            preset.carbs = state.carbs as NSDecimalNumber
-                            if self.moc.hasChanges {
-                                try? moc.save()
-                            }
-                            state.addNewPresetToWaitersNotepad(dish)
-                            saved = false
-                            isPromptPresented = false
-                        }
-                    }
-                    label: { Text("Save") }
-                    Button {
-                        dish = ""
-                        saved = false
-                        isPromptPresented = false }
-                    label: { Text("Cancel") }
-                } header: { Text("Enter Meal Preset Name") }
-            }
-        }
-
-        private var minusButton: some View {
-            Button {
-                if state.carbs != 0,
-                   (state.carbs - (((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal) as Decimal) >= 0
-                {
-                    state.carbs -= (((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal)
-                } else { state.carbs = 0 }
-
-                if state.fat != 0,
-                   (state.fat - (((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal) as Decimal) >= 0
-                {
-                    state.fat -= (((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal)
-                } else { state.fat = 0 }
-
-                if state.protein != 0,
-                   (state.protein - (((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal) as Decimal) >= 0
-                {
-                    state.protein -= (((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal)
-                } else { state.protein = 0 }
-
-                state.removePresetFromNewMeal()
-                if state.carbs == 0, state.fat == 0, state.protein == 0 { state.summation = [] }
-            }
-            label: { Image(systemName: "minus.circle.fill")
-                .font(.system(size: 20))
-            }
-            .disabled(
-                state
-                    .selection == nil ||
-                    (
-                        !state.summation
-                            .contains(state.selection?.dish ?? "") && (state.selection?.dish ?? "") != ""
-                    )
-            )
-            .buttonStyle(.borderless)
-            .tint(.blue)
-        }
-
-        private var plusButton: some View {
-            Button {
-                state.carbs += ((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal
-                state.fat += ((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal
-                state.protein += ((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal
-
-                state.addPresetToNewMeal()
-            }
-            label: { Image(systemName: "plus.circle.fill")
-                .font(.system(size: 20))
-            }
-            .disabled(state.selection == nil)
-            .buttonStyle(.borderless)
-            .tint(.blue)
-        }
-
-        private var mealPresets: some View {
-            Section {
-                HStack {
-                    if state.selection != nil {
-                        minusButton
-                    }
-                    Picker("Preset", selection: $state.selection) {
-                        Text("Saved Food").tag(nil as MealPresetStored?)
-                        ForEach(carbPresets, id: \.self) { (preset: MealPresetStored) in
-                            Text(preset.dish ?? "").tag(preset as MealPresetStored?)
-                        }
-                    }
-                    .labelsHidden()
-                    .frame(maxWidth: .infinity, alignment: .center)
-                    ._onBindingChange($state.selection) { _ in
-                        state.carbs += ((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal
-                        state.fat += ((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal
-                        state.protein += ((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal
-                        state.addToSummation()
-                    }
-                    if state.selection != nil {
-                        plusButton
-                    }
-                }
-
-                HStack {
-                    Button("Delete Preset") {
-                        showAlert.toggle()
-                    }
-                    .disabled(state.selection == nil)
-                    .tint(.orange)
-                    .buttonStyle(.borderless)
-                    .alert(
-                        "Delete preset '\(state.selection?.dish ?? "")'?",
-                        isPresented: $showAlert,
-                        actions: {
-                            Button("No", role: .cancel) {}
-                            Button("Yes", role: .destructive) {
-                                state.deletePreset()
-
-                                state.carbs += ((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal
-                                state.fat += ((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal
-                                state.protein += ((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal
-
-                                state.addPresetToNewMeal()
-                            }
-                        }
-                    )
-
-                    Spacer()
-
-                    Button {
-                        isPromptPresented = true
-                    }
-                    label: { Text("Save as Preset") }
-                        .buttonStyle(.borderless)
-                        .disabled(
-                            empty ||
-                                (
-                                    (((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal) == state
-                                        .carbs && (((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal) == state
-                                        .fat && (((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal) == state
-                                        .protein
-                                )
-                        )
-                }
-            }
-        }
-
         @ViewBuilder private func proteinAndFat() -> some View {
             HStack {
                 Text("Fat").foregroundColor(.orange)
                 Spacer()
-                TextFieldWithToolBar(text: $state.fat, placeholder: "0", keyboardType: .numberPad, numberFormatter: mealFormatter)
+                TextFieldWithToolBar(
+                    text: $state.fat,
+                    placeholder: "0",
+                    keyboardType: .numberPad,
+                    numberFormatter: mealFormatter,
+                    previousTextField: { focusOnPreviousTextField(index: 2) },
+                    nextTextField: { focusOnNextTextField(index: 2) }
+                ).focused($focusedField, equals: .fat)
                 Text("g").foregroundColor(.secondary)
             }
             HStack {
@@ -269,8 +113,10 @@ extension Bolus {
                     text: $state.protein,
                     placeholder: "0",
                     keyboardType: .numberPad,
-                    numberFormatter: mealFormatter
-                )
+                    numberFormatter: mealFormatter,
+                    previousTextField: { focusOnPreviousTextField(index: 3) },
+                    nextTextField: { focusOnNextTextField(index: 3) }
+                ).focused($focusedField, equals: .protein)
                 Text("g").foregroundColor(.secondary)
             }
         }
@@ -283,15 +129,43 @@ extension Bolus {
                     text: $state.carbs,
                     placeholder: "0",
                     keyboardType: .numberPad,
-                    numberFormatter: mealFormatter
-                )
-                .onChange(of: state.carbs) { _ in
-                    handleDebouncedInput()
-                }
+                    numberFormatter: mealFormatter,
+                    previousTextField: { focusOnPreviousTextField(index: 1) },
+                    nextTextField: { focusOnNextTextField(index: 1) }
+                ).focused($focusedField, equals: .carbs)
+                    .onChange(of: state.carbs) { _ in
+                        handleDebouncedInput()
+                    }
                 Text("g").foregroundColor(.secondary)
             }
         }
 
+        func focusOnPreviousTextField(index: Int) {
+            switch index {
+            case 2:
+                focusedField = .carbs
+            case 3:
+                focusedField = .fat
+            case 4:
+                focusedField = .protein
+            default:
+                break
+            }
+        }
+
+        func focusOnNextTextField(index: Int) {
+            switch index {
+            case 1:
+                focusedField = .fat
+            case 2:
+                focusedField = .protein
+            case 3:
+                focusedField = .bolus
+            default:
+                break
+            }
+        }
+
         var body: some View {
             ZStack(alignment: .center) {
                 VStack {
@@ -303,20 +177,6 @@ extension Bolus {
                                 proteinAndFat()
                             }
 
-                            // Summary when combining presets
-                            if state.waitersNotepad() != "" {
-                                HStack {
-                                    Text("Total")
-                                    let test = state.waitersNotepad().components(separatedBy: ", ").removeDublicates()
-                                    HStack(spacing: 0) {
-                                        ForEach(test, id: \.self) {
-                                            Text($0).foregroundStyle(Color.blue).font(.footnote)
-                                            Text($0 == test[test.count - 1] ? "" : ", ")
-                                        }
-                                    }.frame(maxWidth: .infinity, alignment: .trailing)
-                                }
-                            }
-
                             // Time
                             HStack {
                                 Text("Time").foregroundStyle(Color.secondary)
@@ -341,18 +201,12 @@ extension Bolus {
                                     label: { Image(systemName: "plus.circle") }.tint(.blue).buttonStyle(.borderless)
                                 }
                             }
-
-                            .popover(isPresented: $isPromptPresented) {
-                                presetPopover
+                            HStack {
+                                Image(systemName: "square.and.pencil").foregroundColor(.secondary)
+                                TextFieldWithToolBarString(text: $state.note, placeholder: "", maxLength: 25)
                             }
                         }.listRowBackground(Color.chart)
 
-                        if state.displayPresets {
-                            Section {
-                                mealPresets
-                            }.listRowBackground(Color.chart)
-                        }
-
                         Section {
                             HStack {
                                 Button(action: {
@@ -420,19 +274,29 @@ extension Bolus {
                                     placeholder: "0",
                                     textColor: colorScheme == .dark ? .white : .blue,
                                     maxLength: 5,
-                                    numberFormatter: formatter
-                                )
+                                    numberFormatter: formatter,
+                                    previousTextField: { focusOnPreviousTextField(index: 4) },
+                                    nextTextField: { focusOnNextTextField(index: 4) }
+                                ).focused($focusedField, equals: .bolus)
+                                    .onChange(of: state.amount) { _ in
+                                        Task {
+                                            await state.updateForecasts()
+                                        }
+                                    }
                                 Text(" U").foregroundColor(.secondary)
                             }
 
-                            if state.amount > 0 {
-                                HStack {
-                                    Text("External insulin")
-                                    Spacer()
-                                    Toggle("", isOn: $state.externalInsulin).toggleStyle(Checkbox())
-                                }
+                            HStack {
+                                Text("External insulin")
+                                Spacer()
+                                Toggle("", isOn: $state.externalInsulin).toggleStyle(Checkbox())
                             }
                         }.listRowBackground(Color.chart)
+
+                        Section {
+                            ForeCastChart(state: state, units: $state.units)
+                                .padding(.vertical)
+                        }.listRowBackground(Color.chart)
                     }
                 }
                 .safeAreaInset(edge: .bottom, spacing: 0) {
@@ -455,6 +319,16 @@ extension Bolus {
                         Text("Close")
                     }
                 }
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button(action: {
+                        showPresetSheet = true
+                    }, label: {
+                        HStack {
+                            Text("Presets")
+                            Image(systemName: "plus")
+                        }
+                    })
+                }
             })
             .onAppear {
                 configureView {
@@ -471,6 +345,11 @@ extension Bolus {
                         selection: $calculatorDetent
                     )
             }
+            .sheet(isPresented: $showPresetSheet, onDismiss: {
+                showPresetSheet = false
+            }) {
+                MealPresetView(state: state)
+            }
         }
 
         var progressText: ProgressText {

+ 255 - 0
FreeAPS/Sources/Modules/Bolus/View/ForeCastChart.swift

@@ -0,0 +1,255 @@
+import Charts
+import CoreData
+import Foundation
+import SwiftUI
+
+struct ForeCastChart: View {
+    @StateObject var state: Bolus.StateModel
+    @Environment(\.colorScheme) var colorScheme
+    @Binding var units: GlucoseUnits
+
+    @State private var startMarker = Date(timeIntervalSinceNow: -4 * 60 * 60)
+
+    private var endMarker: Date {
+        state
+            .displayForecastsAsLines ? Date(timeIntervalSinceNow: TimeInterval(hours: 3)) :
+            Date(timeIntervalSinceNow: TimeInterval(
+                Int(1.5) * 5 * state
+                    .minCount * 60
+            )) // min is 1.5h -> (1.5*1h = 1.5*(5*12*60))
+    }
+
+    private var glucoseFormatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+
+        if units == .mmolL {
+            formatter.maximumFractionDigits = 1
+            formatter.minimumFractionDigits = 1
+            formatter.roundingMode = .halfUp
+        } else {
+            formatter.maximumFractionDigits = 0
+        }
+        return formatter
+    }
+
+    var body: some View {
+        VStack {
+            forecastChart
+                .padding(.vertical, 3)
+            HStack {
+                Spacer()
+                Image(systemName: "arrow.right.circle")
+                    .font(.system(size: 16, weight: .bold))
+
+                if let eventualBG = state.simulatedDetermination?.eventualBG {
+                    HStack {
+                        Text(
+                            units == .mgdL ? Decimal(eventualBG).description : eventualBG.formattedAsMmolL
+                        )
+                        .font(.footnote)
+                        .foregroundStyle(.primary)
+                        Text("\(units.rawValue)")
+                            .font(.footnote)
+                            .foregroundStyle(.secondary)
+                    }
+                } else {
+                    Text("---")
+                        .font(.footnote)
+                        .foregroundStyle(.primary)
+                    Text("\(units.rawValue)")
+                        .font(.footnote)
+                        .foregroundStyle(.secondary)
+                }
+            }
+        }
+    }
+
+    private var forecastChart: some View {
+        Chart {
+            drawGlucose()
+            drawCurrentTimeMarker()
+
+            if state.displayForecastsAsLines {
+                drawForecastLines()
+            } else {
+                drawForecastsCone()
+            }
+        }
+        .chartXAxis { forecastChartXAxis }
+        .chartXScale(domain: startMarker ... endMarker)
+        .chartYAxis { forecastChartYAxis }
+        .chartYScale(domain: units == .mgdL ? 0 ... 300 : 0.asMmolL ... 300.asMmolL)
+        .backport.chartForegroundStyleScale(state: state)
+    }
+
+    private var stops: [Gradient.Stop] {
+        let low = Double(state.lowGlucose)
+        let high = Double(state.highGlucose)
+
+        let glucoseValues = state.glucoseFromPersistence
+            .map { units == .mgdL ? Decimal($0.glucose) : Decimal($0.glucose).asMmolL }
+
+        let minimum = glucoseValues.min() ?? 0.0
+        let maximum = glucoseValues.max() ?? 0.0
+
+        // Calculate positions for gradient
+        let lowPosition = (low - Double(truncating: minimum as NSNumber)) /
+            (Double(truncating: maximum as NSNumber) - Double(truncating: minimum as NSNumber))
+        let highPosition = (high - Double(truncating: minimum as NSNumber)) /
+            (Double(truncating: maximum as NSNumber) - Double(truncating: minimum as NSNumber))
+
+        // Ensure positions are in bounds [0, 1]
+        let clampedLowPosition = max(0.0, min(lowPosition, 1.0))
+        let clampedHighPosition = max(0.0, min(highPosition, 1.0))
+
+        // Ensure lowPosition is less than highPosition
+        let sortedPositions = [clampedLowPosition, clampedHighPosition].sorted()
+
+        return [
+            Gradient.Stop(color: .red, location: 0.0),
+            Gradient.Stop(color: .red, location: sortedPositions[0]), // draw red gradient till lowGlucose
+            Gradient.Stop(color: .green, location: sortedPositions[0] + 0.0001), // draw green above lowGlucose till highGlucose
+            Gradient.Stop(color: .green, location: sortedPositions[1]),
+            Gradient.Stop(color: .orange, location: sortedPositions[1] + 0.0001), // draw orange above highGlucose
+            Gradient.Stop(color: .orange, location: 1.0)
+        ]
+    }
+
+    private func drawGlucose() -> some ChartContent {
+        ForEach(state.glucoseFromPersistence) { item in
+            let glucoseToDisplay = units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL
+
+            if state.smooth {
+                LineMark(
+                    x: .value("Time", item.date ?? Date()),
+                    y: .value("Value", glucoseToDisplay)
+                )
+                .foregroundStyle(
+                    .linearGradient(stops: stops, startPoint: .bottom, endPoint: .top)
+                )
+                .symbol(.circle).symbolSize(34)
+            } else {
+                if item.glucose > Int(state.highGlucose) {
+                    PointMark(
+                        x: .value("Time", item.date ?? Date(), unit: .second),
+                        y: .value("Value", glucoseToDisplay)
+                    )
+                    .foregroundStyle(Color.orange.gradient)
+                    .symbolSize(20)
+                } else if item.glucose < Int(state.lowGlucose) {
+                    PointMark(
+                        x: .value("Time", item.date ?? Date(), unit: .second),
+                        y: .value("Value", glucoseToDisplay)
+                    )
+                    .foregroundStyle(Color.red.gradient)
+                    .symbolSize(20)
+                } else {
+                    PointMark(
+                        x: .value("Time", item.date ?? Date(), unit: .second),
+                        y: .value("Value", glucoseToDisplay)
+                    )
+                    .foregroundStyle(Color.green.gradient)
+                    .symbolSize(20)
+                }
+            }
+        }
+    }
+
+    private func timeForIndex(_ index: Int32) -> Date {
+        let currentTime = Date()
+        let timeInterval = TimeInterval(index * 300)
+        return currentTime.addingTimeInterval(timeInterval)
+    }
+
+    private func drawForecastsCone() -> some ChartContent {
+        // Draw AreaMark for the forecast bounds
+        ForEach(0 ..< max(state.minForecast.count, state.maxForecast.count), id: \.self) { index in
+            if index < state.minForecast.count, index < state.maxForecast.count {
+                let yMinMaxDelta = Decimal(state.minForecast[index] - state.maxForecast[index])
+                let xValue = timeForIndex(Int32(index))
+
+                // if distance between respective min and max is 0, provide a default range
+                if yMinMaxDelta == 0 {
+                    let yMinValue = units == .mgdL ? Decimal(state.minForecast[index] - 1) :
+                        Decimal(state.minForecast[index] - 1)
+                        .asMmolL
+                    let yMaxValue = units == .mgdL ? Decimal(state.minForecast[index] + 1) :
+                        Decimal(state.minForecast[index] + 1)
+                        .asMmolL
+
+                    AreaMark(
+                        x: .value("Time", xValue <= endMarker ? xValue : endMarker),
+                        yStart: .value("Min Value", units == .mgdL ? yMinValue : yMinValue.asMmolL),
+                        yEnd: .value("Max Value", units == .mgdL ? yMaxValue : yMaxValue.asMmolL)
+                    )
+                    .foregroundStyle(Color.blue.opacity(0.5))
+                    .interpolationMethod(.catmullRom)
+
+                } else {
+                    let yMinValue = Decimal(state.minForecast[index]) <= 300 ? Decimal(state.minForecast[index]) : Decimal(300)
+                    let yMaxValue = Decimal(state.maxForecast[index]) <= 300 ? Decimal(state.maxForecast[index]) : Decimal(300)
+
+                    AreaMark(
+                        x: .value("Time", timeForIndex(Int32(index)) <= endMarker ? timeForIndex(Int32(index)) : endMarker),
+                        yStart: .value("Min Value", units == .mgdL ? yMinValue : yMinValue.asMmolL),
+                        yEnd: .value("Max Value", units == .mgdL ? yMaxValue : yMaxValue.asMmolL)
+                    )
+                    .foregroundStyle(Color.blue.opacity(0.5))
+                    .interpolationMethod(.catmullRom)
+                }
+            }
+        }
+    }
+
+    private func drawForecastLines() -> some ChartContent {
+        let predictions = state.predictionsForChart
+
+        // Prepare the prediction data with only the first 36 values, i.e. 3 hours in the future
+        let predictionData = [
+            ("iob", predictions?.iob?.prefix(36)),
+            ("zt", predictions?.zt?.prefix(36)),
+            ("cob", predictions?.cob?.prefix(36)),
+            ("uam", predictions?.uam?.prefix(36))
+        ]
+
+        return ForEach(predictionData, id: \.0) { name, values in
+            if let values = values {
+                ForEach(values.indices, id: \.self) { index in
+                    LineMark(
+                        x: .value("Time", timeForIndex(Int32(index))),
+                        y: .value("Value", units == .mgdL ? Decimal(values[index]) : Decimal(values[index]).asMmolL)
+                    )
+                    .foregroundStyle(by: .value("Prediction Type", name))
+                }
+            }
+        }
+    }
+
+    private func drawCurrentTimeMarker() -> some ChartContent {
+        RuleMark(
+            x: .value(
+                "",
+                Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970)),
+                unit: .second
+            )
+        ).lineStyle(.init(lineWidth: 2, dash: [3])).foregroundStyle(Color(.systemGray2))
+    }
+
+    private var forecastChartXAxis: some AxisContent {
+        AxisMarks(values: .stride(by: .hour, count: 2)) { _ in
+            AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
+            AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top)
+                .font(.footnote)
+                .foregroundStyle(Color.primary)
+        }
+    }
+
+    private var forecastChartYAxis: some AxisContent {
+        AxisMarks(position: .trailing) { _ in
+            AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
+            AxisTick(length: 3, stroke: .init(lineWidth: 3)).foregroundStyle(Color.secondary)
+            AxisValueLabel().font(.footnote).foregroundStyle(Color.primary)
+        }
+    }
+}

+ 333 - 0
FreeAPS/Sources/Modules/Bolus/View/MealPresetView.swift

@@ -0,0 +1,333 @@
+import CoreData
+import Foundation
+import SwiftUI
+
+struct MealPresetView: View {
+    @StateObject var state: Bolus.StateModel
+    @Environment(\.colorScheme) var colorScheme
+    @Environment(\.dismiss) var dismiss
+    @Environment(\.managedObjectContext) var moc
+
+    @State private var showAlert = false
+    @State private var dish: String = ""
+    @State private var showAddNewPresetSheet = false
+
+    @State private var presetCarbs: Decimal = 0
+    @State private var presetFat: Decimal = 0
+    @State private var presetProtein: Decimal = 0
+
+    @State private var carbs: Decimal = 0
+    @State private var fat: Decimal = 0
+    @State private var protein: Decimal = 0
+
+    @FetchRequest(
+        entity: MealPresetStored.entity(),
+        sortDescriptors: [NSSortDescriptor(key: "dish", ascending: true)]
+    ) var carbPresets: FetchedResults<MealPresetStored>
+
+    private var mealFormatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.maximumFractionDigits = 1
+        return formatter
+    }
+
+    private var color: LinearGradient {
+        colorScheme == .dark ? LinearGradient(
+            gradient: Gradient(colors: [
+                Color.bgDarkBlue,
+                Color.bgDarkerDarkBlue
+            ]),
+            startPoint: .top,
+            endPoint: .bottom
+        )
+            :
+            LinearGradient(
+                gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
+                startPoint: .top,
+                endPoint: .bottom
+            )
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                mealPresets
+                dishInfos()
+                addPresetToTreatmentsButton
+            }
+            .scrollContentBackground(.hidden).background(color)
+            .navigationTitle("Meal Presets")
+            .navigationBarTitleDisplayMode(.automatic)
+            .toolbar(content: {
+                ToolbarItem(placement: .topBarLeading) {
+                    Button {
+                        dismiss()
+                        resetValues()
+                    } label: {
+                        Text("Close")
+                    }
+                }
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button(action: {
+                        showAddNewPresetSheet.toggle()
+                        resetValues()
+                    }, label: {
+                        HStack {
+                            Text("New Preset")
+                            Image(systemName: "plus")
+                        }
+                    })
+                }
+            })
+            .sheet(isPresented: $showAddNewPresetSheet) {
+                AddMealPresetView(
+                    dish: $dish,
+                    presetCarbs: $presetCarbs,
+                    presetFat: $presetFat,
+                    presetProtein: $presetProtein,
+                    onSave: savePreset,
+                    onCancel: {
+                        showAddNewPresetSheet.toggle()
+                        resetValues()
+                    }
+                )
+            }
+            .onDisappear {
+                resetValues()
+            }
+        }
+    }
+
+    private var mealPresets: some View {
+        Section {
+            HStack {
+                if state.selection != nil {
+                    minusButton
+                }
+                Picker("Preset", selection: $state.selection) {
+                    Text("Saved Food").tag(nil as MealPresetStored?)
+                    ForEach(carbPresets, id: \.self) { (preset: MealPresetStored) in
+                        Text(preset.dish ?? "").tag(preset as MealPresetStored?)
+                    }
+                }
+                .labelsHidden()
+                .frame(maxWidth: .infinity, alignment: .center)
+                if state.selection != nil {
+                    plusButton
+                }
+            }
+
+            HStack {
+                Spacer()
+
+                Button("Delete Preset") {
+                    showAlert.toggle()
+                }
+                .disabled(state.selection == nil)
+                .tint(.orange)
+                .buttonStyle(.borderless)
+                .alert(
+                    "Delete preset '\(state.selection?.dish ?? "")'?",
+                    isPresented: $showAlert,
+                    actions: {
+                        Button("No", role: .cancel) {}
+                        Button("Yes", role: .destructive) {
+                            if let selection = state.selection {
+                                let previousSelection = state.selection
+                                let count = state.summation.filter { $0 == selection.dish }.count
+                                state.summation.removeAll { $0 == selection.dish }
+                                carbs -= (((selection.carbs ?? 0) as NSDecimalNumber) as Decimal) * Decimal(count)
+                                fat -= (((selection.fat ?? 0) as NSDecimalNumber) as Decimal) * Decimal(count)
+                                protein -= (((selection.protein ?? 0) as NSDecimalNumber) as Decimal) * Decimal(count)
+                                state.deletePreset()
+                                state.selection = previousSelection
+                            }
+                        }
+                    }
+                )
+
+                Spacer()
+            }
+        }.listRowBackground(Color.chart)
+    }
+
+    private var addPresetToTreatmentsButton: some View {
+        Button {
+            state.carbs += carbs
+            state.fat += fat
+            state.protein += protein
+
+            dismiss()
+        }
+        label: {
+            Text("Add to treatments")
+                .font(.headline)
+                .foregroundStyle(Color.white)
+                .frame(maxWidth: .infinity, alignment: .center)
+        }
+        .disabled(noPresetChosen)
+        .listRowBackground(noPresetChosen ? Color(.systemGray3) : Color(.systemBlue))
+        .shadow(radius: 3)
+        .clipShape(RoundedRectangle(cornerRadius: 8))
+    }
+
+    private var noPresetChosen: Bool {
+        state.selection == nil || carbs == 0 || fat == 0 || protein == 0
+    }
+
+    @ViewBuilder private func dishInfos() -> some View {
+        if !state.summation.isEmpty {
+            let presetSummary = generatePresetSummary()
+
+            Section(header: Text("Summary")) {
+                presetSummary
+                    .lineLimit(nil) // In case the text is too long, allow it to wrap to the next line
+
+                LazyVGrid(columns: [
+                    GridItem(.flexible(), alignment: .leading),
+                    GridItem(.flexible(), alignment: .trailing)
+                ], spacing: 0) {
+                    Group {
+                        Text("Carbs: ")
+                            .font(.footnote)
+                            .foregroundStyle(.secondary)
+                        HStack(spacing: 2) {
+                            Text("\(carbs as NSNumber, formatter: mealFormatter)")
+                                .font(.footnote)
+                            Text(" g")
+                                .font(.footnote)
+                                .foregroundStyle(.secondary)
+                        }
+                    }
+
+                    Group {
+                        Text("Fat: ")
+                            .font(.footnote)
+                            .foregroundStyle(.secondary)
+                        HStack(spacing: 2) {
+                            Text("\(fat as NSNumber, formatter: mealFormatter)")
+                                .font(.footnote)
+                            Text(" g")
+                                .font(.footnote)
+                                .foregroundStyle(.secondary)
+                        }
+                    }
+
+                    Group {
+                        Text("Protein: ")
+                            .font(.footnote)
+                            .foregroundStyle(.secondary)
+                        HStack(spacing: 2) {
+                            Text("\(protein as NSNumber, formatter: mealFormatter)")
+                                .font(.footnote)
+                            Text(" g")
+                                .font(.footnote)
+                                .foregroundStyle(.secondary)
+                        }
+                    }
+                }
+            }.listRowBackground(Color.chart)
+        }
+    }
+
+    private func generatePresetSummary() -> some View {
+        var counts = [String: Int]()
+
+        for preset in state.summation {
+            counts[preset, default: 0] += 1
+        }
+
+        return VStack(alignment: .leading) {
+            ForEach(counts.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
+                if value > 0 {
+                    HStack {
+                        Text("\(value) x")
+                            .foregroundColor(.blue)
+                        Text(key)
+                    }
+                }
+            }
+        }
+    }
+
+    private func resetValues() {
+        dish = ""
+        presetCarbs = 0
+        presetFat = 0
+        presetProtein = 0
+        state.selection = nil
+        state.summation.removeAll()
+    }
+
+    private var minusButton: some View {
+        Button {
+            if carbs != 0 {
+                carbs -= (((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal)
+            } else { carbs = 0 }
+
+            if fat != 0,
+               (fat - (((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal) as Decimal) >= 0
+            {
+                fat -= (((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal)
+            } else { fat = 0 }
+
+            if protein != 0,
+               (protein - (((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal) as Decimal) >= 0
+            {
+                protein -= (((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal)
+            } else { protein = 0 }
+
+            state.removePresetFromNewMeal()
+            if carbs == 0, fat == 0, protein == 0 { state.summation = [] }
+        }
+        label: { Image(systemName: "minus.circle.fill")
+            .font(.system(size: 20))
+        }
+        .disabled(
+            state
+                .selection == nil ||
+                (
+                    !state.summation
+                        .contains(state.selection?.dish ?? "") && (state.selection?.dish ?? "") != ""
+                )
+        )
+        .buttonStyle(.borderless)
+        .tint(.blue)
+    }
+
+    private var plusButton: some View {
+        Button {
+            carbs += ((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal
+            fat += ((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal
+            protein += ((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal
+
+            state.addPresetToNewMeal()
+        }
+        label: { Image(systemName: "plus.circle.fill")
+            .font(.system(size: 20))
+        }
+        .disabled(state.selection == nil)
+        .buttonStyle(.borderless)
+        .tint(.blue)
+    }
+
+    private func savePreset() {
+        if dish != "" {
+            let preset = MealPresetStored(context: moc)
+            preset.dish = dish
+            preset.fat = presetFat as NSDecimalNumber
+            preset.protein = presetProtein as NSDecimalNumber
+            preset.carbs = presetCarbs as NSDecimalNumber
+
+            do {
+                guard moc.hasChanges else { return }
+                try moc.save()
+                showAddNewPresetSheet.toggle()
+                resetValues()
+            } catch let error as NSError {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to save Meal Preset with error: \(error.userInfo)")
+            }
+        }
+    }
+}

+ 5 - 0
FreeAPS/Sources/Modules/DataTable/DataTableStateModel.swift

@@ -8,6 +8,7 @@ extension DataTable {
         @Injected() var unlockmanager: UnlockManager!
         @Injected() private var storage: FileStorage!
         @Injected() var pumpHistoryStorage: PumpHistoryStorage!
+        @Injected() var glucoseStorage: GlucoseStorage!
         @Injected() var healthKitManager: HealthKitManager!
 
         let coredataContext = CoreDataStack.shared.newTaskContext()
@@ -31,6 +32,10 @@ extension DataTable {
             broadcaster.register(DeterminationObserver.self, observer: self)
         }
 
+        func isGlucoseDataFresh(_ glucoseDate: Date?) -> Bool {
+            glucoseStorage.isGlucoseDataFresh(glucoseDate)
+        }
+
         // Carb and FPU deletion from history
         /// marked as MainActor to be able to publish changes from the background
         /// - Parameter: NSManagedObjectID to be able to transfer the object safely from one thread to another thread

+ 28 - 17
FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift

@@ -128,7 +128,9 @@ extension DataTable {
                         .background(color)
                 }.blur(radius: state.waitForSuggestion ? 8 : 0)
 
-                if state.waitForSuggestion {
+                // Show custom progress view
+                /// don't show it if glucose is stale as it will block the UI
+                if state.waitForSuggestion && state.isGlucoseDataFresh(glucoseStored.first?.date) {
                     CustomProgressView(text: progressText.rawValue)
                 }
             })
@@ -450,24 +452,33 @@ extension DataTable {
         }
 
         @ViewBuilder private func mealView(_ meal: CarbEntryStored) -> some View {
-            HStack {
-                if meal.isFPU {
-                    Image(systemName: "circle.fill").foregroundColor(Color.orange.opacity(0.5))
-                    Text("Fat / Protein")
-                    Text((numberFormatter.string(for: meal.carbs) ?? "0") + NSLocalizedString(" g", comment: "gram of carbs"))
-                } else {
-                    Image(systemName: "circle.fill").foregroundColor(Color.loopYellow)
-                    Text("Carbs")
-                    Text(
-                        (numberFormatter.string(for: meal.carbs) ?? "0") +
-                            NSLocalizedString(" g", comment: "gram of carb equilvalents")
-                    )
-                }
+            VStack {
+                HStack {
+                    if meal.isFPU {
+                        Image(systemName: "circle.fill").foregroundColor(Color.orange.opacity(0.5))
+                        Text("Fat / Protein")
+                        Text((numberFormatter.string(for: meal.carbs) ?? "0") + NSLocalizedString(" g", comment: "gram of carbs"))
+                    } else {
+                        Image(systemName: "circle.fill").foregroundColor(Color.loopYellow)
+                        Text("Carbs")
+                        Text(
+                            (numberFormatter.string(for: meal.carbs) ?? "0") +
+                                NSLocalizedString(" g", comment: "gram of carb equilvalents")
+                        )
+                    }
 
-                Spacer()
+                    Spacer()
 
-                Text(dateFormatter.string(from: meal.date ?? Date()))
-                    .moveDisabled(true)
+                    Text(dateFormatter.string(from: meal.date ?? Date()))
+                        .moveDisabled(true)
+                }
+                if let note = meal.note, note != "" {
+                    HStack {
+                        Image(systemName: "square.and.pencil")
+                        Text(note)
+                        Spacer()
+                    }.padding(.top, 5).foregroundColor(.secondary)
+                }
             }
             .swipeActions {
                 Button(

+ 181 - 159
FreeAPS/Sources/Modules/Home/HomeStateModel.swift

@@ -12,6 +12,7 @@ extension Home {
         @Injected() var fetchGlucoseManager: FetchGlucoseManager!
         @Injected() var nightscoutManager: NightscoutManager!
         @Injected() var determinationStorage: DeterminationStorage!
+        @Injected() var glucoseStorage: GlucoseStorage!
         private let timer = DispatchTimer(timeInterval: 5)
         private(set) var filteredHours = 24
         @Published var manualGlucose: [BloodGlucose] = []
@@ -46,8 +47,8 @@ extension Home {
         @Published var manualTempBasal = false
         @Published var smooth = false
         @Published var maxValue: Decimal = 1.2
-        @Published var lowGlucose: Decimal = 4 / 0.0555
-        @Published var highGlucose: Decimal = 10 / 0.0555
+        @Published var lowGlucose: Decimal = 70
+        @Published var highGlucose: Decimal = 180
         @Published var overrideUnit: Bool = false
         @Published var displayXgridLines: Bool = false
         @Published var displayYgridLines: Bool = false
@@ -81,6 +82,11 @@ extension Home {
         @Published var pumpStatusHighlightMessage: String? = nil
         @Published var cgmAvailable: Bool = false
 
+        @Published var minForecast: [Int] = []
+        @Published var maxForecast: [Int] = []
+        @Published var minCount: Int = 12 // count of Forecasts drawn in 5 min distances, i.e. 12 means a min of 1 hour
+        @Published var displayForecastsAsLines: Bool = false
+
         let context = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
@@ -120,8 +126,8 @@ extension Home {
             setupCurrentTempTarget()
             smooth = settingsManager.settings.smoothGlucose
             maxValue = settingsManager.preferences.autosensMax
-            lowGlucose = settingsManager.settings.low
-            highGlucose = settingsManager.settings.high
+            lowGlucose = units == .mgdL ? settingsManager.settings.low : settingsManager.settings.low.asMmolL
+            highGlucose = units == .mgdL ? settingsManager.settings.high : settingsManager.settings.high.asMmolL
             overrideUnit = settingsManager.settings.overrideHbA1cUnit
             displayXgridLines = settingsManager.settings.xGridLines
             displayYgridLines = settingsManager.settings.yGridLines
@@ -129,6 +135,8 @@ extension Home {
             tins = settingsManager.settings.tins
             cgmAvailable = fetchGlucoseManager.cgmGlucoseSourceType != CGMType.none
 
+            displayForecastsAsLines = settingsManager.settings.displayForecastsAsLines
+
             broadcaster.register(GlucoseObserver.self, observer: self)
             broadcaster.register(DeterminationObserver.self, observer: self)
             broadcaster.register(SettingsObserver.self, observer: self)
@@ -229,10 +237,7 @@ extension Home {
         private func registerHandlers() {
             coreDataObserver?.registerHandler(for: "OrefDetermination") { [weak self] in
                 guard let self = self else { return }
-                Task {
-                    self.setupDeterminationsArray()
-                    await self.updateForecastData()
-                }
+                self.setupDeterminationsArray()
             }
 
             coreDataObserver?.registerHandler(for: "GlucoseStored") { [weak self] in
@@ -289,6 +294,11 @@ extension Home {
             provider.heartbeatNow()
         }
 
+        func showProgressView() {
+            glucoseStorage
+                .isGlucoseDataFresh(glucoseFromPersistence.first?.date) ? (waitForSuggestion = true) : (waitForSuggestion = false)
+        }
+
         func cancelBolus() {
             Task {
                 await apsManager.cancelBolus()
@@ -453,12 +463,13 @@ extension Home.StateModel:
         animatedBackground = settingsManager.settings.animatedBackground
         manualTempBasal = apsManager.isManualTempBasal
         smooth = settingsManager.settings.smoothGlucose
-        lowGlucose = settingsManager.settings.low
-        highGlucose = settingsManager.settings.high
+        lowGlucose = units == .mgdL ? settingsManager.settings.low : settingsManager.settings.low.asMmolL
+        highGlucose = units == .mgdL ? settingsManager.settings.high : settingsManager.settings.high.asMmolL
         overrideUnit = settingsManager.settings.overrideHbA1cUnit
         displayXgridLines = settingsManager.settings.xGridLines
         displayYgridLines = settingsManager.settings.yGridLines
         thresholdLines = settingsManager.settings.rulerMarks
+        displayForecastsAsLines = settingsManager.settings.displayForecastsAsLines
         tins = settingsManager.settings.tins
         cgmAvailable = (fetchGlucoseManager.cgmGlucoseSourceType != CGMType.none)
         displayPumpStatusHighlightMessage()
@@ -560,7 +571,8 @@ extension Home.StateModel {
     private func setupGlucoseArray() {
         Task {
             let ids = await self.fetchGlucose()
-            await updateGlucoseArray(with: ids)
+            let glucoseObjects: [GlucoseStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateGlucoseArray(with: glucoseObjects)
         }
     }
 
@@ -581,24 +593,17 @@ extension Home.StateModel {
         }
     }
 
-    @MainActor private func updateGlucoseArray(with IDs: [NSManagedObjectID]) {
-        do {
-            let glucoseObjects = try IDs.compactMap { id in
-                try viewContext.existingObject(with: id) as? GlucoseStored
-            }
-            glucoseFromPersistence = glucoseObjects
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the glucose array: \(error.localizedDescription)"
-            )
-        }
+    @MainActor private func updateGlucoseArray(with objects: [GlucoseStored]) {
+        glucoseFromPersistence = objects
     }
 
     // Setup Manual Glucose
     private func setupManualGlucoseArray() {
         Task {
             let ids = await self.fetchManualGlucose()
-            await updateManualGlucoseArray(with: ids)
+            let manualGlucoseObjects: [GlucoseStored] = await CoreDataStack.shared
+                .getNSManagedObject(with: ids, context: viewContext)
+            await updateManualGlucoseArray(with: manualGlucoseObjects)
         }
     }
 
@@ -619,24 +624,16 @@ extension Home.StateModel {
         }
     }
 
-    @MainActor private func updateManualGlucoseArray(with IDs: [NSManagedObjectID]) {
-        do {
-            let manualGlucoseObjects = try IDs.compactMap { id in
-                try viewContext.existingObject(with: id) as? GlucoseStored
-            }
-            manualGlucoseFromPersistence = manualGlucoseObjects
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the manual glucose array: \(error.localizedDescription)"
-            )
-        }
+    @MainActor private func updateManualGlucoseArray(with objects: [GlucoseStored]) {
+        manualGlucoseFromPersistence = objects
     }
 
     // Setup Carbs
     private func setupCarbsArray() {
         Task {
             let ids = await self.fetchCarbs()
-            await updateCarbsArray(with: ids)
+            let carbObjects: [CarbEntryStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateCarbsArray(with: carbObjects)
         }
     }
 
@@ -656,24 +653,16 @@ extension Home.StateModel {
         }
     }
 
-    @MainActor private func updateCarbsArray(with IDs: [NSManagedObjectID]) {
-        do {
-            let carbObjects = try IDs.compactMap { id in
-                try viewContext.existingObject(with: id) as? CarbEntryStored
-            }
-            carbsFromPersistence = carbObjects
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the carbs array: \(error.localizedDescription)"
-            )
-        }
+    @MainActor private func updateCarbsArray(with objects: [CarbEntryStored]) {
+        carbsFromPersistence = objects
     }
 
     // Setup FPUs
     private func setupFPUsArray() {
         Task {
             let ids = await self.fetchFPUs()
-            await updateFPUsArray(with: ids)
+            let fpuObjects: [CarbEntryStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateFPUsArray(with: fpuObjects)
         }
     }
 
@@ -693,17 +682,8 @@ extension Home.StateModel {
         }
     }
 
-    @MainActor private func updateFPUsArray(with IDs: [NSManagedObjectID]) {
-        do {
-            let fpuObjects = try IDs.compactMap { id in
-                try viewContext.existingObject(with: id) as? CarbEntryStored
-            }
-            fpusFromPersistence = fpuObjects
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the fpus array: \(error.localizedDescription)"
-            )
-        }
+    @MainActor private func updateFPUsArray(with objects: [CarbEntryStored]) {
+        fpusFromPersistence = objects
     }
 
     // Custom fetch to more efficiently filter only for cob and iob
@@ -730,6 +710,7 @@ extension Home.StateModel {
     // Setup Determinations
     private func setupDeterminationsArray() {
         Task {
+            // Get the NSManagedObjectIDs
             async let enactedObjectIDs = determinationStorage
                 .fetchLastDeterminationObjectID(predicate: NSPredicate.enactedDetermination)
             async let enactedAndNonEnactedObjectIDs = fetchCobAndIob()
@@ -737,14 +718,10 @@ extension Home.StateModel {
             let enactedIDs = await enactedObjectIDs
             let enactedAndNonEnactedIDs = await enactedAndNonEnactedObjectIDs
 
-            async let updateEnacted: () = updateDeterminationsArray(with: enactedIDs, keyPath: \.determinationsFromPersistence)
-            async let updateEnactedAndNonEnacted: () = updateDeterminationsArray(
-                with: enactedAndNonEnactedIDs,
-                keyPath: \.enactedAndNonEnactedDeterminations
-            )
+            // Get the NSManagedObjects and return them on the Main Thread
+            await updateDeterminationsArray(with: enactedIDs, keyPath: \.determinationsFromPersistence)
+            await updateDeterminationsArray(with: enactedAndNonEnactedIDs, keyPath: \.enactedAndNonEnactedDeterminations)
 
-            await updateEnacted
-            await updateEnactedAndNonEnacted
             await updateForecastData()
         }
     }
@@ -752,24 +729,21 @@ extension Home.StateModel {
     @MainActor private func updateDeterminationsArray(
         with IDs: [NSManagedObjectID],
         keyPath: ReferenceWritableKeyPath<Home.StateModel, [OrefDetermination]>
-    ) {
-        do {
-            let determinationObjects = try IDs.compactMap { id in
-                try viewContext.existingObject(with: id) as? OrefDetermination
-            }
-            self[keyPath: keyPath] = determinationObjects
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the determinations array: \(error.localizedDescription)"
-            )
-        }
+    ) async {
+        // Fetch the objects off the main thread
+        let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
+            .getNSManagedObject(with: IDs, context: viewContext)
+
+        // Update the array on the main thread
+        self[keyPath: keyPath] = determinationObjects
     }
 
     // Setup Insulin
     private func setupInsulinArray() {
         Task {
             let ids = await self.fetchInsulin()
-            await updateInsulinArray(with: ids)
+            let insulinObjects: [PumpEventStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateInsulinArray(with: insulinObjects)
         }
     }
 
@@ -791,31 +765,21 @@ extension Home.StateModel {
         }
     }
 
-    @MainActor private func updateInsulinArray(with IDs: [NSManagedObjectID]) {
-        do {
-            let insulinObjects = try IDs.compactMap { id in
-                try viewContext.existingObject(with: id) as? PumpEventStored
-            }
-            insulinFromPersistence = insulinObjects
+    @MainActor private func updateInsulinArray(with insulinObjects: [PumpEventStored]) {
+        insulinFromPersistence = insulinObjects
 
-            // filter tempbasals
-            manualTempBasal = apsManager.isManualTempBasal
-            tempBasals = insulinFromPersistence.filter({ $0.tempBasal != nil })
-
-            // suspension and resume events
-            suspensions = insulinFromPersistence
-                .filter({ $0.type == EventType.pumpSuspend.rawValue || $0.type == EventType.pumpResume.rawValue })
-            let lastSuspension = suspensions.last
-
-            pumpSuspended = tempBasals.last?.timestamp ?? Date() > lastSuspension?.timestamp ?? .distantPast && lastSuspension?
-                .type == EventType.pumpSuspend
-                .rawValue
+        // Filter tempbasals
+        manualTempBasal = apsManager.isManualTempBasal
+        tempBasals = insulinFromPersistence.filter({ $0.tempBasal != nil })
 
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the insulin array: \(error.localizedDescription)"
-            )
+        // Suspension and resume events
+        suspensions = insulinFromPersistence.filter {
+            $0.type == EventType.pumpSuspend.rawValue || $0.type == EventType.pumpResume.rawValue
         }
+        let lastSuspension = suspensions.last
+
+        pumpSuspended = tempBasals.last?.timestamp ?? Date() > lastSuspension?.timestamp ?? .distantPast && lastSuspension?
+            .type == EventType.pumpSuspend.rawValue
     }
 
     // Setup Last Bolus to display the bolus progress bar
@@ -858,7 +822,8 @@ extension Home.StateModel {
     private func setupBatteryArray() {
         Task {
             let ids = await self.fetchBattery()
-            await updateBatteryArray(with: ids)
+            let batteryObjects: [OpenAPS_Battery] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateBatteryArray(with: batteryObjects)
         }
     }
 
@@ -878,17 +843,8 @@ extension Home.StateModel {
         }
     }
 
-    @MainActor private func updateBatteryArray(with IDs: [NSManagedObjectID]) {
-        do {
-            let batteryObjects = try IDs.compactMap { id in
-                try viewContext.existingObject(with: id) as? OpenAPS_Battery
-            }
-            batteryFromPersistence = batteryObjects
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the battery array: \(error.localizedDescription)"
-            )
-        }
+    @MainActor private func updateBatteryArray(with objects: [OpenAPS_Battery]) {
+        batteryFromPersistence = objects
     }
 }
 
@@ -897,7 +853,8 @@ extension Home.StateModel {
     private func setupOverrides() {
         Task {
             let ids = await self.fetchOverrides()
-            await updateOverrideArray(with: ids)
+            let overrideObjects: [OverrideStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateOverrideArray(with: overrideObjects)
         }
     }
 
@@ -917,18 +874,8 @@ extension Home.StateModel {
         }
     }
 
-    @MainActor private func updateOverrideArray(with IDs: [NSManagedObjectID]) {
-        do {
-            let overrideObjects = try IDs.compactMap { id in
-                try viewContext.existingObject(with: id) as? OverrideStored
-            }
-
-            overrides = overrideObjects
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the override array: \(error.localizedDescription)"
-            )
-        }
+    @MainActor private func updateOverrideArray(with objects: [OverrideStored]) {
+        overrides = objects
     }
 
     @MainActor func calculateDuration(override: OverrideStored) -> TimeInterval {
@@ -949,7 +896,9 @@ extension Home.StateModel {
     private func setupOverrideRunStored() {
         Task {
             let ids = await self.fetchOverrideRunStored()
-            await updateOverrideRunStoredArray(with: ids)
+            let overrideRunObjects: [OverrideRunStored] = await CoreDataStack.shared
+                .getNSManagedObject(with: ids, context: viewContext)
+            await updateOverrideRunStoredArray(with: overrideRunObjects)
         }
     }
 
@@ -970,19 +919,8 @@ extension Home.StateModel {
         }
     }
 
-    @MainActor private func updateOverrideRunStoredArray(with IDs: [NSManagedObjectID]) {
-        do {
-            let overrideObjects = try IDs.compactMap { id in
-                try viewContext.existingObject(with: id) as? OverrideRunStored
-            }
-
-            overrideRunStored = overrideObjects
-            debugPrint("expiredOverrides: \(DebuggingIdentifiers.inProgress) \(overrideRunStored)")
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the Override Run Stored array: \(error.localizedDescription)"
-            )
-        }
+    @MainActor private func updateOverrideRunStoredArray(with objects: [OverrideRunStored]) {
+        overrideRunStored = objects
     }
 
     @MainActor func saveToOverrideRunStored(withID id: NSManagedObjectID) async {
@@ -1006,41 +944,125 @@ extension Home.StateModel {
     }
 }
 
-// MARK: Extension for Main Chart to draw Forecasts
-
 extension Home.StateModel {
+    // Asynchronously preprocess forecast data in a background thread
     func preprocessForecastData() async -> [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] {
-        guard let id = determinationsFromPersistence.first?.objectID else {
-            return []
-        }
+        await Task.detached { [self] () -> [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] in
+            // Get the first determination ID from persistence
+            guard let id = enactedAndNonEnactedDeterminations.first?.objectID else {
+                return []
+            }
+
+            // Get the forecast IDs for the determination ID
+            let forecastIDs = await determinationStorage.getForecastIDs(for: id, in: context)
+            var result: [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] = []
+
+            // Use a task group to fetch forecast value IDs concurrently
+            await withTaskGroup(of: (UUID, NSManagedObjectID, [NSManagedObjectID]).self) { group in
+                for forecastID in forecastIDs {
+                    group.addTask {
+                        let forecastValueIDs = await self.determinationStorage.getForecastValueIDs(
+                            for: forecastID,
+                            in: self.context
+                        )
+                        return (UUID(), forecastID, forecastValueIDs)
+                    }
+                }
 
-        // Get forecast and forecast values
-        let forecastIDs = await determinationStorage.getForecastIDs(for: id, in: context)
-        var result: [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] = []
+                // Collect the results from the task group
+                for await (uuid, forecastID, forecastValueIDs) in group {
+                    result.append((id: uuid, forecastID: forecastID, forecastValueIDs: forecastValueIDs))
+                }
+            }
 
-        for forecastID in forecastIDs {
-            // Get the forecast value IDs for the given forecast ID
-            let forecastValueIDs = await determinationStorage.getForecastValueIDs(for: forecastID, in: context)
-            let uuid = UUID()
-            result.append((id: uuid, forecastID: forecastID, forecastValueIDs: forecastValueIDs))
+            return result
+        }.value
+    }
+
+    // Fetch forecast values for a given data set
+    func fetchForecastValues(
+        for data: (id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID]),
+        in context: NSManagedObjectContext
+    ) async -> (UUID, Forecast?, [ForecastValue]) {
+        var forecast: Forecast?
+        var forecastValues: [ForecastValue] = []
+
+        do {
+            try await context.perform {
+                // Fetch the forecast object
+                forecast = try context.existingObject(with: data.forecastID) as? Forecast
+
+                // Fetch the first 3h of forecast values
+                for forecastValueID in data.forecastValueIDs.prefix(36) {
+                    if let forecastValue = try context.existingObject(with: forecastValueID) as? ForecastValue {
+                        forecastValues.append(forecastValue)
+                    }
+                }
+            }
+        } catch {
+            debugPrint(
+                "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch forecast Values with error: \(error.localizedDescription)"
+            )
         }
 
-        return result
+        return (data.id, forecast, forecastValues)
     }
 
+    // Update forecast data and UI on the main thread
     @MainActor func updateForecastData() async {
+        // Preprocess forecast data on a background thread
         let forecastData = await preprocessForecastData()
 
-        preprocessedData = forecastData.reduce(into: []) { result, data in
-            guard let forecast = try? viewContext.existingObject(with: data.forecastID) as? Forecast else {
-                return
-            }
+        var allForecastValues = [[ForecastValue]]()
+        var preprocessedData = [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)]()
 
-            for forecastValueID in data.forecastValueIDs {
-                if let forecastValue = try? viewContext.existingObject(with: forecastValueID) as? ForecastValue {
-                    result.append((id: data.id, forecast: forecast, forecastValue: forecastValue))
+        // Use a task group to fetch forecast values concurrently
+        await withTaskGroup(of: (UUID, Forecast?, [ForecastValue]).self) { group in
+            for data in forecastData {
+                group.addTask {
+                    await self.fetchForecastValues(for: data, in: self.viewContext)
                 }
             }
+
+            // Collect the results from the task group
+            for await (id, forecast, forecastValues) in group {
+                guard let forecast = forecast, !forecastValues.isEmpty else { continue }
+
+                allForecastValues.append(forecastValues)
+                preprocessedData.append(contentsOf: forecastValues.map { (id: id, forecast: forecast, forecastValue: $0) })
+            }
+        }
+
+        self.preprocessedData = preprocessedData
+
+        // Ensure there are forecast values to process
+        guard !allForecastValues.isEmpty else {
+            minForecast = []
+            maxForecast = []
+            return
         }
+
+        minCount = max(12, allForecastValues.map(\.count).min() ?? 0)
+        guard minCount > 0 else { return }
+
+        // Copy allForecastValues to a local constant for thread safety
+        let localAllForecastValues = allForecastValues
+
+        // Calculate min and max forecast values in a background task
+        let (minResult, maxResult) = await Task.detached {
+            let minForecast = (0 ..< self.minCount).map { index in
+                localAllForecastValues.compactMap { $0.indices.contains(index) ? Int($0[index].value) : nil }.min() ?? 0
+            }
+
+            let maxForecast = (0 ..< self.minCount).map { index in
+                localAllForecastValues.compactMap { $0.indices.contains(index) ? Int($0[index].value) : nil }.max() ?? 0
+            }
+
+            return (minForecast, maxForecast)
+        }.value
+
+        // Update the properties on the main thread
+        minForecast = minResult
+        maxForecast = maxResult
     }
 }

+ 308 - 89
FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -58,8 +58,9 @@ struct MainChartView: View {
     @State private var basalProfiles: [BasalProfile] = []
     @State private var chartTempTargets: [ChartTempTarget] = []
     @State private var count: Decimal = 1
-    @State private var startMarker = Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970 - 86400))
-    @State private var endMarker = Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970 + 10800))
+    @State private var startMarker =
+        Date(timeIntervalSinceNow: TimeInterval(hours: -24))
+    @State private var endMarker = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
     @State private var minValue: Decimal = 45
     @State private var maxValue: Decimal = 270
     @State private var selection: Date? = nil
@@ -91,10 +92,6 @@ struct MainChartView: View {
         return formatter
     }
 
-    private var conversionFactor: Decimal {
-        units == .mmolL ? 0.0555 : 1
-    }
-
     private var upperLimit: Decimal {
         units == .mgdL ? 400 : 22.2
     }
@@ -107,10 +104,6 @@ struct MainChartView: View {
         units == .mgdL ? 30 : 1.66
     }
 
-    private var interpolationFactor: Double {
-        Double(state.enactedAndNonEnactedDeterminations.first?.cob ?? 1) * 10
-    }
-
     private var selectedGlucose: GlucoseStored? {
         if let selection = selection {
             let lowerBound = selection.addingTimeInterval(-120)
@@ -121,6 +114,30 @@ struct MainChartView: View {
         }
     }
 
+    private var selectedCOBValue: OrefDetermination? {
+        if let selection = selection {
+            let lowerBound = selection.addingTimeInterval(-120)
+            let upperBound = selection.addingTimeInterval(120)
+            return state.enactedAndNonEnactedDeterminations.first {
+                $0.deliverAt ?? now >= lowerBound && $0.deliverAt ?? now <= upperBound
+            }
+        } else {
+            return nil
+        }
+    }
+
+    private var selectedIOBValue: OrefDetermination? {
+        if let selection = selection {
+            let lowerBound = selection.addingTimeInterval(-120)
+            let upperBound = selection.addingTimeInterval(120)
+            return state.enactedAndNonEnactedDeterminations.first {
+                $0.deliverAt ?? now >= lowerBound && $0.deliverAt ?? now <= upperBound
+            }
+        } else {
+            return nil
+        }
+    }
+
     var body: some View {
         VStack {
             ZStack {
@@ -199,6 +216,29 @@ extension Backport {
             content
         }
     }
+
+    @ViewBuilder func chartForegroundStyleScale(state: any StateModel) -> some View {
+        if (state as? Bolus.StateModel)?.displayForecastsAsLines == true ||
+            (state as? Home.StateModel)?.displayForecastsAsLines == true
+        {
+            let modifiedContent = content
+                .chartForegroundStyleScale([
+                    "iob": .blue,
+                    "uam": Color.uam,
+                    "zt": Color.zt,
+                    "cob": .orange
+                ])
+
+            if state is Home.StateModel {
+                modifiedContent
+                    .chartLegend(.hidden)
+            } else {
+                modifiedContent
+            }
+        } else {
+            content
+        }
+    }
 }
 
 extension MainChartView {
@@ -207,9 +247,9 @@ extension MainChartView {
         Chart {
             /// high and low threshold lines
             if thresholdLines {
-                RuleMark(y: .value("High", highGlucose * conversionFactor)).foregroundStyle(Color.loopYellow)
+                RuleMark(y: .value("High", highGlucose)).foregroundStyle(Color.loopYellow)
                     .lineStyle(.init(lineWidth: 1, dash: [5]))
-                RuleMark(y: .value("Low", lowGlucose * conversionFactor)).foregroundStyle(Color.loopRed)
+                RuleMark(y: .value("Low", lowGlucose)).foregroundStyle(Color.loopRed)
                     .lineStyle(.init(lineWidth: 1, dash: [5]))
             }
         }
@@ -220,7 +260,7 @@ extension MainChartView {
         .chartXScale(domain: startMarker ... endMarker)
         .chartXAxis(.hidden)
         .chartYAxis { mainChartYAxis }
-        .chartYScale(domain: minValue ... maxValue)
+        .chartYScale(domain: units == .mgdL ? minValue ... maxValue : minValue.asMmolL ... maxValue.asMmolL)
         .chartLegend(.hidden)
     }
 
@@ -262,20 +302,54 @@ extension MainChartView {
                 drawTempTargets()
                 drawActiveOverrides()
                 drawOverrideRunStored()
-                drawForecasts()
                 drawGlucose(dummy: false)
                 drawManualGlucose()
                 drawCarbs()
 
+                if state.displayForecastsAsLines {
+                    drawForecastsLines()
+                } else {
+                    drawForecastsCone()
+                }
+
                 /// show glucose value when hovering over it
-                if let selectedGlucose {
-                    RuleMark(x: .value("Selection", selectedGlucose.date ?? now, unit: .minute))
-                        .foregroundStyle(Color.tabBar)
-                        .offset(yStart: 70)
-                        .lineStyle(.init(lineWidth: 2, dash: [5]))
-                        .annotation(position: .top) {
-                            selectionPopover
-                        }
+                if #available(iOS 17, *) {
+                    if let selectedGlucose {
+                        RuleMark(x: .value("Selection", selectedGlucose.date ?? now, unit: .minute))
+                            .foregroundStyle(Color.tabBar)
+                            .offset(yStart: 70)
+                            .lineStyle(.init(lineWidth: 2))
+                            .annotation(
+                                position: .top,
+                                alignment: .center,
+                                overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
+                            ) {
+                                selectionPopover
+                            }
+
+                        PointMark(
+                            x: .value("Time", selectedGlucose.date ?? now, unit: .minute),
+                            y: .value("Value", selectedGlucose.glucose)
+                        )
+                        .zIndex(-1)
+                        .symbolSize(CGSize(width: 15, height: 15))
+                        .foregroundStyle(
+                            Decimal(selectedGlucose.glucose) > highGlucose ? Color.orange
+                                .opacity(0.8) :
+                                (
+                                    Decimal(selectedGlucose.glucose) < lowGlucose ? Color.red.opacity(0.8) : Color.green
+                                        .opacity(0.8)
+                                )
+                        )
+
+                        PointMark(
+                            x: .value("Time", selectedGlucose.date ?? now, unit: .minute),
+                            y: .value("Value", selectedGlucose.glucose)
+                        )
+                        .zIndex(-1)
+                        .symbolSize(CGSize(width: 6, height: 6))
+                        .foregroundStyle(Color.primary)
+                    }
                 }
             }
             .id("MainChart")
@@ -295,37 +369,57 @@ extension MainChartView {
             .chartYAxis { mainChartYAxis }
             .chartYAxis(.hidden)
             .backport.chartXSelection(value: $selection)
-            .chartYScale(domain: minValue ... maxValue)
-            .chartForegroundStyleScale([
-                "zt": Color.zt,
-                "uam": Color.uam,
-                "cob": .orange,
-                "iob": .blue
-            ])
-            .chartLegend(.hidden)
+            .chartYScale(domain: units == .mgdL ? minValue ... maxValue : minValue.asMmolL ... maxValue.asMmolL)
+            .backport.chartForegroundStyleScale(state: state)
         }
     }
 
     @ViewBuilder var selectionPopover: some View {
         if let sgv = selectedGlucose?.glucose {
-            let glucoseToShow = Decimal(sgv) * conversionFactor
-            VStack {
-                Text(selectedGlucose?.date?.formatted(.dateTime.hour().minute(.twoDigits)) ?? "")
+            let glucoseToShow = units == .mgdL ? Decimal(sgv) : Decimal(sgv).asMmolL
+            VStack(alignment: .leading) {
                 HStack {
-                    Text(glucoseToShow.formatted(.number.precision(units == .mmolL ? .fractionLength(1) : .fractionLength(0))))
-                        .fontWeight(.bold)
-                        .foregroundStyle(
-                            Decimal(sgv) < lowGlucose ? Color
-                                .red : (Decimal(sgv) > highGlucose ? Color.orange : Color.primary)
-                        )
-                    Text(units.rawValue).foregroundColor(.secondary)
+                    Image(systemName: "clock")
+                    Text(selectedGlucose?.date?.formatted(.dateTime.hour().minute(.twoDigits)) ?? "")
+                        .font(.body).bold()
+                }.font(.body).padding(.bottom, 5)
+
+                HStack {
+                    Text(units == .mgdL ? glucoseToShow.description : Decimal(sgv).formattedAsMmolL)
+                        .bold()
+                        + Text(" \(units.rawValue)")
+                }.foregroundStyle(
+                    glucoseToShow < lowGlucose ? Color
+                        .red : (glucoseToShow > highGlucose ? Color.orange : Color.primary)
+                ).font(.body)
+
+                if let selectedIOBValue, let iob = selectedIOBValue.iob {
+                    HStack {
+                        Image(systemName: "syringe.fill").frame(width: 15)
+                        Text(bolusFormatter.string(from: iob) ?? "")
+                            .bold()
+                            + Text(NSLocalizedString(" U", comment: "Insulin unit"))
+                    }.foregroundStyle(Color.insulin).font(.body)
+                }
+
+                if let selectedCOBValue {
+                    HStack {
+                        Image(systemName: "fork.knife").frame(width: 15)
+                        Text(carbsFormatter.string(from: selectedCOBValue.cob as NSNumber) ?? "")
+                            .bold()
+                            + Text(NSLocalizedString(" g", comment: "gram of carbs"))
+                    }.foregroundStyle(Color.orange).font(.body)
                 }
             }
-            .padding(6)
+            .padding()
             .background {
                 RoundedRectangle(cornerRadius: 4)
-                    .fill(Color.gray.opacity(0.1))
-                    .shadow(color: .blue, radius: 2)
+                    .fill(Color.chart.opacity(0.85))
+                    .shadow(color: Color.secondary, radius: 2)
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 4)
+                            .stroke(Color.secondary, lineWidth: 2)
+                    )
             }
         }
     }
@@ -359,8 +453,7 @@ extension MainChartView {
             .chartXAxis { basalChartXAxis }
             .chartXAxis(.hidden)
             .chartYAxis(.hidden)
-            .rotationEffect(.degrees(180))
-            .scaleEffect(x: -1, y: 1, anchor: .center)
+            .chartPlotStyle { basalChartPlotStyle($0) }
         }
     }
 
@@ -368,10 +461,29 @@ extension MainChartView {
         VStack {
             Chart {
                 drawIOB()
+
+                if #available(iOS 17, *) {
+                    if let selectedIOBValue {
+                        PointMark(
+                            x: .value("Time", selectedIOBValue.deliverAt ?? now, unit: .minute),
+                            y: .value("Value", Int(truncating: selectedIOBValue.iob ?? 0))
+                        )
+                        .symbolSize(CGSize(width: 15, height: 15))
+                        .foregroundStyle(Color.darkerBlue.opacity(0.8))
+
+                        PointMark(
+                            x: .value("Time", selectedIOBValue.deliverAt ?? now, unit: .minute),
+                            y: .value("Value", Int(truncating: selectedIOBValue.iob ?? 0))
+                        )
+                        .symbolSize(CGSize(width: 6, height: 6))
+                        .foregroundStyle(Color.primary)
+                    }
+                }
             }
             .frame(minHeight: geo.size.height * 0.12)
             .frame(width: fullWidth(viewWidth: screenSize.width))
             .chartXScale(domain: startMarker ... endMarker)
+            .backport.chartXSelection(value: $selection)
             .chartXAxis { basalChartXAxis }
             .chartYAxis { cobChartYAxis }
             .chartYScale(domain: minValueIobChart ... maxValueIobChart)
@@ -383,10 +495,29 @@ extension MainChartView {
         Chart {
             drawCurrentTimeMarker()
             drawCOB(dummy: false)
+
+            if #available(iOS 17, *) {
+                if let selectedCOBValue {
+                    PointMark(
+                        x: .value("Time", selectedCOBValue.deliverAt ?? now, unit: .minute),
+                        y: .value("Value", selectedCOBValue.cob)
+                    )
+                    .symbolSize(CGSize(width: 15, height: 15))
+                    .foregroundStyle(Color.orange.opacity(0.8))
+
+                    PointMark(
+                        x: .value("Time", selectedCOBValue.deliverAt ?? now, unit: .minute),
+                        y: .value("Value", selectedCOBValue.cob)
+                    )
+                    .symbolSize(CGSize(width: 6, height: 6))
+                    .foregroundStyle(Color.primary)
+                }
+            }
         }
         .frame(minHeight: geo.size.height * 0.12)
         .frame(width: fullWidth(viewWidth: screenSize.width))
         .chartXScale(domain: startMarker ... endMarker)
+        .backport.chartXSelection(value: $selection)
         .chartXAxis { basalChartXAxis }
         .chartYAxis { cobChartYAxis }
         .chartYScale(domain: minValueCobChart ... maxValueCobChart)
@@ -402,7 +533,7 @@ extension MainChartView {
             let bolusDate = insulin.timestamp ?? Date()
 
             if amount != 0, let glucose = timeToNearestGlucose(time: bolusDate.timeIntervalSince1970)?.glucose {
-                let yPosition = (Decimal(glucose) * conversionFactor) + bolusOffset
+                let yPosition = (units == .mgdL ? Decimal(glucose) : Decimal(glucose).asMmolL) + bolusOffset
                 let size = (Config.bolusSize + CGFloat(truncating: amount) * Config.bolusScale) * 1.8
 
                 PointMark(
@@ -415,7 +546,7 @@ extension MainChartView {
                 .annotation(position: .top) {
                     Text(bolusFormatter.string(from: amount) ?? "")
                         .font(.caption2)
-                        .foregroundStyle(Color.insulin)
+                        .foregroundStyle(Color.primary)
                 }
             }
         }
@@ -428,20 +559,21 @@ extension MainChartView {
             let carbDate = carb.date ?? Date()
 
             if let glucose = timeToNearestGlucose(time: carbDate.timeIntervalSince1970)?.glucose {
-                let yPosition = (Decimal(glucose) * conversionFactor) - bolusOffset
+                let yPosition = (units == .mgdL ? Decimal(glucose) : Decimal(glucose).asMmolL) - bolusOffset
                 let size = (Config.carbsSize + CGFloat(carbAmount) * Config.carbsScale)
+                let limitedSize = size > 30 ? 30 : size
 
                 PointMark(
                     x: .value("Time", carbDate, unit: .second),
                     y: .value("Value", yPosition)
                 )
                 .symbol {
-                    Image(systemName: "arrowtriangle.down.fill").font(.system(size: size)).foregroundStyle(Color.orange)
+                    Image(systemName: "arrowtriangle.down.fill").font(.system(size: limitedSize)).foregroundStyle(Color.orange)
                         .rotationEffect(.degrees(180))
                 }
                 .annotation(position: .bottom) {
                     Text(carbsFormatter.string(from: carbAmount as NSNumber)!).font(.caption2)
-                        .foregroundStyle(Color.orange)
+                        .foregroundStyle(Color.primary)
                 }
             }
         }
@@ -463,42 +595,66 @@ extension MainChartView {
         }
     }
 
+    private var stops: [Gradient.Stop] {
+        let low = Double(lowGlucose)
+        let high = Double(highGlucose)
+
+        let glucoseValues = state.glucoseFromPersistence
+            .map { units == .mgdL ? Decimal($0.glucose) : Decimal($0.glucose).asMmolL }
+
+        let minimum = glucoseValues.min() ?? 0.0
+        let maximum = glucoseValues.max() ?? 0.0
+
+        // Calculate positions for gradient
+        let lowPosition = (low - Double(truncating: minimum as NSNumber)) /
+            (Double(truncating: maximum as NSNumber) - Double(truncating: minimum as NSNumber))
+        let highPosition = (high - Double(truncating: minimum as NSNumber)) /
+            (Double(truncating: maximum as NSNumber) - Double(truncating: minimum as NSNumber))
+
+        // Ensure positions are in bounds [0, 1]
+        let clampedLowPosition = max(0.0, min(lowPosition, 1.0))
+        let clampedHighPosition = max(0.0, min(highPosition, 1.0))
+
+        // Ensure lowPosition is less than highPosition
+        let sortedPositions = [clampedLowPosition, clampedHighPosition].sorted()
+
+        return [
+            Gradient.Stop(color: .red, location: 0.0),
+            Gradient.Stop(color: .red, location: sortedPositions[0]), // draw red gradient till lowGlucose
+            Gradient.Stop(color: .green, location: sortedPositions[0] + 0.0001), // draw green above lowGlucose till highGlucose
+            Gradient.Stop(color: .green, location: sortedPositions[1]),
+            Gradient.Stop(color: .orange, location: sortedPositions[1] + 0.0001), // draw orange above highGlucose
+            Gradient.Stop(color: .orange, location: 1.0)
+        ]
+    }
+
     private func drawGlucose(dummy _: Bool) -> some ChartContent {
         /// glucose point mark
         /// filtering for high and low bounds in settings
         ForEach(state.glucoseFromPersistence) { item in
+            let glucoseToDisplay = units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL
+
             if smooth {
-                if item.glucose > Int(highGlucose) {
-                    PointMark(
-                        x: .value("Time", item.date ?? Date(), unit: .second),
-                        y: .value("Value", Decimal(item.glucose) * conversionFactor)
-                    ).foregroundStyle(Color.orange.gradient).symbolSize(20).interpolationMethod(.cardinal)
-                } else if item.glucose < Int(lowGlucose) {
-                    PointMark(
-                        x: .value("Time", item.date ?? Date(), unit: .second),
-                        y: .value("Value", Decimal(item.glucose) * conversionFactor)
-                    ).foregroundStyle(Color.red.gradient).symbolSize(20).interpolationMethod(.cardinal)
-                } else {
-                    PointMark(
-                        x: .value("Time", item.date ?? Date(), unit: .second),
-                        y: .value("Value", Decimal(item.glucose) * conversionFactor)
-                    ).foregroundStyle(Color.green.gradient).symbolSize(20).interpolationMethod(.cardinal)
-                }
+                LineMark(x: .value("Time", item.date ?? Date()), y: .value("Value", glucoseToDisplay))
+                    .foregroundStyle(
+                        .linearGradient(stops: stops, startPoint: .bottom, endPoint: .top)
+                    )
+                    .symbol(.circle).symbolSize(34)
             } else {
-                if item.glucose > Int(highGlucose) {
+                if glucoseToDisplay > highGlucose {
                     PointMark(
                         x: .value("Time", item.date ?? Date(), unit: .second),
-                        y: .value("Value", Decimal(item.glucose) * conversionFactor)
+                        y: .value("Value", glucoseToDisplay)
                     ).foregroundStyle(Color.orange.gradient).symbolSize(20)
-                } else if item.glucose < Int(lowGlucose) {
+                } else if glucoseToDisplay < lowGlucose {
                     PointMark(
                         x: .value("Time", item.date ?? Date(), unit: .second),
-                        y: .value("Value", Decimal(item.glucose) * conversionFactor)
+                        y: .value("Value", glucoseToDisplay)
                     ).foregroundStyle(Color.red.gradient).symbolSize(20)
                 } else {
                     PointMark(
                         x: .value("Time", item.date ?? Date(), unit: .second),
-                        y: .value("Value", Decimal(item.glucose) * conversionFactor)
+                        y: .value("Value", glucoseToDisplay)
                     ).foregroundStyle(Color.green.gradient).symbolSize(20)
                 }
             }
@@ -511,18 +667,66 @@ extension MainChartView {
         return currentTime.addingTimeInterval(timeInterval)
     }
 
-    private func drawForecasts() -> some ChartContent {
+    private func drawForecastsCone() -> some ChartContent {
+        // Draw AreaMark for the forecast bounds
+        ForEach(0 ..< max(state.minForecast.count, state.maxForecast.count), id: \.self) { index in
+            if index < state.minForecast.count, index < state.maxForecast.count {
+                let yMinMaxDelta = Decimal(state.minForecast[index] - state.maxForecast[index])
+                let xValue = timeForIndex(Int32(index))
+
+                // if distance between respective min and max is 0, provide a default range
+                if yMinMaxDelta == 0 {
+                    let yMinValue = units == .mgdL ? Decimal(state.minForecast[index] - 1) :
+                        Decimal(state.minForecast[index] - 1)
+                        .asMmolL
+                    let yMaxValue = units == .mgdL ? Decimal(state.minForecast[index] + 1) :
+                        Decimal(state.minForecast[index] + 1)
+                        .asMmolL
+
+                    if xValue <= Date(timeIntervalSinceNow: TimeInterval(hours: 2.5)) {
+                        AreaMark(
+                            x: .value("Time", xValue),
+                            // maxValue is already parsed to user units, no need to parse
+                            yStart: .value("Min Value", yMinValue <= maxValue ? yMinValue : maxValue),
+                            yEnd: .value("Max Value", yMaxValue <= maxValue ? yMaxValue : maxValue)
+                        )
+                        .foregroundStyle(Color.blue.opacity(0.5))
+                        .interpolationMethod(.catmullRom)
+                    }
+                } else {
+                    let yMinValue = units == .mgdL ? Decimal(state.minForecast[index]) : Decimal(state.minForecast[index]).asMmolL
+                    let yMaxValue = units == .mgdL ? Decimal(state.maxForecast[index]) : Decimal(state.maxForecast[index]).asMmolL
+
+                    if xValue <= Date(timeIntervalSinceNow: TimeInterval(hours: 2.5)) {
+                        AreaMark(
+                            x: .value("Time", xValue),
+                            // maxValue is already parsed to user units, no need to parse
+                            yStart: .value("Min Value", yMinValue <= maxValue ? yMinValue : maxValue),
+                            yEnd: .value("Max Value", yMaxValue <= maxValue ? yMaxValue : maxValue)
+                        )
+                        .foregroundStyle(Color.blue.opacity(0.5))
+                        .interpolationMethod(.catmullRom)
+                    }
+                }
+            }
+        }
+    }
+
+    private func drawForecastsLines() -> some ChartContent {
         ForEach(state.preprocessedData, id: \.id) { tuple in
             let forecastValue = tuple.forecastValue
             let forecast = tuple.forecast
             let valueAsDecimal = Decimal(forecastValue.value)
             let displayValue = units == .mmolL ? valueAsDecimal.asMmolL : valueAsDecimal
+            let xValue = timeForIndex(forecastValue.index)
 
-            LineMark(
-                x: .value("Time", timeForIndex(forecastValue.index)),
-                y: .value("Value", displayValue)
-            )
-            .foregroundStyle(by: .value("Predictions", forecast.type ?? ""))
+            if xValue <= Date(timeIntervalSinceNow: TimeInterval(hours: 2.5)) {
+                LineMark(
+                    x: .value("Time", xValue),
+                    y: .value("Value", displayValue)
+                )
+                .foregroundStyle(by: .value("Predictions", forecast.type ?? ""))
+            }
         }
     }
 
@@ -615,10 +819,10 @@ extension MainChartView {
     private func drawManualGlucose() -> some ChartContent {
         /// manual glucose mark
         ForEach(state.manualGlucoseFromPersistence) { item in
-            let manualGlucose = item.glucose
+            let manualGlucose = units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL
             PointMark(
                 x: .value("Time", item.date ?? Date(), unit: .second),
-                y: .value("Value", Decimal(manualGlucose) * conversionFactor)
+                y: .value("Value", manualGlucose)
             )
             .symbol {
                 Image(systemName: "drop.fill").font(.system(size: 10)).symbolRenderingMode(.monochrome)
@@ -659,7 +863,8 @@ extension MainChartView {
 
     private func drawIOB() -> some ChartContent {
         ForEach(state.enactedAndNonEnactedDeterminations) { iob in
-            let amount: Double = (iob.iob?.doubleValue ?? 0 / interpolationFactor)
+            let rawAmount = iob.iob?.doubleValue ?? 0
+            let amount: Double = rawAmount > 0 ? rawAmount : rawAmount * 2 // weigh negative iob with factor 2
             let date: Date = iob.deliverAt ?? Date()
 
             LineMark(x: .value("Time", date), y: .value("Amount", amount))
@@ -876,9 +1081,10 @@ extension MainChartView {
             isTempTargetActive = firstNonZeroTarget.createdAt <= now && now <= end
 
             if firstNonZeroTarget.targetTop != nil {
+                let targetTop = firstNonZeroTarget.targetTop ?? 0
                 calculatedTTs
                     .append(ChartTempTarget(
-                        amount: (firstNonZeroTarget.targetTop ?? 0) * conversionFactor,
+                        amount: units == .mgdL ? targetTop : targetTop.asMmolL,
                         start: firstNonZeroTarget.createdAt,
                         end: end
                     ))
@@ -949,7 +1155,18 @@ extension MainChartView {
     /// update start and  end marker to fix scroll update problem with x axis
     private func updateStartEndMarkers() {
         startMarker = Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970 - 86400))
-        endMarker = Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970 + 10800))
+
+        let threeHourSinceNow = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
+
+        // min is 1.5h -> (1.5*1h = 1.5*(5*12*60))
+        let dynamicFutureDateForCone = Date(timeIntervalSinceNow: TimeInterval(
+            Int(1.5) * 5 * state
+                .minCount * 60
+        ))
+
+        endMarker = state
+            .displayForecastsAsLines ? threeHourSinceNow : dynamicFutureDateForCone <= threeHourSinceNow ?
+            dynamicFutureDateForCone.addingTimeInterval(TimeInterval(minutes: 30)) : threeHourSinceNow
     }
 
     private func calculateBasals() {
@@ -991,16 +1208,19 @@ extension MainChartView {
               let minForecast = forecastValues.min(), let maxForecast = forecastValues.max()
         else {
             // default values
-            minValue = 45 * conversionFactor - 20 * conversionFactor
-            maxValue = 270 * conversionFactor + 50 * conversionFactor
+            minValue = 45 - 20
+            maxValue = 270 + 50
             return
         }
 
-        let minOverall = min(minGlucose, minForecast)
-        let maxOverall = max(maxGlucose, maxForecast)
+        // Ensure maxForecast is not more than 100 over maxGlucose
+        let adjustedMaxForecast = min(maxForecast, maxGlucose + 100)
+
+        var minOverall = min(minGlucose, minForecast)
+        var maxOverall = max(maxGlucose, adjustedMaxForecast)
 
-        minValue = minOverall * conversionFactor - 50 * conversionFactor
-        maxValue = maxOverall * conversionFactor + 80 * conversionFactor
+        minValue = minOverall - 50
+        maxValue = maxOverall + 80
     }
 
     private func yAxisChartDataCobChart() {
@@ -1033,7 +1253,6 @@ extension MainChartView {
         plotContent
             .rotationEffect(.degrees(180))
             .scaleEffect(x: -1, y: 1)
-            .chartXAxis(.hidden)
     }
 
     private var mainChartXAxis: some AxisContent {

+ 9 - 14
FreeAPS/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift

@@ -84,13 +84,13 @@ struct CurrentGlucoseView: View {
                 VStack(alignment: .center) {
                     HStack {
                         if let glucoseValue = combinedGlucoseValues.first?.glucose {
-                            let displayGlucose = convertGlucose(glucoseValue, to: units)
+                            let displayGlucose = units == .mgdL ? Decimal(glucoseValue).description : Decimal(glucoseValue)
+                                .formattedAsMmolL
                             Text(
-                                glucoseValue == 400 ? "HIGH" :
-                                    glucoseFormatter.string(from: NSNumber(value: displayGlucose)) ?? "--"
+                                glucoseValue == 400 ? "HIGH" : displayGlucose
                             )
                             .font(.system(size: 40, weight: .bold, design: .rounded))
-                            .foregroundColor(alarm == nil ? colourGlucoseText : .loopRed)
+                            .foregroundColor(alarm == nil ? glucoseDisplayColor : .loopRed)
                         } else {
                             Text("--")
                                 .font(.system(size: 40, weight: .bold, design: .rounded))
@@ -155,15 +155,6 @@ struct CurrentGlucoseView: View {
         }
     }
 
-    private func convertGlucose(_ value: Int16, to units: GlucoseUnits) -> Double {
-        switch units {
-        case .mmolL:
-            return Double(value) / 18.0
-        case .mgdL:
-            return Double(value)
-        }
-    }
-
     private var delta: String {
         guard combinedGlucoseValues.count >= 2 else {
             return "--"
@@ -176,13 +167,17 @@ struct CurrentGlucoseView: View {
         return deltaFormatter.string(from: deltaAsDecimal as NSNumber) ?? "--"
     }
 
-    var colourGlucoseText: Color {
+    var glucoseDisplayColor: Color {
         // Fetch the first glucose reading and convert it to Int for comparison
         let whichGlucose = Int(combinedGlucoseValues.first?.glucose ?? 0)
 
         // Define default color based on the color scheme
         let defaultColor: Color = colorScheme == .dark ? .white : .black
 
+        // low and high glucose is parsed in state to mmol/L; parse it back to mg/dl here for comparison
+        let lowGlucose = units == .mgdL ? lowGlucose : lowGlucose.asMgdL
+        let highGlucose = units == .mgdL ? highGlucose : highGlucose.asMgdL
+
         // Ensure the thresholds are logical
         guard lowGlucose < highGlucose else { return .primary }
 

+ 36 - 24
FreeAPS/Sources/Modules/Home/View/HomeRootView.swift

@@ -697,7 +697,7 @@ extension Home {
                         Spacer()
 
                         Button {
-                            state.waitForSuggestion = true
+                            state.showProgressView()
                             state.cancelBolus()
                         } label: {
                             Image(systemName: "xmark.app")
@@ -787,30 +787,42 @@ extension Home {
                     .font(.subheadline)
                     .foregroundColor(.secondary)
 
-                    List {
-                        DefinitionRow(
-                            term: "IOB (Insulin on Board)",
-                            definition: "Forecasts BG based on the amount of insulin still active in the body.",
-                            color: .insulin
-                        )
-                        DefinitionRow(
-                            term: "ZT (Zero-Temp)",
-                            definition: "Forecasts the worst-case blood glucose (BG) scenario if no carbs are absorbed and insulin delivery is stopped until BG starts rising.",
-                            color: .zt
-                        )
-                        DefinitionRow(
-                            term: "COB (Carbs on Board)",
-                            definition: "Forecasts BG changes by considering the amount of carbohydrates still being absorbed in the body.",
-                            color: .loopYellow
-                        )
-                        DefinitionRow(
-                            term: "UAM (Unannounced Meal)",
-                            definition: "Forecasts BG levels and insulin dosing needs for unexpected meals or other causes of BG rises without prior notice.",
-                            color: .uam
-                        )
+                    if state.settingsManager.settings.displayForecastsAsLines {
+                        List {
+                            DefinitionRow(
+                                term: "IOB (Insulin on Board)",
+                                definition: "Forecasts BG based on the amount of insulin still active in the body.",
+                                color: .insulin
+                            )
+                            DefinitionRow(
+                                term: "ZT (Zero-Temp)",
+                                definition: "Forecasts the worst-case blood glucose (BG) scenario if no carbs are absorbed and insulin delivery is stopped until BG starts rising.",
+                                color: .zt
+                            )
+                            DefinitionRow(
+                                term: "COB (Carbs on Board)",
+                                definition: "Forecasts BG changes by considering the amount of carbohydrates still being absorbed in the body.",
+                                color: .loopYellow
+                            )
+                            DefinitionRow(
+                                term: "UAM (Unannounced Meal)",
+                                definition: "Forecasts BG levels and insulin dosing needs for unexpected meals or other causes of BG rises without prior notice.",
+                                color: .uam
+                            )
+                        }
+                        .padding(.trailing, 10)
+                        .navigationBarTitle("Legend", displayMode: .inline)
+                    } else {
+                        List {
+                            DefinitionRow(
+                                term: "Cone of Uncertainty",
+                                definition: "For simplicity reasons, oref's various forecast curves are displayed as a \"Cone of Uncertainty\" that depicts a possible, forecasted range of future glucose fluctuation based on the current data and the algothim's result.",
+                                color: Color.blue.opacity(0.5)
+                            )
+                        }
+                        .padding(.trailing, 10)
+                        .navigationBarTitle("Legend", displayMode: .inline)
                     }
-                    .padding(.trailing, 10)
-                    .navigationBarTitle("Legend", displayMode: .inline)
 
                     Button { state.isLegendPresented.toggle() }
                     label: { Text("Got it!").frame(maxWidth: .infinity, alignment: .center) }

+ 3 - 14
FreeAPS/Sources/Modules/OverrideConfig/View/OverrideRootView.swift

@@ -51,17 +51,6 @@ extension OverrideConfig {
             return formatter
         }
 
-        private var glucoseFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 0
-            if state.units == .mmolL {
-                formatter.maximumFractionDigits = 1
-            }
-            formatter.roundingMode = .halfUp
-            return formatter
-        }
-
         var body: some View {
             VStack {
                 Picker("Tab", selection: $state.selectedTab) {
@@ -449,8 +438,8 @@ extension OverrideConfig {
         }
 
         @ViewBuilder private func overridesView(for preset: OverrideStored) -> some View {
-            let target = state.units == .mmolL ? (((preset.target ?? 0) as NSDecimalNumber) as Decimal)
-                .asMmolL : (preset.target ?? 0) as Decimal
+            let target = (state.units == .mgdL ? preset.target : preset.target?.decimalValue.asMmolL as NSDecimalNumber?) ?? 0
+
             let duration = (preset.duration ?? 0) as Decimal
             let name = ((preset.name ?? "") == "") || (preset.name?.isEmpty ?? true) ? "" : preset.name!
             let percent = preset.percentage / 100
@@ -458,7 +447,7 @@ extension OverrideConfig {
             let durationString = perpetual ? "" : "\(formatter.string(from: duration as NSNumber)!)"
             let scheduledSMBstring = (preset.smbIsOff && preset.smbIsAlwaysOff) ? "Scheduled SMBs" : ""
             let smbString = (preset.smbIsOff && scheduledSMBstring == "") ? "SMBs are off" : ""
-            let targetString = target != 0 ? "\(glucoseFormatter.string(from: target as NSNumber)!)" : ""
+            let targetString = target != 0 ? target.description : ""
             let maxMinutesSMB = (preset.smbMinutes as Decimal?) != nil ? (preset.smbMinutes ?? 0) as Decimal : 0
             let maxMinutesUAM = (preset.uamMinutes as Decimal?) != nil ? (preset.uamMinutes ?? 0) as Decimal : 0
             let isfString = preset.isf ? "ISF" : ""

+ 2 - 0
FreeAPS/Sources/Modules/StatConfig/StatConfigStateModel.swift

@@ -16,6 +16,7 @@ extension StatConfig {
         @Published var yGridLines: Bool = false
         @Published var oneDimensionalGraph = false
         @Published var rulerMarks: Bool = true
+        @Published var displayForecastsAsLines: Bool = false
 
         var units: GlucoseUnits = .mgdL
 
@@ -27,6 +28,7 @@ extension StatConfig {
             subscribeSetting(\.xGridLines, on: $xGridLines) { xGridLines = $0 }
             subscribeSetting(\.yGridLines, on: $yGridLines) { yGridLines = $0 }
             subscribeSetting(\.rulerMarks, on: $rulerMarks) { rulerMarks = $0 }
+            subscribeSetting(\.displayForecastsAsLines, on: $displayForecastsAsLines) { displayForecastsAsLines = $0 }
             subscribeSetting(\.useFPUconversion, on: $useFPUconversion) { useFPUconversion = $0 }
             subscribeSetting(\.tins, on: $tins) { tins = $0 }
             subscribeSetting(\.skipBolusScreenAfterCarbs, on: $skipBolusScreenAfterCarbs) { skipBolusScreenAfterCarbs = $0 }

+ 1 - 0
FreeAPS/Sources/Modules/StatConfig/View/StatConfigRootView.swift

@@ -56,6 +56,7 @@ extension StatConfig {
                         TextFieldWithToolBar(text: $state.hours, placeholder: "6", numberFormatter: carbsFormatter)
                         Text("hours").foregroundColor(.secondary)
                     }
+                    Toggle("Show Forecasts as Lines", isOn: $state.displayForecastsAsLines)
                 } header: { Text("Home Chart settings ") }
 
                 Section {

+ 3 - 3
FreeAPS/Sources/Services/LiveActivity/LiveActitiyAttributes.swift

@@ -15,11 +15,11 @@ struct LiveActivityAttributes: ActivityAttributes {
     }
 
     public struct ContentAdditionalState: Codable, Hashable {
-        let chart: [Double]
+        let chart: [Decimal]
         let chartDate: [Date?]
         let rotationDegrees: Double
-        let highGlucose: Double
-        let lowGlucose: Double
+        let highGlucose: Decimal
+        let lowGlucose: Decimal
         let cob: Decimal
         let iob: Decimal
         let unit: String

+ 8 - 20
FreeAPS/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift

@@ -79,32 +79,20 @@ extension LiveActivityAttributes.ContentState {
 
         switch settings.lockScreenView {
         case .detailed:
-            let chartBG = chart.map(\.glucose)
-
-            let conversionFactor: Double = settings.units == .mmolL ? 18.0 : 1.0
-            let convertedChartBG = chartBG.map { Double($0) / conversionFactor }
-
+            let chartBG = chart.map { Decimal($0.glucose) }
             let chartDate = chart.map(\.date)
 
             /// glucose limits from UI settings, not from notifications settings
-            let highGlucose = settings.high / Decimal(conversionFactor)
-            let lowGlucose = settings.low / Decimal(conversionFactor)
-
-            let cob = determination?.cob ?? 0
-            let iob = determination?.iob ?? 0
-            let unit = settings.units == .mmolL ? " mmol/L" : " mg/dL"
-            let isOverrideActive = override?.isActive ?? false
-
             detailedState = LiveActivityAttributes.ContentAdditionalState(
-                chart: convertedChartBG,
+                chart: chartBG,
                 chartDate: chartDate,
                 rotationDegrees: rotationDegrees,
-                highGlucose: Double(highGlucose),
-                lowGlucose: Double(lowGlucose),
-                cob: Decimal(cob),
-                iob: iob as Decimal,
-                unit: unit,
-                isOverrideActive: isOverrideActive
+                highGlucose: settings.high,
+                lowGlucose: settings.low,
+                cob: Decimal(determination?.cob ?? 0),
+                iob: determination?.iob ?? 0 as Decimal,
+                unit: settings.units.rawValue,
+                isOverrideActive: override?.isActive ?? false
             )
 
         case .simple:

+ 103 - 95
FreeAPS/Sources/Services/WatchManager/WatchManager.swift

@@ -67,6 +67,7 @@ final class BaseWatchManager: NSObject, WatchManager, Injectable {
         setupNotification()
         coreDataObserver = CoreDataObserver()
         registerHandlers()
+
         Task {
             await configureState()
         }
@@ -91,10 +92,6 @@ final class BaseWatchManager: NSObject, WatchManager, Injectable {
             }
             return data
         }
-
-        Task {
-            await configureState()
-        }
     }
 
     func setupNotification() {
@@ -190,111 +187,122 @@ final class BaseWatchManager: NSObject, WatchManager, Injectable {
         }
     }
 
-    @MainActor private func configureState() async {
+    private func configureState() async {
         let glucoseValuesIDs = await fetchGlucose()
-        guard let lastDeterminationID = await fetchlastDetermination().first,
-              let latestOverrideID = await fetchLatestOverride() else { return }
+        async let lastDeterminationIDs = fetchlastDetermination()
+        async let latestOverrideID = fetchLatestOverride()
+
+        guard let lastDeterminationID = await lastDeterminationIDs.first,
+              let latestOverrideID = await latestOverrideID
+        else {
+            debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to get last Determination/ last Override")
+            return
+        }
 
         do {
-            let glucoseValues = try glucoseValuesIDs.compactMap { id in
-                try viewContext.existingObject(with: id) as? GlucoseStored
-            }
+            let glucoseValues: [GlucoseStored] = await CoreDataStack.shared
+                .getNSManagedObject(with: glucoseValuesIDs, context: viewContext)
 
             let lastDetermination = try viewContext.existingObject(with: lastDeterminationID) as? OrefDetermination
             let latestOverride = try viewContext.existingObject(with: latestOverrideID) as? OverrideStored
 
-            if let firstGlucoseValue = glucoseValues.first {
-                let value = settingsManager.settings
-                    .units == .mgdL ? Decimal(firstGlucoseValue.glucose) : Decimal(firstGlucoseValue.glucose).asMmolL
-                state.glucose = glucoseFormatter.string(from: value as NSNumber)
-                state.trend = firstGlucoseValue.directionEnum?.symbol
-                let delta = glucoseValues
-                    .count >= 2 ? Decimal(firstGlucoseValue.glucose) - Decimal(glucoseValues.dropFirst().first?.glucose ?? 0) : 0
-                let deltaConverted = settingsManager.settings.units == .mgdL ? delta : delta.asMmolL
-                state.delta = deltaFormatter.string(from: deltaConverted as NSNumber)
-                state.trendRaw = firstGlucoseValue.direction
-                state.glucoseDate = firstGlucoseValue.date
-            }
-
-            state.lastLoopDate = lastDetermination?.timestamp
-            state.lastLoopDateInterval = state.lastLoopDate.map {
-                guard $0.timeIntervalSince1970 > 0 else { return 0 }
-                return UInt64($0.timeIntervalSince1970)
-            }
-            state.bolusIncrement = settingsManager.preferences.bolusIncrement
-            state.maxCOB = settingsManager.preferences.maxCOB
-            state.maxBolus = settingsManager.pumpSettings.maxBolus
-            state.carbsRequired = lastDetermination?.carbsRequired as? Decimal
-
-            var insulinRequired = lastDetermination?.insulinReq as? Decimal ?? 0
-
-            var double: Decimal = 2
-            if lastDetermination?.manualBolusErrorString == 0 {
-                insulinRequired = lastDetermination?.insulinForManualBolus as? Decimal ?? 0
-                double = 1
-            }
-
-            state.useNewCalc = settingsManager.settings.useCalc
+            let recommendedInsulin = await newBolusCalc(
+                ids: glucoseValuesIDs,
+                determination: lastDetermination
+            )
+
+            await MainActor.run { [weak self] in
+                guard let self = self else { return }
+
+                if let firstGlucoseValue = glucoseValues.first {
+                    let value = self.settingsManager.settings.units == .mgdL
+                        ? Decimal(firstGlucoseValue.glucose)
+                        : Decimal(firstGlucoseValue.glucose).asMmolL
+
+                    self.state.glucose = self.glucoseFormatter.string(from: value as NSNumber)
+                    self.state.trend = firstGlucoseValue.directionEnum?.symbol
+
+                    let delta = glucoseValues.count >= 2
+                        ? Decimal(firstGlucoseValue.glucose) - Decimal(glucoseValues.dropFirst().first?.glucose ?? 0)
+                        : 0
+                    let deltaConverted = self.settingsManager.settings.units == .mgdL ? delta : delta.asMmolL
+                    self.state.delta = self.deltaFormatter.string(from: deltaConverted as NSNumber)
+                    self.state.trendRaw = firstGlucoseValue.direction
+                    self.state.glucoseDate = firstGlucoseValue.date
+                }
 
-            if !(state.useNewCalc ?? false) {
-                state.bolusRecommended = apsManager
-                    .roundBolus(amount: max(
-                        insulinRequired * (settingsManager.settings.insulinReqPercentage / 100) * double,
-                        0
-                    ))
-            } else {
-                let recommended = await newBolusCalc(
-                    ids: glucoseValuesIDs,
-                    determination: lastDetermination
-                )
-                state.bolusRecommended = apsManager
-                    .roundBolus(amount: max(recommended, 0))
-            }
-            state.bolusAfterCarbs = !settingsManager.settings.skipBolusScreenAfterCarbs
-            state.displayOnWatch = settingsManager.settings.displayOnWatch
-            state.displayFatAndProteinOnWatch = settingsManager.settings.displayFatAndProteinOnWatch
-            state.confirmBolusFaster = settingsManager.settings.confirmBolusFaster
-
-            state.iob = lastDetermination?.iob as? Decimal
-            state.cob = lastDetermination?.cob as? Decimal
-            state.tempTargets = tempTargetsStorage.presets()
-                .map { target -> TempTargetWatchPreset in
-                    let untilDate = self.tempTargetsStorage.current().flatMap { currentTarget -> Date? in
-                        guard currentTarget.id == target.id else { return nil }
-                        let date = currentTarget.createdAt.addingTimeInterval(TimeInterval(currentTarget.duration * 60))
-                        return date > Date() ? date : nil
+                self.state.lastLoopDate = lastDetermination?.timestamp
+                self.state.lastLoopDateInterval = self.state.lastLoopDate.map {
+                    guard $0.timeIntervalSince1970 > 0 else { return 0 }
+                    return UInt64($0.timeIntervalSince1970)
+                }
+                self.state.bolusIncrement = self.settingsManager.preferences.bolusIncrement
+                self.state.maxCOB = self.settingsManager.preferences.maxCOB
+                self.state.maxBolus = self.settingsManager.pumpSettings.maxBolus
+                self.state.carbsRequired = lastDetermination?.carbsRequired as? Decimal
+
+//                var insulinRequired = lastDetermination?.insulinReq as? Decimal ?? 0
+//
+//                var double: Decimal = 2
+//                if lastDetermination?.manualBolusErrorString == 0 {
+//                    insulinRequired = lastDetermination?.insulinForManualBolus as? Decimal ?? 0
+//                    double = 1
+//                }
+
+                self.state.useNewCalc = self.settingsManager.settings.useCalc
+                self.state.bolusRecommended = self.apsManager
+                    .roundBolus(amount: max(recommendedInsulin, 0))
+                self.state.bolusAfterCarbs = !self.settingsManager.settings.skipBolusScreenAfterCarbs
+                self.state.displayOnWatch = self.settingsManager.settings.displayOnWatch
+                self.state.displayFatAndProteinOnWatch = self.settingsManager.settings.displayFatAndProteinOnWatch
+                self.state.confirmBolusFaster = self.settingsManager.settings.confirmBolusFaster
+
+                self.state.iob = lastDetermination?.iob as? Decimal
+                if let cobValue = lastDetermination?.cob {
+                    self.state.cob = Decimal(cobValue)
+                } else {
+                    self.state.cob = 0
+                }
+                self.state.tempTargets = self.tempTargetsStorage.presets()
+                    .map { target -> TempTargetWatchPreset in
+                        let untilDate = self.tempTargetsStorage.current().flatMap { currentTarget -> Date? in
+                            guard currentTarget.id == target.id else { return nil }
+                            let date = currentTarget.createdAt.addingTimeInterval(TimeInterval(currentTarget.duration * 60))
+                            return date > Date() ? date : nil
+                        }
+                        return TempTargetWatchPreset(
+                            name: target.displayName,
+                            id: target.id,
+                            description: self.descriptionForTarget(target),
+                            until: untilDate
+                        )
                     }
-                    return TempTargetWatchPreset(
-                        name: target.displayName,
-                        id: target.id,
-                        description: self.descriptionForTarget(target),
-                        until: untilDate
-                    )
+                self.state.bolusAfterCarbs = !self.settingsManager.settings.skipBolusScreenAfterCarbs
+                self.state.displayOnWatch = self.settingsManager.settings.displayOnWatch
+                self.state.displayFatAndProteinOnWatch = self.settingsManager.settings.displayFatAndProteinOnWatch
+                self.state.confirmBolusFaster = self.settingsManager.settings.confirmBolusFaster
+
+                if let eventualBG = self.settingsManager.settings.units == .mgdL ? lastDetermination?
+                    .eventualBG : lastDetermination?
+                    .eventualBG?.decimalValue.asMmolL as NSDecimalNumber?
+                {
+                    let eventualBGAsString = self.eventualFormatter.string(from: eventualBG)
+                    self.state.eventualBG = eventualBGAsString.map { "⇢ " + $0 }
+                    self.state.eventualBGRaw = eventualBGAsString
                 }
-            state.bolusAfterCarbs = !settingsManager.settings.skipBolusScreenAfterCarbs
-            state.displayOnWatch = settingsManager.settings.displayOnWatch
-            state.displayFatAndProteinOnWatch = settingsManager.settings.displayFatAndProteinOnWatch
-            state.confirmBolusFaster = settingsManager.settings.confirmBolusFaster
-
-            if let eventualBG = settingsManager.settings.units == .mgdL ? lastDetermination?.eventualBG : lastDetermination?
-                .eventualBG?.decimalValue.asMmolL as NSDecimalNumber?
-            {
-                let eventualBGAsString = eventualFormatter.string(from: eventualBG)
-                state.eventualBG = eventualBGAsString.map { "⇢ " + $0 }
-                state.eventualBGRaw = eventualBGAsString
-            }
 
-            state.isf = lastDetermination?.insulinSensitivity as? Decimal
+                self.state.isf = lastDetermination?.insulinSensitivity as? Decimal
 
-            if latestOverride?.enabled ?? false {
-                let percentString = "\((latestOverride?.percentage ?? 100).formatted(.number)) %"
-                state.override = percentString
+                if latestOverride?.enabled ?? false {
+                    let percentString = "\((latestOverride?.percentage ?? 100).formatted(.number)) %"
+                    self.state.override = percentString
 
-            } else {
-                state.override = "100 %"
-            }
+                } else {
+                    self.state.override = "100 %"
+                }
 
-            sendState()
+                self.sendState()
+            }
 
         } catch let error as NSError {
             debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to configure state with error: \(error)")

+ 28 - 2
FreeAPS/Sources/Views/TextFieldWithToolBar.swift

@@ -15,6 +15,8 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
     var textFieldDidBeginEditing: (() -> Void)?
     var numberFormatter: NumberFormatter
     var allowDecimalSeparator: Bool
+    var previousTextField: (() -> Void)?
+    var nextTextField: (() -> Void)?
 
     public init(
         text: Binding<Decimal>,
@@ -29,7 +31,9 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
         isDismissible: Bool = true,
         textFieldDidBeginEditing: (() -> Void)? = nil,
         numberFormatter: NumberFormatter,
-        allowDecimalSeparator: Bool = true
+        allowDecimalSeparator: Bool = true,
+        previousTextField: (() -> Void)? = nil,
+        nextTextField: (() -> Void)? = nil
     ) {
         _text = text
         self.placeholder = placeholder
@@ -45,6 +49,8 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
         self.numberFormatter = numberFormatter
         self.numberFormatter.numberStyle = .decimal
         self.allowDecimalSeparator = allowDecimalSeparator
+        self.previousTextField = previousTextField
+        self.nextTextField = nextTextField
     }
 
     public func makeUIView(context: Context) -> UITextField {
@@ -77,8 +83,20 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
             target: context.coordinator,
             action: #selector(Coordinator.clearText)
         )
+        let previousButton = UIBarButtonItem(
+            image: UIImage(systemName: "chevron.up"),
+            style: .plain,
+            target: context.coordinator,
+            action: #selector(Coordinator.previousTextField)
+        )
+        let nextButton = UIBarButtonItem(
+            image: UIImage(systemName: "chevron.down"),
+            style: .plain,
+            target: context.coordinator,
+            action: #selector(Coordinator.nextTextField)
+        )
 
-        toolbar.items = [clearButton, flexibleSpace, doneButton]
+        toolbar.items = [clearButton, previousButton, nextButton, flexibleSpace, doneButton]
         toolbar.sizeToFit()
         return toolbar
     }
@@ -136,6 +154,14 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
             }
         }
 
+        @objc fileprivate func previousTextField() {
+            parent.previousTextField?()
+        }
+
+        @objc fileprivate func nextTextField() {
+            parent.nextTextField?()
+        }
+
         // Helper method to calculate the number of decimal places in a string
         fileprivate func calculateDecimalPlaces(in string: String) -> Int {
             guard let decimalSeparator = decimalFormatter.decimalSeparator else { return 0 }

+ 64 - 9
LiveActivity/LiveActivity.swift

@@ -1,5 +1,6 @@
 import ActivityKit
 import Charts
+import Foundation
 import SwiftUI
 import WidgetKit
 
@@ -9,6 +10,55 @@ private enum Size {
     case expanded
 }
 
+enum GlucoseUnits: String, Equatable {
+    case mgdL = "mg/dL"
+    case mmolL = "mmol/L"
+
+    static let exchangeRate: Decimal = 0.0555
+}
+
+func rounded(_ value: Decimal, scale: Int, roundingMode: NSDecimalNumber.RoundingMode) -> Decimal {
+    var result = Decimal()
+    var toRound = value
+    NSDecimalRound(&result, &toRound, scale, roundingMode)
+    return result
+}
+
+extension Int {
+    var asMmolL: Decimal {
+        rounded(Decimal(self) * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain)
+    }
+
+    var formattedAsMmolL: String {
+        NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
+    }
+}
+
+extension Decimal {
+    var asMmolL: Decimal {
+        rounded(self * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain)
+    }
+
+    var asMgdL: Decimal {
+        rounded(self / GlucoseUnits.exchangeRate, scale: 0, roundingMode: .plain)
+    }
+
+    var formattedAsMmolL: String {
+        NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
+    }
+}
+
+extension NumberFormatter {
+    static let glucoseFormatter: NumberFormatter = {
+        let formatter = NumberFormatter()
+        formatter.locale = Locale.current
+        formatter.numberStyle = .decimal
+        formatter.minimumFractionDigits = 1
+        formatter.maximumFractionDigits = 1
+        return formatter
+    }()
+}
+
 struct LiveActivity: Widget {
     private let dateFormatter: DateFormatter = {
         var f = DateFormatter()
@@ -215,27 +265,32 @@ struct LiveActivity: Widget {
             Text("No data available")
         } else {
             // Determine scale
-            let conversionFactor = additionalState.unit == "mmol/L" ? 0.0555 : 1
-            let min = (additionalState.chart.min() ?? 40 * conversionFactor) - 20 * conversionFactor
-            let max = (additionalState.chart.max() ?? 270 * conversionFactor) + 50 * conversionFactor
+            let min = min(additionalState.chart.min() ?? 45, 40) - 20
+            let max = max(additionalState.chart.max() ?? 270, 300) + 50
+
+            let yAxisRuleMarkMin = additionalState.unit == "mg/dL" ? additionalState.lowGlucose : additionalState.lowGlucose
+                .asMmolL
+            let yAxisRuleMarkMax = additionalState.unit == "mg/dL" ? additionalState.highGlucose : additionalState.highGlucose
+                .asMmolL
 
             Chart {
-                RuleMark(y: .value("High", additionalState.highGlucose))
+                RuleMark(y: .value("Low", yAxisRuleMarkMin))
                     .lineStyle(.init(lineWidth: 0.5, dash: [5]))
-                RuleMark(y: .value("Low", additionalState.lowGlucose))
+                RuleMark(y: .value("High", yAxisRuleMarkMax))
                     .lineStyle(.init(lineWidth: 0.5, dash: [5]))
 
                 ForEach(additionalState.chart.indices, id: \.self) { index in
                     let currentValue = additionalState.chart[index]
+                    let displayValue = additionalState.unit == "mg/dL" ? currentValue : currentValue.asMmolL
                     let chartDate = additionalState.chartDate[index] ?? Date()
                     let pointMark = PointMark(
                         x: .value("Time", chartDate),
-                        y: .value("Value", currentValue)
+                        y: .value("Value", displayValue)
                     ).symbolSize(15)
 
-                    if currentValue > additionalState.highGlucose {
+                    if displayValue > yAxisRuleMarkMax {
                         pointMark.foregroundStyle(Color.orange.gradient)
-                    } else if currentValue < additionalState.lowGlucose {
+                    } else if displayValue < yAxisRuleMarkMin {
                         pointMark.foregroundStyle(Color.red.gradient)
                     } else {
                         pointMark.foregroundStyle(Color.green.gradient)
@@ -248,7 +303,7 @@ struct LiveActivity: Widget {
                     AxisValueLabel().foregroundStyle(.secondary).font(.footnote)
                 }
             }
-            .chartYScale(domain: min ... max)
+            .chartYScale(domain: additionalState.unit == "mg/dL" ? min ... max : min.asMmolL ... max.asMmolL)
             .chartXAxis {
                 AxisMarks(position: .automatic) { _ in
                     AxisGridLine(stroke: .init(lineWidth: 0.2, dash: [2, 3])).foregroundStyle(Color.white)

+ 20 - 0
Model/CoreDataStack.swift

@@ -403,6 +403,26 @@ extension CoreDataStack {
             }
         }
     }
+
+    // Get NSManagedObject
+    func getNSManagedObject<T: NSManagedObject>(
+        with ids: [NSManagedObjectID],
+        context: NSManagedObjectContext
+    ) async -> [T] {
+        await Task { () -> [T] in
+            var objects = [T]()
+            do {
+                for id in ids {
+                    if let object = try context.existingObject(with: id) as? T {
+                        objects.append(object)
+                    }
+                }
+            } catch {
+                debugPrint("Failed to fetch objects: \(error.localizedDescription)")
+            }
+            return objects
+        }.value
+    }
 }
 
 // MARK: - Save

+ 17 - 0
Model/Helper/CarbsGlucose+helper.swift

@@ -0,0 +1,17 @@
+import Foundation
+
+struct CarbAndGlucose: Encodable {
+    let carbs: Decimal
+    let glucose: Decimal
+
+    enum CodingKeys: String, CodingKey {
+        case carbs
+        case glucose
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(NSDecimalNumber(decimal: carbs).stringValue, forKey: .carbs)
+        try container.encode(NSDecimalNumber(decimal: glucose).stringValue, forKey: .glucose)
+    }
+}

+ 9 - 0
Model/Helper/NSPredicates.swift

@@ -30,6 +30,10 @@ extension Date {
         Calendar.current.date(byAdding: .hour, value: -2, to: Date())!
     }
 
+    static var fourHoursAgo: Date {
+        Calendar.current.date(byAdding: .hour, value: -4, to: Date())!
+    }
+
     static var sixHoursAgo: Date {
         Calendar.current.date(byAdding: .hour, value: -6, to: Date())!
     }
@@ -87,6 +91,11 @@ extension NSPredicate {
         return NSPredicate(format: "date >= %@", date as NSDate)
     }
 
+    static var predicateForFourHoursAgo: NSPredicate {
+        let date = Date.fourHoursAgo
+        return NSPredicate(format: "date >= %@", date as NSDate)
+    }
+
     static var predicateForSixHoursAgo: NSPredicate {
         let date = Date.sixHoursAgo
         return NSPredicate(format: "date >= %@", date as NSDate)

+ 4 - 1
oref0_source_version.txt

@@ -1,6 +1,9 @@
-oref0 branch: dev - git version: d1dfb70
+oref0 branch: dev - git version: 363fd11
 
 Last commits:
+363fd11 Merge pull request #28 from bjornoleh/harmonise_defaults
+2d695e1 index.js: set enableUAM to false, and remove whitespace in L11
+8f5f820 Harmonise profile defaults with openaps/oref0
 d1dfb70 Merge pull request #26 from MikePlante1/typo
 d9f1662 fix `threshold_setting` typo
 b454837 Merge pull request #24 from nightscout/Trio_renames

+ 15 - 16
trio-oref/lib/profile/index.js

@@ -8,11 +8,11 @@ var _ = require('lodash');
 
 function defaults ( ) {
   return /* profile */ {
-    max_iob: 9 // if max_iob is not provided, will default to zero
-    , max_daily_safety_multiplier: 5
-    , current_basal_safety_multiplier: 6
-    , autosens_max: 2.5
-    , autosens_min: 0.5
+    max_iob: 0 // if max_iob is not provided, will default to zero
+    , max_daily_safety_multiplier: 3
+    , current_basal_safety_multiplier: 4
+    , autosens_max: 1.2
+    , autosens_min: 0.7
     , rewind_resets_autosens: true // reset autosensitivity to neutral for awhile after each pump rewind
     // , autosens_adjust_targets: false // when autosens detects sensitivity/resistance, also adjust BG target accordingly
     , high_temptarget_raises_sensitivity: false // raise sensitivity for temptargets >= 101.  synonym for exercise_mode
@@ -32,10 +32,10 @@ function defaults ( ) {
     , remainingCarbsFraction: 1.0 // fraction of carbs we'll assume will absorb over 4h if we don't yet see carb absorption
     , remainingCarbsCap: 90 // max carbs we'll assume will absorb over 4h if we don't yet see carb absorption
     // WARNING: use SMB with caution: it can and will automatically bolus up to max_iob worth of extra insulin
-    , enableUAM: true // enable detection of unannounced meal carb absorption
+    , enableUAM: false // enable detection of unannounced meal carb absorption
     , A52_risk_enable: false
-    , enableSMB_with_COB: true // enable supermicrobolus while COB is positive
-    , enableSMB_with_temptarget: true // enable supermicrobolus for eating soon temp targets
+    , enableSMB_with_COB: false // enable supermicrobolus while COB is positive
+    , enableSMB_with_temptarget: false // enable supermicrobolus for eating soon temp targets
     // *** WARNING *** DO NOT USE enableSMB_always or enableSMB_after_carbs with Libre or similar
     // LimiTTer, etc. do not properly filter out high-noise SGVs.  xDrip+ builds greater than or equal to
     // version number d8e-7097-2018-01-22 provide proper noise values, so that oref0 can ignore high noise
@@ -45,16 +45,18 @@ function defaults ( ) {
     // if the CGM sensor reads falsely high and doesn't come down as actual BG does
     , enableSMB_always: false // always enable supermicrobolus (unless disabled by high temptarget)
     , enableSMB_after_carbs: false // enable supermicrobolus for 6h after carbs, even with 0 COB
+    , enableSMB_high_bg: false // enable SMBs when a high BG is detected, based on the high BG target (adjusted or profile)
+    , enableSMB_high_bg_target: 110 // set the value enableSMB_high_bg will compare against to enable SMB. If BG > than this value, SMBs should enable.
     // *** WARNING *** DO NOT USE enableSMB_always or enableSMB_after_carbs with Libre or similar.
-    , allowSMB_with_high_temptarget: true // allow supermicrobolus (if otherwise enabled) even with high temp targets
-    , maxSMBBasalMinutes: 90 // maximum minutes of basal that can be delivered as a single SMB with uncovered COB
-    , maxUAMSMBBasalMinutes: 90 // maximum minutes of basal that can be delivered as a single SMB when IOB exceeds COB
+    , allowSMB_with_high_temptarget: false // allow supermicrobolus (if otherwise enabled) even with high temp targets
+    , maxSMBBasalMinutes: 30 // maximum minutes of basal that can be delivered as a single SMB with uncovered COB
+    , maxUAMSMBBasalMinutes: 30 // maximum minutes of basal that can be delivered as a single SMB when IOB exceeds COB
     , SMBInterval: 3 // minimum interval between SMBs, in minutes.
-    , bolus_increment: 0.05 // minimum bolus that can be delivered as an SMB
+    , bolus_increment: 0.1 // minimum bolus that can be delivered as an SMB
     , maxDelta_bg_threshold: 0.2 // maximum change in bg to use SMB, above that will disable SMB
     , curve: "rapid-acting" // change this to "ultra-rapid" for Fiasp, or "bilinear" for old curve
     , useCustomPeakTime: false // allows changing insulinPeakTime
-    , insulinPeakTime: 45 // number of minutes after a bolus activity peaks.  defaults to 55m for Fiasp if useCustomPeakTime: false
+    , insulinPeakTime: 75 // number of minutes after a bolus activity peaks.  defaults to 55m for Fiasp if useCustomPeakTime: false
     , carbsReqThreshold: 1 // grams of carbsReq to trigger a pushover
     , offline_hotspot: false // enabled an offline-only local wifi hotspot if no Internet available
     , noisyCGMTargetMultiplier: 1.3 // increase target by this amount when looping off raw/noisy CGM data
@@ -66,7 +68,6 @@ function defaults ( ) {
     //, maxRaw: 200 // highest raw/noisy CGM value considered safe to use for looping
     , calc_glucose_noise: false
     , target_bg: false // set to an integer value in mg/dL to override pump min_bg
-    // autoISF variables
     , smb_delivery_ratio: 0.5 //Default value: 0.5 Used if flexible delivery ratio is not used. This is another key OpenAPS safety cap, and specifies what share of the total insulin required can be delivered as SMB. This is to prevent people from getting into dangerous territory by setting SMB requests from the caregivers phone at the same time. Increase this experimental value slowly and with caution.
     , adjustmentFactor: 0.8
     , adjustmentFactorSigmoid: 0.5
@@ -75,8 +76,6 @@ function defaults ( ) {
     , sigmoid: false
     , weightPercentage: 0.65 
     , tddAdjBasal: false // Enable adjustment of basal based on the ratio of 24 h : 10 day average TDD
-    , enableSMB_high_bg: false // enable SMBs when a high BG is detected, based on the high BG target (adjusted or profile)
-    , enableSMB_high_bg_target: 110 // set the value enableSMB_high_bg will compare against to enable SMB. If BG > than this value, SMBs should enable.
     , threshold_setting: 60 // Use a configurable threshold setting
   }
 }