diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..aedbee244 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# Code owners for LoopFollow. +# Owners listed here are automatically requested for review on PRs and, +# when "Require review from Code Owners" is enabled in branch protection, +# their approval is required before a PR can be merged. + +* @marionbarker @bjorkert @codebymini diff --git a/.github/workflows/auto_version_dev.yml b/.github/workflows/auto_version_dev.yml index 2317d261d..a2adf3c3d 100644 --- a/.github/workflows/auto_version_dev.yml +++ b/.github/workflows/auto_version_dev.yml @@ -35,7 +35,7 @@ on: jobs: bump-version: - if: github.repository_owner == 'loopandlearn' + if: ${{ !github.event.repository.fork }} runs-on: ubuntu-latest steps: @@ -43,13 +43,32 @@ jobs: uses: actions/checkout@v5 with: token: ${{ secrets.LOOPFOLLOW_TOKEN_AUTOBUMP }} + fetch-depth: 0 + + - name: Skip if Config.xcconfig was changed in this push + id: check + run: | + BEFORE="${{ github.event.before }}" + if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "No previous SHA on this push; not skipping." + exit 0 + fi + if git diff "$BEFORE..HEAD" -- Config.xcconfig | grep -qE '^\+LOOP_FOLLOW_MARKETING_VERSION'; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "LOOP_FOLLOW_MARKETING_VERSION was set in this push (likely a release sync); skipping auto-bump." + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi - name: Set up Git + if: steps.check.outputs.skip != 'true' run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - name: Bump dev version number in Config.xcconfig + if: steps.check.outputs.skip != 'true' run: | FILE=Config.xcconfig @@ -85,6 +104,7 @@ jobs: echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV - name: Commit and push changes + if: steps.check.outputs.skip != 'true' run: | git add Config.xcconfig git commit -m "CI: Bump dev version to $NEW_VERSION [skip ci]" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 68aa860f6..8b1c217f3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,6 +3,10 @@ run-name: Lint (${{ github.head_ref || github.ref_name }}) on: pull_request: + # 'edited' so the lint re-runs when a PR's base branch is changed; otherwise + # retargeting (e.g. main -> dev) leaves the required SwiftFormat check with no + # run on the new base and the PR stays blocked indefinitely. + types: [opened, synchronize, reopened, edited] workflow_dispatch: concurrency: @@ -15,6 +19,9 @@ permissions: jobs: swiftformat: name: SwiftFormat + # Run on open/sync/reopen always; on 'edited' only when the base branch + # actually changed, so routine title/description edits don't re-lint. + if: ${{ github.event.action != 'edited' || github.event.changes.base != null }} runs-on: ubuntu-latest container: swift:6.0 steps: diff --git a/.github/workflows/tag_on_main.yml b/.github/workflows/tag_on_main.yml new file mode 100644 index 000000000..4fa3d1714 --- /dev/null +++ b/.github/workflows/tag_on_main.yml @@ -0,0 +1,68 @@ +# ----------------------------------------------------------------------------- +# Workflow: Tag release on push to main +# +# Description: +# Creates an annotated git tag whenever main advances to a release version +# (X.Y.0). The version is read from LOOP_FOLLOW_MARKETING_VERSION in +# Config.xcconfig and the tag name is `v`. +# +# Triggered by: any push to main (release PR merge). +# Skips if: the version on main is not X.Y.0 (e.g. a hotfix that didn't bump +# minor/major), or if the tag already exists. +# ----------------------------------------------------------------------------- + +name: Tag release on main + +on: + push: + branches: + - main + +jobs: + tag: + if: ${{ !github.event.repository.fork }} + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Extract version from Config.xcconfig + id: version + run: | + VERSION=$(grep -E "^LOOP_FOLLOW_MARKETING_VERSION[[:space:]]*=" Config.xcconfig | awk '{print $3}') + if [ -z "$VERSION" ]; then + echo "::error::Could not find LOOP_FOLLOW_MARKETING_VERSION in Config.xcconfig" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Found version: $VERSION" + + - name: Skip non-release versions (only X.Y.0 is tagged) + id: check + run: | + VERSION="${{ steps.version.outputs.version }}" + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.0$ ]]; then + echo "is_release=true" >> "$GITHUB_OUTPUT" + else + echo "is_release=false" >> "$GITHUB_OUTPUT" + echo "Version $VERSION is not a release version (X.Y.0); skipping tag." + fi + + - name: Create and push tag if missing + if: steps.check.outputs.is_release == 'true' + run: | + TAG="v${{ steps.version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists; skipping." + else + git tag -a "$TAG" -m "$TAG" + git push origin "$TAG" + echo "Created and pushed tag $TAG" + fi diff --git a/.github/workflows/warn_main_pr.yml b/.github/workflows/warn_main_pr.yml index c24b78f11..7d79ebb53 100644 --- a/.github/workflows/warn_main_pr.yml +++ b/.github/workflows/warn_main_pr.yml @@ -8,7 +8,7 @@ on: jobs: warn: - if: github.repository_owner == 'loopandlearn' + if: ${{ !github.event.repository.fork }} runs-on: ubuntu-latest permissions: diff --git a/Config.xcconfig b/Config.xcconfig index 3ff1066f3..9b34855d4 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 6.1.0 +LOOP_FOLLOW_MARKETING_VERSION = 6.2.0 diff --git a/Gemfile b/Gemfile index 1a3b246f6..0eb90cb08 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,2 @@ source "https://rubygems.org" -gem "fastlane", "2.233.1" -gem "json", ">=2.19.2" -gem "addressable", ">=2.9.0" +gem "fastlane", "2.235.0" diff --git a/Gemfile.lock b/Gemfile.lock index 25ea69c8c..d255873eb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,8 +8,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1206.0) - aws-sdk-core (3.241.4) + aws-partitions (1.1254.0) + aws-sdk-core (3.250.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,19 +17,19 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.121.0) - aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-kms (1.128.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.211.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-s3 (1.224.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) + base64 (0.3.0) benchmark (0.5.0) - bigdecimal (4.0.1) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -71,17 +71,17 @@ GEM faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.4.0) - fastlane (2.233.1) - CFPropertyList (>= 2.3, < 4.0.0) - abbrev (~> 0.1.2) + fastimage (2.4.1) + fastlane (2.235.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - base64 (~> 0.2.0) + base64 (~> 0.2) benchmark (>= 0.1.0) - bundler (>= 1.17.3, < 5.0.0) + bundler (>= 2.4.0, < 5.0.0) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) @@ -96,18 +96,18 @@ GEM gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, <= 2.1.1) + google-cloud-env (>= 1.6.0, < 2.3.0) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) json (< 3.0.0) - jwt (>= 2.1.0, < 3) + jwt (>= 2.1.0, < 4) logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) - mutex_m (~> 0.3.0) + mutex_m (~> 0.3) naturally (~> 2.2) - nkf (~> 0.2.0) + nkf (~> 0.2) optparse (>= 0.1.1, < 1.0.0) ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) @@ -124,39 +124,44 @@ GEM xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.54.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.3) + google-apis-androidpublisher_v3 (0.101.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - google-apis-iamcredentials_v1 (0.17.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-playcustomapp_v1 (0.13.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.31.0) - google-apis-core (>= 0.11.0, < 2.a) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.62.0) + google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.5.0) - google-cloud-storage (1.47.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.60.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.31.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) + google-logging-utils (0.2.0) + googleauth (1.16.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) @@ -166,13 +171,13 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.19.3) - jwt (2.10.2) + json (2.19.7) + jwt (3.2.0) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.19.1) + multi_json (1.21.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) @@ -182,13 +187,13 @@ GEM os (1.1.4) ostruct (0.6.3) plist (3.7.2) - public_suffix (7.0.2) - rake (13.3.1) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) + retriable (3.8.0) rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) @@ -230,9 +235,7 @@ PLATFORMS ruby DEPENDENCIES - addressable (>= 2.9.0) - fastlane (= 2.233.1) - json (>= 2.19.2) + fastlane (= 2.235.0) BUNDLED WITH - 4.0.6 + 4.0.12 diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index c76e57915..b162633e0 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */; }; + B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A3 /* QuickPickBolusesManager.swift */; }; + B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */; }; + B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B3 /* QuickPickMealsManager.swift */; }; 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; @@ -57,6 +61,7 @@ 6589CC6D2E9E7D1600BB18FE /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC582E9E7D1600BB18FE /* CalendarSettingsView.swift */; }; 6589CC6E2E9E7D1600BB18FE /* SettingsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */; }; 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */; }; + 6589CC712E9E7D1600BB18FE /* ShareLogNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E7D1600BB18FE /* ShareLogNoticeView.swift */; }; 6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; }; 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; }; 65A100012F5AA00000AA1001 /* UnitsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A100002F5AA00000AA1001 /* UnitsSettingsView.swift */; }; @@ -88,18 +93,24 @@ DD0C0C682C48529400DBADDF /* Metric.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C672C48529400DBADDF /* Metric.swift */; }; DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C6A2C48562000DBADDF /* InsulinMetric.swift */; }; DD0C0C6D2C48606200DBADDF /* CarbMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C6C2C48606200DBADDF /* CarbMetric.swift */; }; - DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C6F2C4AFFE800DBADDF /* RemoteViewController.swift */; }; - DD0C0C722C4B000800DBADDF /* TrioNightscoutRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */; }; - DD12D4852E1705D9004E0112 /* AlarmViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD12D4842E1705D9004E0112 /* AlarmViewController.swift */; }; + DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */; }; + AA1B2C3D4E5F6A7B8C9D0E2F /* LoopFollowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */; }; + BB2C3D4E5F6A7B8C9D0E2F2A /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */; }; DD12D4872E1705E6004E0112 /* AlarmsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD12D4862E1705E6004E0112 /* AlarmsContainerView.swift */; }; DD13BC752C3FD6210062313B /* InfoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC742C3FD6200062313B /* InfoType.swift */; }; DD13BC772C3FD64E0062313B /* InfoData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC762C3FD64E0062313B /* InfoData.swift */; }; DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC782C3FE63A0062313B /* InfoManager.swift */; }; + DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */; }; DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; }; DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; }; DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF102C997B4600FB655A /* LoadingButtonView.swift */; }; + B500000000000000000000C2 /* QuickPickSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000C1 /* QuickPickSectionHeader.swift */; }; DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52B82E1EB5DC00432050 /* TabPosition.swift */; }; - DD1D52BB2E1EB60B00432050 /* MoreMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */; }; + CC3D4E5F6A7B8C9D0E2F2A3B /* MoreMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */; }; + DD7A3B5D2F1E8D9A00B4C6E1 /* BGDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */; }; + DD7A3B5F2F1E8DA000B4C6E1 /* LineChartWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B5E2F1E8DA000B4C6E1 /* LineChartWrapper.swift */; }; + DD7A3B612F1E8DA600B4C6E1 /* MainHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B602F1E8DA600B4C6E1 /* MainHomeView.swift */; }; + EE5F6A7B8C9D0E2F2A3B4C5D /* NightscoutContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */; }; DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BF2E4C100000000001 /* AppearanceMode.swift */; }; DD1D52C22E4C100000000002 /* PredictionDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */; }; DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; }; @@ -109,12 +120,12 @@ DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; }; DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; }; DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */; }; + AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */; }; DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */; }; DD4878102C7B74BF0048F05C /* TrioRemoteControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */; }; DD4878132C7B750D0048F05C /* TempTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878122C7B750D0048F05C /* TempTargetView.swift */; }; DD4878152C7B75230048F05C /* MealView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878142C7B75230048F05C /* MealView.swift */; }; DD4878172C7B75350048F05C /* BolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878162C7B75350048F05C /* BolusView.swift */; }; - DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */; }; DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */; }; DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781F2C7DAF890048F05C /* PushMessage.swift */; }; DD493AD52ACF2109009A6922 /* ResumePump.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AD42ACF2109009A6922 /* ResumePump.swift */; }; @@ -218,7 +229,6 @@ DDC6CA472DD8D9010060EE25 /* PumpChangeAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC6CA462DD8D9010060EE25 /* PumpChangeAlarmEditor.swift */; }; DDC6CA492DD8E47A0060EE25 /* PumpVolumeCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC6CA482DD8E47A0060EE25 /* PumpVolumeCondition.swift */; }; DDC6CA4B2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC6CA4A2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift */; }; - DDC7E5152DBCFA7900EB1127 /* SnoozerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5132DBCE1B900EB1127 /* SnoozerViewController.swift */; }; DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5122DBCE1B900EB1127 /* SnoozerView.swift */; }; DDC7E5382DBD887400EB1127 /* isOnPhoneCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5372DBD887400EB1127 /* isOnPhoneCall.swift */; }; DDC7E5422DBD8A1600EB1127 /* AlarmGeneralSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5392DBD8A1600EB1127 /* AlarmGeneralSection.swift */; }; @@ -236,7 +246,6 @@ DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */; }; DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A592DDC988F006F1C10 /* CarbSample.swift */; }; DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; - DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979324C0D380002C9752 /* UIViewExtension.swift */; }; DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; }; DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; }; DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A872D85FD33004DF4DD /* AlarmData.swift */; }; @@ -253,7 +262,6 @@ A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; DDDC31CC2E13A7DF009EA0F3 /* AddAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */; }; DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */; }; - DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; }; DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE69ED12C7256260013EAEC /* RemoteType.swift */; }; DDE75D232DE5E505007C1FC1 /* Glyph.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE75D222DE5E505007C1FC1 /* Glyph.swift */; }; DDE75D272DE5E539007C1FC1 /* ActionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE75D262DE5E539007C1FC1 /* ActionRow.swift */; }; @@ -288,6 +296,8 @@ FC16A98124996C07003D6245 /* DateTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A98024996C07003D6245 /* DateTime.swift */; }; FC1BDD2B24A22650001B652C /* Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2A24A22650001B652C /* Stats.swift */; }; FC1BDD2D24A23204001B652C /* MainViewController+updateStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */; }; + A1B2C3D4E5F6A7B8C9D0E1F3 /* StatsDisplayModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* StatsDisplayModel.swift */; }; + A1B2C3D4E5F6A7B8C9D0E1F5 /* StatsDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F4 /* StatsDisplayView.swift */; }; FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2E24A232A3001B652C /* DataStructs.swift */; }; FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */; }; FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC688592489554800A0279D /* BackgroundTaskAudio.swift */; }; @@ -409,10 +419,8 @@ FC7CE59C248D33A9001F83B8 /* dragbar.png in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE59B248D33A9001F83B8 /* dragbar.png */; }; FC8589BF252B54F500C8FC73 /* Mobileprovision.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC8589BE252B54F500C8FC73 /* Mobileprovision.swift */; }; FC9788182485969B00A7906C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC9788172485969B00A7906C /* AppDelegate.swift */; }; - FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC9788192485969B00A7906C /* SceneDelegate.swift */; }; FC97881C2485969B00A7906C /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC97881B2485969B00A7906C /* MainViewController.swift */; }; FC97881E2485969B00A7906C /* NightScoutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC97881D2485969B00A7906C /* NightScoutViewController.swift */; }; - FC9788212485969B00A7906C /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FC97881F2485969B00A7906C /* Main.storyboard */; }; FC9788262485969C00A7906C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FC9788252485969C00A7906C /* Assets.xcassets */; }; FC9788292485969C00A7906C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FC9788272485969C00A7906C /* LaunchScreen.storyboard */; }; FCA2DDE62501095000254A8C /* Timers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCA2DDE52501095000254A8C /* Timers.swift */; }; @@ -430,7 +438,9 @@ FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCEF87AA24A1417900AE6FA0 /* Localizer.swift */; }; FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCFEEC9D2486E68E00402A7F /* WebKit.framework */; }; FCFEECA02488157B00402A7F /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEEC9F2488157B00402A7F /* Chart.swift */; }; - FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEECA1248857A600402A7F /* SettingsViewController.swift */; }; + DD50C10A2F60A00000000001 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = DD50C10A2F60A00000000003 /* SocketIO */; }; + DD50C10A2F60B00000000002 /* NightscoutSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */; }; + DD50C10A2F60B00000000004 /* NightscoutSocketDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -465,6 +475,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteBolusHistoryEntry.swift; sourceTree = ""; }; + B500000000000000000000A3 /* QuickPickBolusesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickBolusesManager.swift; sourceTree = ""; }; + B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMealHistoryEntry.swift; sourceTree = ""; }; + B500000000000000000000B3 /* QuickPickMealsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickMealsManager.swift; sourceTree = ""; }; 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; @@ -511,6 +525,7 @@ 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsView.swift; sourceTree = ""; }; 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuView.swift; sourceTree = ""; }; 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; + 6589CC702E9E7D1600BB18FE /* ShareLogNoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLogNoticeView.swift; sourceTree = ""; }; 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = ""; }; 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = ""; }; 65A100002F5AA00000AA1001 /* UnitsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsSettingsView.swift; sourceTree = ""; }; @@ -543,18 +558,24 @@ DD0C0C672C48529400DBADDF /* Metric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metric.swift; sourceTree = ""; }; DD0C0C6A2C48562000DBADDF /* InsulinMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinMetric.swift; sourceTree = ""; }; DD0C0C6C2C48606200DBADDF /* CarbMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbMetric.swift; sourceTree = ""; }; - DD0C0C6F2C4AFFE800DBADDF /* RemoteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteViewController.swift; sourceTree = ""; }; - DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioNightscoutRemoteView.swift; sourceTree = ""; }; - DD12D4842E1705D9004E0112 /* AlarmViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmViewController.swift; sourceTree = ""; }; + DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteContentView.swift; sourceTree = ""; }; + AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopFollowApp.swift; sourceTree = ""; }; + BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; DD12D4862E1705E6004E0112 /* AlarmsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmsContainerView.swift; sourceTree = ""; }; DD13BC742C3FD6200062313B /* InfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoType.swift; sourceTree = ""; }; DD13BC762C3FD64E0062313B /* InfoData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoData.swift; sourceTree = ""; }; DD13BC782C3FE63A0062313B /* InfoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoManager.swift; sourceTree = ""; }; + DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoTableView.swift; sourceTree = ""; }; DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = ""; }; DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = ""; }; DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.swift; sourceTree = ""; }; + B500000000000000000000C1 /* QuickPickSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickSectionHeader.swift; sourceTree = ""; }; DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = ""; }; - DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuViewController.swift; sourceTree = ""; }; + CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuView.swift; sourceTree = ""; }; + DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGDisplayView.swift; sourceTree = ""; }; + DD7A3B5E2F1E8DA000B4C6E1 /* LineChartWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartWrapper.swift; sourceTree = ""; }; + DD7A3B602F1E8DA600B4C6E1 /* MainHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeView.swift; sourceTree = ""; }; + EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutContentView.swift; sourceTree = ""; }; DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = ""; }; DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionDisplayType.swift; sourceTree = ""; }; DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = ""; }; @@ -564,12 +585,12 @@ DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = ""; }; DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViewModel.swift; sourceTree = ""; }; + AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDiagnostics.swift; sourceTree = ""; }; DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlViewModel.swift; sourceTree = ""; }; DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlView.swift; sourceTree = ""; }; DD4878122C7B750D0048F05C /* TempTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetView.swift; sourceTree = ""; }; DD4878142C7B75230048F05C /* MealView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealView.swift; sourceTree = ""; }; DD4878162C7B75350048F05C /* BolusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusView.swift; sourceTree = ""; }; - DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioNightscoutRemoteController.swift; sourceTree = ""; }; DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationManager.swift; sourceTree = ""; }; DD48781F2C7DAF890048F05C /* PushMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessage.swift; sourceTree = ""; }; DD493AD42ACF2109009A6922 /* ResumePump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumePump.swift; sourceTree = ""; }; @@ -583,6 +604,8 @@ DD493AE42ACF2383009A6922 /* Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Treatments.swift; sourceTree = ""; }; DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatus.swift; sourceTree = ""; }; DD493AE82ACF2445009A6922 /* BGData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGData.swift; sourceTree = ""; }; + DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketManager.swift; sourceTree = ""; }; + DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketDataHandler.swift; sourceTree = ""; }; DD4A407D2E6AFEE6007B318B /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeOfDay.swift; sourceTree = ""; }; DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmConfiguration.swift; sourceTree = ""; }; @@ -674,7 +697,6 @@ DDC6CA482DD8E47A0060EE25 /* PumpVolumeCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpVolumeCondition.swift; sourceTree = ""; }; DDC6CA4A2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpVolumeAlarmEditor.swift; sourceTree = ""; }; DDC7E5122DBCE1B900EB1127 /* SnoozerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozerView.swift; sourceTree = ""; }; - DDC7E5132DBCE1B900EB1127 /* SnoozerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozerViewController.swift; sourceTree = ""; }; DDC7E5372DBD887400EB1127 /* isOnPhoneCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = isOnPhoneCall.swift; sourceTree = ""; }; DDC7E5392DBD8A1600EB1127 /* AlarmGeneralSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmGeneralSection.swift; sourceTree = ""; }; DDC7E53A2DBD8A1600EB1127 /* SoundFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundFile.swift; sourceTree = ""; }; @@ -693,7 +715,6 @@ DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; }; DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - DDCF979324C0D380002C9752 /* UIViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtension.swift; sourceTree = ""; }; DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alarm.swift; sourceTree = ""; }; DDCF9A812D85FD14004DF4DD /* AlarmType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmType.swift; sourceTree = ""; }; DDCF9A872D85FD33004DF4DD /* AlarmData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmData.swift; sourceTree = ""; }; @@ -710,7 +731,6 @@ A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = ""; }; DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAlarmSheet.swift; sourceTree = ""; }; DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTile.swift; sourceTree = ""; }; - DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.swift; sourceTree = ""; }; DDE69ED12C7256260013EAEC /* RemoteType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteType.swift; sourceTree = ""; }; DDE75D222DE5E505007C1FC1 /* Glyph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Glyph.swift; sourceTree = ""; }; DDE75D262DE5E539007C1FC1 /* ActionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionRow.swift; sourceTree = ""; }; @@ -745,6 +765,8 @@ FC16A98024996C07003D6245 /* DateTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTime.swift; sourceTree = ""; }; FC1BDD2A24A22650001B652C /* Stats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stats.swift; sourceTree = ""; }; FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainViewController+updateStats.swift"; sourceTree = ""; }; + A1B2C3D4E5F6A7B8C9D0E1F2 /* StatsDisplayModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDisplayModel.swift; sourceTree = ""; }; + A1B2C3D4E5F6A7B8C9D0E1F4 /* StatsDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDisplayView.swift; sourceTree = ""; }; FC1BDD2E24A232A3001B652C /* DataStructs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStructs.swift; sourceTree = ""; }; FC3AE7B4249E8E0E00AAE1E0 /* LoopFollow.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LoopFollow.xcdatamodel; sourceTree = ""; }; FC5A5C3C2497B229009C550E /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; @@ -867,10 +889,8 @@ FC8DEEE62485D1ED0075863F /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FC9788142485969B00A7906C /* Loop Follow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Loop Follow.app"; sourceTree = BUILT_PRODUCTS_DIR; }; FC9788172485969B00A7906C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - FC9788192485969B00A7906C /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; FC97881B2485969B00A7906C /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; FC97881D2485969B00A7906C /* NightScoutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScoutViewController.swift; sourceTree = ""; }; - FC9788202485969B00A7906C /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; FC9788252485969C00A7906C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; FC9788282485969C00A7906C /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = ""; }; @@ -892,7 +912,6 @@ FCEF87AA24A1417900AE6FA0 /* Localizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localizer.swift; sourceTree = ""; }; FCFEEC9D2486E68E00402A7F /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; FCFEEC9F2488157B00402A7F /* Chart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chart.swift; sourceTree = ""; }; - FCFEECA1248857A600402A7F /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -923,6 +942,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DD50C10A2F60A00000000001 /* SocketIO in Frameworks */, FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, ); @@ -978,6 +998,7 @@ 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */, 657F98172F043D8100F732BD /* HomeContentView.swift */, 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */, + 6589CC702E9E7D1600BB18FE /* ShareLogNoticeView.swift */, 65A100002F5AA00000AA1001 /* UnitsSettingsView.swift */, 65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */, 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */, @@ -1048,16 +1069,34 @@ path = Metric; sourceTree = ""; }; + B500000000000000000000B5 /* QuickPickMeals */ = { + isa = PBXGroup; + children = ( + B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */, + B500000000000000000000B3 /* QuickPickMealsManager.swift */, + ); + path = QuickPickMeals; + sourceTree = ""; + }; + B500000000000000000000A5 /* QuickPickBoluses */ = { + isa = PBXGroup; + children = ( + B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */, + B500000000000000000000A3 /* QuickPickBolusesManager.swift */, + ); + path = QuickPickBoluses; + sourceTree = ""; + }; DD0C0C6E2C4AFFB800DBADDF /* Remote */ = { isa = PBXGroup; children = ( - DDDF6F4A2D479B6A00884336 /* Nightscout */, - DDDF6F482D479AEF00884336 /* NoRemoteView.swift */, + B500000000000000000000A5 /* QuickPickBoluses */, + B500000000000000000000B5 /* QuickPickMeals */, DDEF503E2D479B8A00884336 /* LoopAPNS */, DD4878112C7B74F90048F05C /* TRC */, DD4878062C7B2E9E0048F05C /* Settings */, DDF699972C5AA2E50058A8D9 /* TempTargetPreset */, - DD0C0C6F2C4AFFE800DBADDF /* RemoteViewController.swift */, + DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */, DDE69ED12C7256260013EAEC /* RemoteType.swift */, ); path = Remote; @@ -1070,6 +1109,7 @@ DD13BC762C3FD64E0062313B /* InfoData.swift */, DD13BC782C3FE63A0062313B /* InfoManager.swift */, DD0C0C652C46E54C00DBADDF /* InfoDataSeparator.swift */, + DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */, ); path = InfoTable; sourceTree = ""; @@ -1090,6 +1130,7 @@ 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */, DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */, DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */, + AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */, ); path = Settings; sourceTree = ""; @@ -1106,7 +1147,6 @@ DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */, DD4878162C7B75350048F05C /* BolusView.swift */, DDD10F022C518A6500D76A8E /* TreatmentResponse.swift */, - DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */, DDFD5C522CB167DA00D3FD68 /* TRCCommandType.swift */, DDBE3ABC2CB5A961006B37DC /* OverrideView.swift */, ); @@ -1169,6 +1209,8 @@ DD0C0C5F2C415B9D00DBADDF /* ProfileManager.swift */, DD0C0C612C4175FD00DBADDF /* NSProfile.swift */, DD5334222C60ED3600062F9D /* IAge.swift */, + DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */, + DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */, ); path = Nightscout; sourceTree = ""; @@ -1177,7 +1219,6 @@ isa = PBXGroup; children = ( DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */, - DDCF979324C0D380002C9752 /* UIViewExtension.swift */, DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */, DD0C0C632C45A59400DBADDF /* HKUnit+Extensions.swift */, DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */, @@ -1233,7 +1274,6 @@ children = ( DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */, DDC7E5122DBCE1B900EB1127 /* SnoozerView.swift */, - DDC7E5132DBCE1B900EB1127 /* SnoozerViewController.swift */, ); path = Snoozer; sourceTree = ""; @@ -1347,14 +1387,6 @@ path = AddAlarm; sourceTree = ""; }; - DDDF6F4A2D479B6A00884336 /* Nightscout */ = { - isa = PBXGroup; - children = ( - DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */, - ); - path = Nightscout; - sourceTree = ""; - }; DDEF503D2D32753A00999A5D /* Task */ = { isa = PBXGroup; children = ( @@ -1406,6 +1438,7 @@ DDF6999D2C5AAA640058A8D9 /* ErrorMessageView.swift */, DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */, DD16AF102C997B4600FB655A /* LoadingButtonView.swift */, + B500000000000000000000C1 /* QuickPickSectionHeader.swift */, DDE75D262DE5E539007C1FC1 /* ActionRow.swift */, 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */, DDE75D282DE5E56C007C1FC1 /* LinkRow.swift */, @@ -1441,9 +1474,9 @@ FC16A97624995FEE003D6245 /* Application */ = { isa = PBXGroup; children = ( - FC97881F2485969B00A7906C /* Main.storyboard */, FC9788172485969B00A7906C /* AppDelegate.swift */, - FC9788192485969B00A7906C /* SceneDelegate.swift */, + AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */, + BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */, FC9788272485969C00A7906C /* LaunchScreen.storyboard */, ); path = Application; @@ -1459,6 +1492,8 @@ FC16A97E249969E2003D6245 /* Graphs.swift */, FC1BDD2A24A22650001B652C /* Stats.swift */, FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */, + A1B2C3D4E5F6A7B8C9D0E1F2 /* StatsDisplayModel.swift */, + A1B2C3D4E5F6A7B8C9D0E1F4 /* StatsDisplayView.swift */, FCA2DDE52501095000254A8C /* Timers.swift */, DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */, 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */, @@ -1720,11 +1755,13 @@ FCC68871248A736700A0279D /* ViewControllers */ = { isa = PBXGroup; children = ( - DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */, - DD12D4842E1705D9004E0112 /* AlarmViewController.swift */, + DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */, + DD7A3B5E2F1E8DA000B4C6E1 /* LineChartWrapper.swift */, + DD7A3B602F1E8DA600B4C6E1 /* MainHomeView.swift */, + CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */, + EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */, FC97881B2485969B00A7906C /* MainViewController.swift */, FC97881D2485969B00A7906C /* NightScoutViewController.swift */, - FCFEECA1248857A600402A7F /* SettingsViewController.swift */, ); path = ViewControllers; sourceTree = ""; @@ -1801,6 +1838,7 @@ ); name = LoopFollow; packageProductDependencies = ( + DD50C10A2F60A00000000003 /* SocketIO */, ); productName = LoopFollow; productReference = FC9788142485969B00A7906C /* Loop Follow.app */; @@ -1838,6 +1876,7 @@ ); mainGroup = FC97880B2485969B00A7906C; packageReferences = ( + DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */, ); productRefGroup = FC9788152485969B00A7906C /* Products */; projectDirPath = ""; @@ -1963,7 +2002,6 @@ FC7CE529248ABE37001F83B8 /* Laser_Shoot.caf in Resources */, FC7CE54A248ABE37001F83B8 /* Siri_Alert_Urgent_High_Glucose.caf in Resources */, FC7CE579248ABE37001F83B8 /* Good_Morning.caf in Resources */, - FC9788212485969B00A7906C /* Main.storyboard in Resources */, FC7CE538248ABE37001F83B8 /* 20ms-of-silence.caf in Resources */, FC7CE56B248ABE37001F83B8 /* Cartoon_Ascend_Then_Descend.caf in Resources */, FC7CE54E248ABE37001F83B8 /* Hell_Yeah_Somewhat_Calmer.caf in Resources */, @@ -2147,11 +2185,9 @@ DD608A0A2C23593900F91132 /* SMB.swift in Sources */, FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */, DD7F4C252DD7B20700D449E9 /* AlarmType+timeUnit.swift in Sources */, - DDC7E5152DBCFA7900EB1127 /* SnoozerViewController.swift in Sources */, DD7F4C072DD5042F00D449E9 /* OverrideStartAlarmEditor.swift in Sources */, DDCC3A4B2DDBB5E4006F1C10 /* BatteryCondition.swift in Sources */, DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */, - DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */, 656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */, DD12D4872E1705E6004E0112 /* AlarmsContainerView.swift in Sources */, DD83164A2DE4C504004467AA /* SettingsStepperRow.swift in Sources */, @@ -2179,6 +2215,7 @@ DDC6CA4B2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift in Sources */, 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */, DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */, + B500000000000000000000C2 /* QuickPickSectionHeader.swift in Sources */, DDFF3D7F2D1414A200BF9D9E /* BLEDevice.swift in Sources */, DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */, FC16A97D24996747003D6245 /* SpeakBG.swift in Sources */, @@ -2205,12 +2242,17 @@ 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */, DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */, DDEF503A2D31615000999A5D /* LogManager.swift in Sources */, + B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */, + B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */, + B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */, + B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */, DD4878172C7B75350048F05C /* BolusView.swift in Sources */, DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */, 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */, DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */, + DD50C10A2F60B00000000002 /* NightscoutSocketManager.swift in Sources */, + DD50C10A2F60B00000000004 /* NightscoutSocketDataHandler.swift in Sources */, DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */, - FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */, DD7F4C232DD7A62200D449E9 /* AlarmType+SortDirection.swift in Sources */, 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */, DD0650F72DCFDA26004D3B41 /* InfoBanner.swift in Sources */, @@ -2221,11 +2263,12 @@ DD7F4BA12DD2193F00D449E9 /* AlarmSnoozeSection.swift in Sources */, DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */, DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */, - DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */, + DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */, + DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */, DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */, + AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */, FCFEECA02488157B00402A7F /* Chart.swift in Sources */, DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */, - DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */, DD0C0C6D2C48606200DBADDF /* CarbMetric.swift in Sources */, DDC7E5422DBD8A1600EB1127 /* AlarmGeneralSection.swift in Sources */, DD7F4BC72DD473A600D449E9 /* FastDropCondition.swift in Sources */, @@ -2241,7 +2284,11 @@ FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */, FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */, DDF6999E2C5AAA640058A8D9 /* ErrorMessageView.swift in Sources */, - DD1D52BB2E1EB60B00432050 /* MoreMenuViewController.swift in Sources */, + CC3D4E5F6A7B8C9D0E2F2A3B /* MoreMenuView.swift in Sources */, + DD7A3B5D2F1E8D9A00B4C6E1 /* BGDisplayView.swift in Sources */, + DD7A3B5F2F1E8DA000B4C6E1 /* LineChartWrapper.swift in Sources */, + DD7A3B612F1E8DA600B4C6E1 /* MainHomeView.swift in Sources */, + EE5F6A7B8C9D0E2F2A3B4C5D /* NightscoutContentView.swift in Sources */, 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */, DD4878152C7B75230048F05C /* MealView.swift in Sources */, FC16A97F249969E2003D6245 /* Graphs.swift in Sources */, @@ -2267,11 +2314,11 @@ 65A100032F5AA00000AA1002 /* UnitsConfigurationView.swift in Sources */, 657F98182F043D8100F732BD /* HomeContentView.swift in Sources */, 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */, + 6589CC712E9E7D1600BB18FE /* ShareLogNoticeView.swift in Sources */, DD493ADF2ACF22BB009A6922 /* SAge.swift in Sources */, DDC6CA3F2DD7C6340060EE25 /* TemporaryAlarmEditor.swift in Sources */, DDF699992C5AA3060058A8D9 /* TempTargetPresetManager.swift in Sources */, DDC6CA452DD8D8E60060EE25 /* PumpChangeCondition.swift in Sources */, - DD0C0C722C4B000800DBADDF /* TrioNightscoutRemoteView.swift in Sources */, DD493ADB2ACF21A3009A6922 /* Bolus.swift in Sources */, DDF9676E2AD08C6E00C5EB95 /* SiteChange.swift in Sources */, DDCC3A542DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift in Sources */, @@ -2314,7 +2361,8 @@ DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */, DDEF503C2D31BE2D00999A5D /* TaskScheduler.swift in Sources */, DD0650EB2DCE8385004D3B41 /* LowBGCondition.swift in Sources */, - DD12D4852E1705D9004E0112 /* AlarmViewController.swift in Sources */, + AA1B2C3D4E5F6A7B8C9D0E2F /* LoopFollowApp.swift in Sources */, + BB2C3D4E5F6A7B8C9D0E2F2A /* MainTabView.swift in Sources */, DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */, DD493AD92ACF2171009A6922 /* Carbs.swift in Sources */, DD493AE92ACF2445009A6922 /* BGData.swift in Sources */, @@ -2340,7 +2388,6 @@ FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */, DDC6CA432DD8CED20060EE25 /* SensorAgeCondition.swift in Sources */, DD7F4C212DD66BB200D449E9 /* FastRiseAlarmEditor.swift in Sources */, - FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */, DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */, 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */, 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, @@ -2351,11 +2398,12 @@ DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */, DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */, DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */, - DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */, FC1BDD2B24A22650001B652C /* Stats.swift in Sources */, DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */, DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */, FC1BDD2D24A23204001B652C /* MainViewController+updateStats.swift in Sources */, + A1B2C3D4E5F6A7B8C9D0E1F3 /* StatsDisplayModel.swift in Sources */, + A1B2C3D4E5F6A7B8C9D0E1F5 /* StatsDisplayView.swift in Sources */, DD4878102C7B74BF0048F05C /* TrioRemoteControlView.swift in Sources */, DD7F4C152DD51FEB00D449E9 /* TempTargetEndAlarmEditor.swift in Sources */, FCE537BC249A4D7D00F80BF8 /* carbBolusArrays.swift in Sources */, @@ -2415,14 +2463,6 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ - FC97881F2485969B00A7906C /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - FC9788202485969B00A7906C /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; FC9788272485969C00A7906C /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -2826,6 +2866,25 @@ versionGroupType = wrapper.xcdatamodel; }; /* End XCVersionGroup section */ + +/* Begin XCRemoteSwiftPackageReference section */ + DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/socketio/socket.io-client-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 16.1.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DD50C10A2F60A00000000003 /* SocketIO */ = { + isa = XCSwiftPackageProductDependency; + package = DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */; + productName = SocketIO; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = FC97880C2485969B00A7906C /* Project object */; } diff --git a/LoopFollow/Alarm/AlarmsContainerView.swift b/LoopFollow/Alarm/AlarmsContainerView.swift index 133bea1d5..41c30172e 100644 --- a/LoopFollow/Alarm/AlarmsContainerView.swift +++ b/LoopFollow/Alarm/AlarmsContainerView.swift @@ -4,28 +4,35 @@ import SwiftUI struct AlarmsContainerView: View { - var onBack: (() -> Void)? + private let embedsInNavigationStack: Bool + + init(embedsInNavigationStack: Bool = true) { + self.embedsInNavigationStack = embedsInNavigationStack + } var body: some View { - NavigationStack { - AlarmListView() - .toolbar { - if let onBack { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: onBack) { - Image(systemName: "chevron.left") - } - } - } - ToolbarItem(placement: .navigationBarTrailing) { - NavigationLink { - AlarmSettingsView() - } label: { - Image(systemName: "gearshape") - } - } + Group { + if embedsInNavigationStack { + NavigationStack { + content } + } else { + content + } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) } + + private var content: some View { + AlarmListView() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + NavigationLink { + AlarmSettingsView() + } label: { + Image(systemName: "gearshape") + } + } + } + } } diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 831395f02..6c9d8e884 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -1,14 +1,12 @@ // LoopFollow // AppDelegate.swift -import CoreData +import AVFoundation import EventKit import UIKit import UserNotifications -@main class AppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? let notificationCenter = UNUserNotificationCenter.current() func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { @@ -65,14 +63,49 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Detect Before-First-Unlock launch. If protected data is unavailable here, // StorageValues were cached from encrypted UserDefaults and need a reload - // on the first foreground after the user unlocks. + // once the device is unlocked. let bfu = !UIApplication.shared.isProtectedDataAvailable Storage.shared.needsBFUReload = bfu LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)") + // Recovery is driven from AppDelegate (not MainViewController) because under + // the SwiftUI App lifecycle the home tab's UIHostingController is materialized + // lazily — on a BG-only launch (BGAppRefreshTask, BLE wake) MainViewController + // may not exist when the device is unlocked, and would miss willEnterForeground. + // protectedDataDidBecomeAvailable fires the moment file protection lifts and + // is the authoritative signal; willEnterForeground is a fallback. + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + + // Race guard: protected data may have become available between the check + // above and the observer registration just now. + if Storage.shared.needsBFUReload, UIApplication.shared.isProtectedDataAvailable { + performBFUReloadIfNeeded() + } + return true } + // MARK: - BFU recovery + + @objc private func protectedDataDidBecomeAvailable() { + performBFUReloadIfNeeded() + } + + @objc private func handleWillEnterForeground() { + performBFUReloadIfNeeded() + } + + private func performBFUReloadIfNeeded() { + guard Storage.shared.needsBFUReload else { return } + Storage.shared.needsBFUReload = false + LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues") + Storage.shared.reloadAll() + LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(Storage.shared.url.value)'") + NotificationCenter.default.post(name: .bfuReloadCompleted, object: nil) + } + func applicationWillTerminate(_: UIApplication) { #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.endOnTerminate() @@ -127,85 +160,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate { completionHandler(.newData) } - // MARK: - URL handling - - // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to - // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate - // handles ://la-tap for Live Activity tap navigation. - - // MARK: UISceneSession Lifecycle - func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // set the "prevent screen lock" option when the app is started - // This method doesn't seem to be working anymore. Added to view controllers as solution offered on SO UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value - return true } + // MARK: - Scene configuration + + // Under the scene-based lifecycle (which the SwiftUI App lifecycle uses), + // UIKit delivers Home Screen quick actions and opened URLs to the window + // scene delegate — application(_:performActionFor:) is never called. + // Injecting a delegate class here is the supported way to receive those + // events; SwiftUI still creates and manages the window itself. func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } - - func application(_: UIApplication, didDiscardSceneSessions _: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. - } - - // MARK: - Core Data stack - - lazy var persistentContainer: NSPersistentCloudKitContainer = { - /* - The persistent container for the application. This implementation - creates and returns a container, having loaded the store for the - application to it. This property is optional since there are legitimate - error conditions that could cause the creation of the store to fail. - */ - let container = NSPersistentCloudKitContainer(name: "LoopFollow") - container.loadPersistentStores(completionHandler: { _, error in - if let error = error as NSError? { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - - /* - Typical reasons for an error here include: - * The parent directory does not exist, cannot be created, or disallows writing. - * The persistent store is not accessible, due to permissions or data protection when the device is locked. - * The device is out of space. - * The store could not be migrated to the current model version. - Check the error message to determine what the actual problem was. - */ - fatalError("Unresolved error \(error), \(error.userInfo)") - } - }) - return container - }() - - // MARK: - Core Data Saving support - - func saveContext() { - let context = persistentContainer.viewContext - if context.hasChanges { - do { - try context.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nserror = error as NSError - fatalError("Unresolved error \(nserror), \(nserror.userInfo)") - } + let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + if connectingSceneSession.role == .windowApplication { + configuration.delegateClass = AppSceneDelegate.self } + return configuration } func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if response.actionIdentifier == "OPEN_APP_ACTION" { - if let window { - window.rootViewController?.dismiss(animated: true, completion: nil) - window.rootViewController?.present(MainViewController(), animated: true, completion: nil) - } + // Dismiss any presented modal/sheet so the user actually sees Home + UIApplication.shared.topMost?.dismiss(animated: true) + Observable.shared.selectedTabIndex.value = 0 } if response.actionIdentifier == "snooze" { @@ -226,17 +205,79 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } +extension Notification.Name { + /// Posted by AppDelegate after a Before-First-Unlock recovery completes + /// (Storage.reloadAll has run with the now-decrypted UserDefaults). + static let bfuReloadCompleted = Notification.Name("LoopFollow.bfuReloadCompleted") +} + +/// Window scene delegate installed via configurationForConnecting. SwiftUI owns +/// the window; this class only handles the events UIKit routes to the scene +/// delegate instead of the application delegate. +final class AppSceneDelegate: NSObject, UIWindowSceneDelegate { + private let speechSynthesizer = AVSpeechSynthesizer() + + /// A quick action used to cold-launch the app arrives in the connection + /// options; windowScene(_:performActionFor:) is not called for that launch. + func scene(_: UIScene, willConnectTo _: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + if let shortcutItem = connectionOptions.shortcutItem { + handleShortcutItem(shortcutItem) + } + } + + /// Called when the user taps the "Speak BG" Home Screen quick action while + /// the app is already running. + func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { + completionHandler(handleShortcutItem(shortcutItem)) + } + + @discardableResult + private func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool { + guard let bundleIdentifier = Bundle.main.bundleIdentifier, + shortcutItem.type == bundleIdentifier + ".toggleSpeakBG" + else { + return false + } + Storage.shared.speakBG.value.toggle() + let message = Storage.shared.speakBG.value ? "BG Speak is now on" : "BG Speak is now off" + speechSynthesizer.speak(AVSpeechUtterance(string: message)) + return true + } + + /// With a custom scene delegate installed, UIKit delivers opened URLs here + /// rather than through SwiftUI's onOpenURL, so the Live Activity tap + /// handling from LoopFollowApp is mirrored. Posting twice is harmless — + /// the navigation it triggers is idempotent. + func scene(_: UIScene, openURLContexts URLContexts: Set) { + guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return } + #if !targetEnvironment(macCatalyst) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + #endif + } +} + extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - // Log the notification - let userInfo = notification.request.content.userInfo - let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted() - LogManager.shared.log(category: .general, message: "Will present notification: keys=\(userInfoKeys)") + let content = notification.request.content + let userInfoKeys = content.userInfo.keys.compactMap { $0 as? String }.sorted() + LogManager.shared.log( + category: .general, + message: "Will present notification: keys=\(userInfoKeys), interruption=\(content.interruptionLevel.rawValue), title=\(content.title.isEmpty ? "empty" : "set"), body=\(content.body.isEmpty ? "empty" : "set")" + ) + + // Suppress notifications iOS routes here that we never intended to surface: + // the Live Activity push-to-start uses interruption-level: passive with empty + // title/body and must not produce a banner or sound when LF is foregrounded. + if content.interruptionLevel == .passive || (content.title.isEmpty && content.body.isEmpty) { + completionHandler([]) + return + } - // Show the notification even when app is in foreground completionHandler([.banner, .sound, .badge]) } } diff --git a/LoopFollow/Application/Base.lproj/Main.storyboard b/LoopFollow/Application/Base.lproj/Main.storyboard deleted file mode 100644 index 6441edc6c..000000000 --- a/LoopFollow/Application/Base.lproj/Main.storyboard +++ /dev/null @@ -1,458 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LoopFollow/Application/LoopFollowApp.swift b/LoopFollow/Application/LoopFollowApp.swift new file mode 100644 index 000000000..ee5e2f0fc --- /dev/null +++ b/LoopFollow/Application/LoopFollowApp.swift @@ -0,0 +1,23 @@ +// LoopFollow +// LoopFollowApp.swift + +import SwiftUI + +@main +struct LoopFollowApp: App { + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + + var body: some Scene { + WindowGroup { + MainTabView() + .onOpenURL { url in + guard url.scheme == AppGroupID.urlScheme, url.host == "la-tap" else { return } + #if !targetEnvironment(macCatalyst) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + #endif + } + } + } +} diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift new file mode 100644 index 000000000..140204358 --- /dev/null +++ b/LoopFollow/Application/MainTabView.swift @@ -0,0 +1,86 @@ +// LoopFollow +// MainTabView.swift + +import SwiftUI + +struct MainTabView: View { + @ObservedObject private var selectedTab = Observable.shared.selectedTabIndex + @ObservedObject private var appearanceMode = Storage.shared.appearanceMode + @ObservedObject private var homePosition = Storage.shared.homePosition + @ObservedObject private var alarmsPosition = Storage.shared.alarmsPosition + @ObservedObject private var remotePosition = Storage.shared.remotePosition + @ObservedObject private var nightscoutPosition = Storage.shared.nightscoutPosition + @ObservedObject private var snoozerPosition = Storage.shared.snoozerPosition + @ObservedObject private var statisticsPosition = Storage.shared.statisticsPosition + @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition + + @State private var showTelemetryConsent = false + + private var orderedItems: [TabItem] { + Storage.shared.orderedTabBarItems() + } + + var body: some View { + TabView(selection: $selectedTab.value) { + ForEach(Array(orderedItems.prefix(4).enumerated()), id: \.element) { index, item in + tabContent(for: item) + .tabItem { + Label(item.displayName, systemImage: item.icon) + } + .tag(index) + } + + NavigationStack { + MoreMenuView() + } + .tabItem { + Label("Menu", systemImage: "line.3.horizontal") + } + .tag(4) + } + .preferredColorScheme(appearanceMode.value.colorScheme) + .onAppear { + // Start the data pipeline as soon as the UI appears, independent of + // tab layout. Without this, a user who moves Home into the Menu would + // have no MainViewController — and therefore no data fetching, alarms, + // or background audio — until they manually opened Home. Tying it to + // onAppear (not app launch) keeps it off the BG-only refresh path. + MainViewController.bootstrap() + + // One-time consent prompt. Previously presented by SceneDelegate, + // which was removed in the storyboard→SwiftUI migration; without + // this, fresh installs stay permanently undecided and telemetry + // never sends. The storage flag keeps it to a single appearance. + if !Storage.shared.telemetryConsentDecisionMade.value { + showTelemetryConsent = true + } + } + .sheet(isPresented: $showTelemetryConsent) { + // User must explicitly choose — no swipe-to-dismiss. + TelemetryConsentView() + .interactiveDismissDisabled(true) + } + } + + @ViewBuilder + private func tabContent(for item: TabItem) -> some View { + switch item { + case .home: + HomeContentView() + case .alarms: + AlarmsContainerView() + case .remote: + RemoteContentView() + case .nightscout: + NightscoutContentView() + case .snoozer: + SnoozerView() + case .treatments: + TreatmentsView() + case .stats: + NavigationStack { + AggregatedStatsContentView(mainViewController: MainViewController.shared) + } + } + } +} diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift deleted file mode 100644 index 526eb7a78..000000000 --- a/LoopFollow/Application/SceneDelegate.swift +++ /dev/null @@ -1,121 +0,0 @@ -// LoopFollow -// SceneDelegate.swift - -import AVFoundation -import SwiftUI -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - let synthesizer = AVSpeechSynthesizer() - - /// One-shot guard so the consent prompt is only attempted once per - /// process lifetime even if the scene activates repeatedly. - private var consentPromptShownThisProcess = false - - func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } - - // get the tabBar - guard let tabBarController = window?.rootViewController as? UITabBarController, - let viewControllers = tabBarController.viewControllers - else { - return - } - } - - func sceneDidDisconnect(_: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - runTelemetryFirstForegroundHook() - } - - /// Presents the one-time consent sheet on first foreground. Sending is - /// handled by AppDelegate at launch and by TaskScheduler thereafter — - /// firing maybeSend here would duplicate the launch-time send. - private func runTelemetryFirstForegroundHook() { - if !Storage.shared.telemetryConsentDecisionMade.value, - !consentPromptShownThisProcess - { - consentPromptShownThisProcess = true - presentTelemetryConsentSheet() - } - } - - private func presentTelemetryConsentSheet() { - guard let root = window?.rootViewController else { return } - // Find the topmost presented controller so we don't try to present - // over a sheet that's already up. - var top = root - while let presented = top.presentedViewController { - top = presented - } - - let host = UIHostingController(rootView: TelemetryConsentView()) - host.isModalInPresentation = true // user must explicitly choose - // Defer to the next runloop so view hierarchy is settled when the - // scene first becomes active on a fresh install. - DispatchQueue.main.async { - top.present(host, animated: true) - } - } - - func scene(_: UIScene, openURLContexts URLContexts: Set) { - guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return } - // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app - // foregrounds from background. Post on the next run loop so the view - // hierarchy (including any presented modals) is fully settled. - #if !targetEnvironment(macCatalyst) - DispatchQueue.main.async { - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } - #endif - } - - func sceneWillResignActive(_: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - - // Save changes in the application's managed object context when the application transitions to the background. - (UIApplication.shared.delegate as? AppDelegate)?.saveContext() - } - - /// Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance. - func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) { - if let bundleIdentifier = Bundle.main.bundleIdentifier { - let expectedType = bundleIdentifier + ".toggleSpeakBG" - if shortcutItem.type == expectedType { - Storage.shared.speakBG.value.toggle() - let message = Storage.shared.speakBG.value ? "BG Speak is now on" : "BG Speak is now off" - let utterance = AVSpeechUtterance(string: message) - synthesizer.speak(utterance) - } - } - } - - /// The following method is called when the user taps on the Home Screen Quick Action - func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler _: @escaping (Bool) -> Void) { - handleShortcutItem(shortcutItem) - } -} diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift index a50bc6ea9..b03a67e68 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift @@ -11,22 +11,20 @@ struct BackgroundRefreshSettingsView: View { @ObservedObject var bleManager = BLEManager.shared var body: some View { - NavigationView { - Form { - refreshTypeSection + Form { + refreshTypeSection - if viewModel.backgroundRefreshType.isBluetooth { - selectedDeviceSection - availableDevicesSection - } - } - .onAppear { - startTimer() - } - .onDisappear { - stopTimer() + if viewModel.backgroundRefreshType.isBluetooth { + selectedDeviceSection + availableDevicesSection } } + .onAppear { + startTimer() + } + .onDisappear { + stopTimer() + } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Background Refresh Settings", displayMode: .inline) } diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index b83383185..7f32fd181 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -339,7 +339,11 @@ extension MPVolumeView { slider?.value = volume } // Optional - Remove the HUD - if let app = UIApplication.shared.delegate as? AppDelegate, let window = app.window { + let activeWindow = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .windows.first(where: \.isKeyWindow) + if let window = activeWindow { volumeView.alpha = 0.000001 window.addSubview(volumeView) } diff --git a/LoopFollow/Controllers/Graphs.swift b/LoopFollow/Controllers/Graphs.swift index 9789e9c46..184b67b8f 100644 --- a/LoopFollow/Controllers/Graphs.swift +++ b/LoopFollow/Controllers/Graphs.swift @@ -7,6 +7,22 @@ import UIKit import Charts +/// Fill colors for the override and temp-target bars on the BG graph. +/// +/// Loop draws overrides green and temp targets purple, while Trio (and other +/// OpenAPS-based algorithms) use the inverse — overrides purple, temp targets +/// green. We follow the active backend's convention so the colors match the +/// looping app the user is running. +enum TreatmentGraphColors { + static var override: NSUIColor { + Storage.shared.device.value == "Loop" ? .systemGreen : .systemPurple + } + + static var tempTarget: NSUIColor { + Storage.shared.device.value == "Loop" ? .systemPurple : .systemGreen + } +} + enum GraphDataIndex: Int { case bg = 0 case prediction = 1 @@ -27,6 +43,7 @@ enum GraphDataIndex: Int { case smb = 16 case tempTarget = 17 case predictionCone = 18 + case yesterday = 19 } extension GraphDataIndex { @@ -51,6 +68,7 @@ extension GraphDataIndex { case .smb: return "SMB" case .tempTarget: return "Temp Target" case .predictionCone: return "Prediction Cone" + case .yesterday: return "Yesterday" } } } @@ -201,7 +219,7 @@ class TempTargetRenderer: LineChartRenderer { } context.saveGState() - context.setFillColor(NSUIColor.systemPurple.withAlphaComponent(0.5).cgColor) + context.setFillColor(TreatmentGraphColors.tempTarget.withAlphaComponent(0.5).cgColor) context.fill(rect) context.restoreGState() } @@ -381,7 +399,7 @@ extension MainViewController { lineOverride.lineWidth = 0 lineOverride.drawFilledEnabled = true lineOverride.fillFormatter = OverrideFillFormatter() - lineOverride.fillColor = NSUIColor.systemGreen + lineOverride.fillColor = TreatmentGraphColors.override lineOverride.fillAlpha = 0.6 lineOverride.drawCirclesEnabled = false lineOverride.axisDependency = YAxis.AxisDependency.right @@ -622,6 +640,16 @@ extension MainViewController { lineCone.axisDependency = YAxis.AxisDependency.right data.append(lineCone) + // Dataset 19: Yesterday's BG comparison overlay (thin dimmed gray line, no dots) + let lineYesterday = LineChartDataSet(entries: [ChartDataEntry](), label: "") + lineYesterday.lineWidth = 1.5 + lineYesterday.setColor(NSUIColor.systemGray, alpha: 0.4) + lineYesterday.drawCirclesEnabled = false + lineYesterday.drawValuesEnabled = false + lineYesterday.highlightEnabled = false + lineYesterday.axisDependency = YAxis.AxisDependency.right + data.append(lineYesterday) + data.setValueFont(UIFont.systemFont(ofSize: 12)) // Add marker popups for bolus and carbs @@ -813,6 +841,11 @@ extension MainViewController { BGChart.data?.notifyDataChanged() BGChart.notifyDataSetChanged() + // Reflect the yesterday overlay toggle immediately, and reload the BG window + // so the extra day of history is fetched (or dropped) when the toggle changed. + updateYesterdayBGGraph() + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date()) + // Re-render prediction display in case display type changed updateOpenAPSPredictionDisplay() } @@ -882,7 +915,16 @@ extension MainViewController { BGChartFull.data?.notifyDataChanged() BGChartFull.notifyDataSetChanged() - if firstGraphLoad { + updateYesterdayBGGraph() + + // The initial zoom is a one-shot, relative to the chart's current + // viewport. Skip it until the chart actually has a width — otherwise a + // refresh that lands while the view is loaded but off-screen (e.g. Home + // lives in the Menu, so MainViewController is force-loaded headless by + // bootstrap()) would consume firstGraphLoad against a zero-size viewport + // and leave the main graph blank. viewDidAppear re-runs updateBGGraph + // once a real frame exists, applying the zoom correctly. + if firstGraphLoad, BGChart.bounds.width > 0 { var scaleX = CGFloat(Storage.shared.chartScaleX.value) if scaleX > CGFloat(ScaleXMax) { scaleX = CGFloat(ScaleXMax) @@ -899,6 +941,32 @@ extension MainViewController { } } + // Populates (or clears) the dimmed "yesterday" comparison overlay on the main graph. + // Points in yesterdayBGData are already shifted +24h so they align with today's clock time. + func updateYesterdayBGGraph() { + let dataIndex = GraphDataIndex.yesterday.rawValue + guard let lineData = BGChart.lineData, + dataIndex < lineData.dataSets.count, + let dataSet = lineData.dataSets[dataIndex] as? LineChartDataSet + else { + return + } + + dataSet.removeAll(keepingCapacity: false) + + if Storage.shared.showYesterdayLine.value { + for entry in yesterdayBGData { + // Clamp the plotted y-value to the same bounds the main BG line uses. + let plottedSgv = Double(min(max(entry.sgv, globalVariables.minDisplayGlucose), globalVariables.maxDisplayGlucose)) + dataSet.append(ChartDataEntry(x: entry.date, y: plottedSgv)) + } + } + + BGChart.data?.dataSets[dataIndex].notifyDataSetChanged() + BGChart.data?.notifyDataChanged() + BGChart.notifyDataSetChanged() + } + func updatePredictionGraph(color: UIColor? = nil) { let dataIndex = 1 var mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet @@ -1453,7 +1521,7 @@ extension MainViewController { lineOverride.lineWidth = 0 lineOverride.drawFilledEnabled = true lineOverride.fillFormatter = OverrideFillFormatter() - lineOverride.fillColor = NSUIColor.systemGreen + lineOverride.fillColor = TreatmentGraphColors.override lineOverride.fillAlpha = 0.6 lineOverride.drawCirclesEnabled = false lineOverride.axisDependency = YAxis.AxisDependency.right @@ -1676,6 +1744,9 @@ extension MainViewController { var smallChart = BGChartFull.lineData!.dataSets[dataIndex] as! LineChartDataSet chart.clear() smallChart.clear() + // Refresh the fill color in case the backend (Loop vs Trio) changed. + chart.fillColor = TreatmentGraphColors.override + smallChart.fillColor = TreatmentGraphColors.override let thisData = overrideGraphData var colors = [NSUIColor]() @@ -1991,9 +2062,11 @@ extension MainViewController { var coneData = [ConeChartDataEntry]() if !allArrays.isEmpty { - let maxLength = min(allArrays.map { $0.count }.max()!, toLoad + 1) + // Cap at the shortest predBG array length so every cone point uses + // the same set of contributing arrays. Matches Trio's ForecastSetup. + let coneLength = min(allArrays.map { $0.count }.min()!, toLoad + 1) var t = predictionStart - for i in 0 ..< maxLength { + for i in 0 ..< coneLength { var valuesAtIndex = [Double]() for arr in allArrays where i < arr.count { valuesAtIndex.append(arr[i]) diff --git a/LoopFollow/Controllers/MainViewController+updateStats.swift b/LoopFollow/Controllers/MainViewController+updateStats.swift index 890307d4d..f3c4c0d10 100644 --- a/LoopFollow/Controllers/MainViewController+updateStats.swift +++ b/LoopFollow/Controllers/MainViewController+updateStats.swift @@ -1,9 +1,7 @@ // LoopFollow // MainViewController+updateStats.swift -import Charts import Foundation -import UIKit extension MainViewController { func updateStats() { @@ -22,75 +20,33 @@ extension MainViewController { let stats = StatsData(bgData: lastDayOfData) - statsLowPercent.text = String(format: "%.1f%%", stats.percentLow) - statsInRangePercent.text = String(format: "%.1f%%", stats.percentRange) - statsHighPercent.text = String(format: "%.1f%%", stats.percentHigh) - statsAvgBG.text = Localizer.toDisplayUnits(String(format: "%.0f", stats.avgBG)) - statsEstA1CTitle.text = UnitSettingsStore.shared.glycemicMetricMode == .gmi ? "GMI:" : "Est. A1C:" + statsDisplayModel.lowPercent = String(format: "%.1f%%", stats.percentLow) + statsDisplayModel.inRangePercent = String(format: "%.1f%%", stats.percentRange) + statsDisplayModel.highPercent = String(format: "%.1f%%", stats.percentHigh) + statsDisplayModel.avgBG = Localizer.toDisplayUnits(String(format: "%.0f", stats.avgBG)) + statsDisplayModel.estA1CTitle = UnitSettingsStore.shared.glycemicMetricMode == .gmi ? "GMI:" : "Est. A1C:" if UnitSettingsStore.shared.glycemicOutputUnit == .mmolMol { - statsEstA1C.text = String(format: "%.0f", stats.a1C) + statsDisplayModel.estA1C = String(format: "%.0f", stats.a1C) } else { - statsEstA1C.text = String(format: "%.1f", stats.a1C) + statsDisplayModel.estA1C = String(format: "%.1f", stats.a1C) } if UnitSettingsStore.shared.variabilityMetricMode == .stdDeviation { - statsStdDevTitle.text = "Std Dev:" + statsDisplayModel.stdDevTitle = "Std Dev:" if UnitSettingsStore.shared.glucoseUnit == .mgdL { - statsStdDev.text = String(format: "%.0f", stats.stdDev) + statsDisplayModel.stdDev = String(format: "%.0f", stats.stdDev) } else { - statsStdDev.text = String(format: "%.1f", stats.stdDev) + statsDisplayModel.stdDev = String(format: "%.1f", stats.stdDev) } } else { - statsStdDevTitle.text = "CV:" - statsStdDev.text = String(format: "%.1f%%", stats.coefficientOfVariation) + statsDisplayModel.stdDevTitle = "CV:" + statsDisplayModel.stdDev = String(format: "%.1f%%", stats.coefficientOfVariation) } - createStatsPie(pieData: stats.pie) + statsDisplayModel.pieLow = Double(stats.percentLow) + statsDisplayModel.pieRange = Double(stats.percentRange) + statsDisplayModel.pieHigh = Double(stats.percentHigh) } } - - fileprivate func createStatsPie(pieData: [DataStructs.pieData]) { - statsPieChart.legend.enabled = false - statsPieChart.drawEntryLabelsEnabled = false - statsPieChart.drawHoleEnabled = false - statsPieChart.rotationEnabled = false - - var chartEntry = [PieChartDataEntry]() - var colors = [NSUIColor]() - - for i in 0 ..< pieData.count { - var slice = Double(pieData[i].value) - if slice == 0 { slice = 0.1 } - let value = PieChartDataEntry(value: slice) - chartEntry.append(value) - - if pieData[i].name == "high" { - colors.append(NSUIColor.systemYellow) - } else if pieData[i].name == "low" { - colors.append(NSUIColor.systemRed) - } else { - colors.append(NSUIColor.systemGreen) - } - } - - let set = PieChartDataSet(entries: chartEntry, label: "") - - set.drawIconsEnabled = false - set.sliceSpace = 2 - set.drawValuesEnabled = false - set.valueLineWidth = 0 - set.formLineWidth = 0 - set.sliceSpace = 0 - - set.colors.removeAll() - if colors.count > 0 { - for i in 0 ..< colors.count { - set.addColor(colors[i]) - } - } - - let data = PieChartData(dataSet: set) - statsPieChart.data = data - } } diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index d97aba24c..2d6c73a77 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -5,11 +5,19 @@ import Foundation import UIKit extension MainViewController { + /// Number of days of BG history to request from the source. One extra day is + /// added when the "Show Yesterday's BG" overlay is enabled (Nightscout only), + /// so the overlay can display the same clock time from the day before. + var bgFetchDays: Int { + let extraDay = (Storage.shared.showYesterdayLine.value && IsNightscoutEnabled()) ? 1 : 0 + return Storage.shared.downloadDays.value + extraDay + } + // Dex Share Web Call func webLoadDexShare() { // Dexcom Share only returns 24 hrs of data as of now // Requesting more just for consistency with NS - let graphHours = 24 * Storage.shared.downloadDays.value + let graphHours = 24 * bgFetchDays let count = graphHours * 12 dexShare?.fetchData(count) { err, result in if let error = err { @@ -33,11 +41,17 @@ extension MainViewController { return } - // Dexcom only returns 24 hrs of data. If we need more, call NS. - if graphHours > 24, IsNightscoutEnabled() { - self.webLoadNSBGData(dexData: data) + // Dexcom Share can return duplicate readings when multiple uploaders + // write to the same Dexcom account. Dedup before any further use. + let dedupedData = self.deduplicateBGReadings(data) + + // Supplement with NS if Dex data doesn't cover the full requested window. + let dexCutoff = dateTimeUtils.getNowTimeIntervalUTC() - Double(graphHours) * 3600 + let dexCoversFull = dedupedData.last.map { $0.date <= dexCutoff } ?? false + if !dexCoversFull, IsNightscoutEnabled() { + self.webLoadNSBGData(dexData: dedupedData) } else { - self.ProcessDexBGData(data: data, sourceName: "Dexcom") + self.ProcessDexBGData(data: dedupedData, sourceName: "Dexcom") } } } @@ -51,8 +65,8 @@ extension MainViewController { } var parameters: [String: String] = [:] - let date = Calendar.current.date(byAdding: .day, value: -1 * Storage.shared.downloadDays.value, to: Date())! - parameters["count"] = "\(Storage.shared.downloadDays.value * 2 * 24 * 60 / 5)" + let date = Calendar.current.date(byAdding: .day, value: -1 * bgFetchDays, to: Date())! + parameters["count"] = "\(bgFetchDays * globalVariables.maxExpectedUploaders * 24 * 60 / 5)" parameters["find[date][$gte]"] = "\(Int(date.timeIntervalSince1970 * 1000))" // Exclude 'cal' entries @@ -70,18 +84,7 @@ extension MainViewController { nsData[i].date.round(FloatingPointRoundingRule.toNearestOrEven) } - var nsData2: [ShareGlucoseData] = [] - var lastAddedTime = Double.infinity - var lastAddedSGV: Int? - let minInterval: Double = 30 - - for reading in nsData { - if (lastAddedSGV == nil || lastAddedSGV != reading.sgv) || (lastAddedTime - reading.date >= minInterval) { - nsData2.append(reading) - lastAddedTime = reading.date - lastAddedSGV = reading.sgv - } - } + var nsData2 = self.deduplicateBGReadings(nsData) // merge NS and Dex data if needed; use recent Dex data and older NS data var sourceName = "Nightscout" @@ -117,6 +120,21 @@ extension MainViewController { } } + /// Removes consecutive duplicate readings (same SGV within 30 s). Expects newest-first input. + func deduplicateBGReadings(_ readings: [ShareGlucoseData]) -> [ShareGlucoseData] { + var result: [ShareGlucoseData] = [] + var lastTime = Double.infinity + var lastSGV: Int? + for reading in readings { + if lastSGV == nil || lastSGV != reading.sgv || lastTime - reading.date >= 30 { + result.append(reading) + lastTime = reading.date + lastSGV = reading.sgv + } + } + return result + } + /// Processes incoming BG data. func ProcessDexBGData(data: [ShareGlucoseData], sourceName: String) { let graphHours = 24 * Storage.shared.downloadDays.value @@ -176,6 +194,10 @@ extension MainViewController { TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3)) } + if NightscoutSocketManager.shared.connectionState == .authenticated { + delayToSchedule = max(delayToSchedule * 3, 60) + } + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date().addingTimeInterval(delayToSchedule)) // Evaluate speak conditions if there is a previous value. @@ -207,14 +229,35 @@ extension MainViewController { LogManager.shared.log(category: .nightscout, message: "Graph data updated with \(bgData.count) entries.", isDebug: true) + + // Build the optional "yesterday" comparison overlay. Every fetched reading is + // shifted +24h so it lines up with the same clock time today; the extra day of + // history pulled by bgFetchDays provides the portion that falls inside the + // visible window. The overlay is capped to "now + hours of prediction" so it + // never extends further into the future than the prediction line. + yesterdayBGData.removeAll() + if Storage.shared.showYesterdayLine.value, IsNightscoutEnabled() { + let cutoff = dateTimeUtils.getTimeIntervalNHoursAgo(N: 24 * bgFetchDays) + let futureLimit = dateTimeUtils.getNowTimeIntervalUTC() + Storage.shared.predictionToLoad.value * 3600 + for i in 0 ..< data.count { + let reading = data[data.count - 1 - i] + guard reading.date >= cutoff, reading.sgv <= 600 else { continue } + let shiftedDate = reading.date + 24 * 60 * 60 + guard shiftedDate <= futureLimit else { continue } + yesterdayBGData.append(ShareGlucoseData(sgv: reading.sgv, + date: shiftedDate, + direction: reading.direction)) + } + } + viewUpdateNSBG(sourceName: sourceName) } func updateServerText(with serverText: String? = nil) { if Storage.shared.showDisplayName.value, let displayName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String { - self.serverText.text = displayName + Observable.shared.serverText.value = displayName } else if let serverText = serverText { - self.serverText.text = serverText + Observable.shared.serverText.value = serverText } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index ff2b13a78..d2d6920d8 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -1,10 +1,9 @@ // LoopFollow // DeviceStatus.swift -import Charts import Foundation import HealthKit -import UIKit +import SwiftUI extension MainViewController { func webLoadNSDeviceStatus() { @@ -36,7 +35,6 @@ extension MainViewController { } func evaluateNotLooping() { - guard let statusStackView = LoopStatusLabel.superview as? UIStackView else { return } guard let lastLoopTime = Observable.shared.alertLastLoopTime.value, lastLoopTime > 0 else { return } @@ -47,15 +45,9 @@ extension MainViewController { if IsNightscoutEnabled(), (now - lastLoopTime) >= nonLoopingTimeThreshold, lastLoopTime > 0 { IsNotLooping = true Observable.shared.isNotLooping.value = true - statusStackView.distribution = .fill - PredictionLabel.isHidden = true - LoopStatusLabel.frame = CGRect(x: 0, y: 0, width: statusStackView.frame.width, height: statusStackView.frame.height) - - LoopStatusLabel.textAlignment = .center - LoopStatusLabel.text = "⚠️ Not Looping!" - LoopStatusLabel.textColor = UIColor.systemYellow - LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18) + Observable.shared.loopStatusText.value = "⚠️ Not Looping!" + Observable.shared.loopStatusColor.value = .yellow #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping") #endif @@ -63,20 +55,8 @@ extension MainViewController { } else { IsNotLooping = false Observable.shared.isNotLooping.value = false - statusStackView.distribution = .fillEqually - PredictionLabel.isHidden = false - - LoopStatusLabel.textAlignment = .right - LoopStatusLabel.font = UIFont.systemFont(ofSize: 17) - - switch Storage.shared.appearanceMode.value { - case .dark: - LoopStatusLabel.textColor = UIColor.white - case .light: - LoopStatusLabel.textColor = UIColor.black - case .system: - LoopStatusLabel.textColor = UIColor.label - } + + Observable.shared.loopStatusColor.value = .primary #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed") #endif @@ -213,37 +193,28 @@ extension MainViewController { let secondsAgo = now - (Observable.shared.alertLastLoopTime.value ?? 0) DispatchQueue.main.async { + var interval: Double if secondsAgo >= (20 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(5 * 60) - ) - + interval = 5 * 60 } else if secondsAgo >= (10 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(60) - ) - + interval = 60 } else if secondsAgo >= (7 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(30) - ) - + interval = 30 } else if secondsAgo >= (5 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(10) - ) + interval = 10 } else { - let interval = (310 - secondsAgo) - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(interval) - ) + interval = 310 - secondsAgo TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3)) } + + if NightscoutSocketManager.shared.connectionState == .authenticated { + interval = max(interval * 3, 60) + } + + TaskScheduler.shared.rescheduleTask( + id: .deviceStatus, + to: Date().addingTimeInterval(interval) + ) } evaluateNotLooping() diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 56ebb6af0..771fdc68d 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -1,10 +1,9 @@ // LoopFollow // DeviceStatusLoop.swift -import Charts import Foundation import HealthKit -import UIKit +import SwiftUI extension MainViewController { func DeviceStatusLoop(formatter: ISO8601DateFormatter, lastLoopRecord: [String: AnyObject]) { @@ -18,7 +17,7 @@ extension MainViewController { let lastLoopTime = Observable.shared.alertLastLoopTime.value ?? 0 if lastLoopRecord["failureReason"] != nil { - LoopStatusLabel.text = "X" + Observable.shared.loopStatusText.value = "X" latestLoopStatusString = "X" } else { var wasEnacted = false @@ -67,8 +66,8 @@ extension MainViewController { if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] { let prediction = predictdata["values"] as! [Double] - PredictionLabel.text = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) - PredictionLabel.textColor = UIColor.systemPurple + Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) + Observable.shared.predictionColor.value = .purple if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime { predictionData.removeAll() var predictionTime = lastLoopTime @@ -113,15 +112,15 @@ extension MainViewController { lastBGTime = bgData[bgData.count - 1].date } if tempBasalTime > lastBGTime, !wasEnacted { - LoopStatusLabel.text = "⏀" + Observable.shared.loopStatusText.value = "⏀" latestLoopStatusString = "⏀" } else { - LoopStatusLabel.text = "↻" + Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" } } } else { - LoopStatusLabel.text = "↻" + Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index bc4da5ebd..ca8338a47 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -9,11 +9,11 @@ extension MainViewController { func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) { Storage.shared.device.value = lastDeviceStatus?["device"] as? String ?? "" if lastLoopRecord["failureReason"] != nil { - LoopStatusLabel.text = "X" + Observable.shared.loopStatusText.value = "X" latestLoopStatusString = "X" } else { guard let enactedOrSuggested = lastLoopRecord["suggested"] as? [String: AnyObject] ?? lastLoopRecord["enacted"] as? [String: AnyObject] else { - LoopStatusLabel.text = "↻" + Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" return } @@ -117,7 +117,7 @@ extension MainViewController { // Eventual BG if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double { let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue) - PredictionLabel.text = Localizer.formatQuantity(eventualBGQuantity) + Observable.shared.predictionText.value = Localizer.formatQuantity(eventualBGQuantity) Storage.shared.projectedBgMgdl.value = eventualBGValue } else { Storage.shared.projectedBgMgdl.value = nil @@ -173,8 +173,7 @@ extension MainViewController { return nil }() - let predictioncolor = UIColor.systemGray - PredictionLabel.textColor = predictioncolor + Observable.shared.predictionColor.value = .gray topPredictionBG = Storage.shared.minBGScale.value if let predbgdata = predBGsData { @@ -202,7 +201,7 @@ extension MainViewController { Storage.shared.lastMinBgMgdl.value = minPredBG Storage.shared.lastMaxBgMgdl.value = maxPredBG } else { - infoManager.updateInfoData(type: .minMax, value: "N/A") + infoManager.clearInfoData(type: .minMax) } updateOpenAPSPredictionDisplay() @@ -218,15 +217,15 @@ extension MainViewController { lastBGTime = bgData[bgData.count - 1].date } if tempBasalTime > lastBGTime { - LoopStatusLabel.text = "⏀" + Observable.shared.loopStatusText.value = "⏀" latestLoopStatusString = "⏀" } else { - LoopStatusLabel.text = "↻" + Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" } } } else { - LoopStatusLabel.text = "↻" + Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" } diff --git a/LoopFollow/Controllers/Nightscout/NSProfile.swift b/LoopFollow/Controllers/Nightscout/NSProfile.swift index eadea9d4a..669de7297 100644 --- a/LoopFollow/Controllers/Nightscout/NSProfile.swift +++ b/LoopFollow/Controllers/Nightscout/NSProfile.swift @@ -48,6 +48,8 @@ struct NSProfile: Decodable { let deviceToken: String? let teamID: String? let expirationDate: String? + let startDate: String? + let createdAt: String? struct TrioOverrideEntry: Decodable { let name: String @@ -97,5 +99,7 @@ struct NSProfile: Decodable { case loopSettings case teamID case expirationDate + case startDate + case createdAt = "created_at" } } diff --git a/LoopFollow/Controllers/Nightscout/NightscoutSocketDataHandler.swift b/LoopFollow/Controllers/Nightscout/NightscoutSocketDataHandler.swift new file mode 100644 index 000000000..054491c68 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/NightscoutSocketDataHandler.swift @@ -0,0 +1,54 @@ +// LoopFollow +// NightscoutSocketDataHandler.swift + +import Foundation + +extension MainViewController { + func setupNightscoutSocket() { + NightscoutSocketManager.shared.onDataUpdate = { [weak self] data in + self?.handleSocketDataUpdate(data) + } + NightscoutSocketManager.shared.connectIfNeeded() + } + + func handleSocketDataUpdate(_ data: [String: Any]) { + let isDelta = data["delta"] as? Bool ?? false + + if !isDelta { + // Full data on initial connect — trigger all fetches + LogManager.shared.log(category: .websocket, message: "Full data received, triggering all fetches") + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date()) + return + } + + // Selective: only fetch data types present in the delta + var triggered: [String] = [] + + if data["sgvs"] != nil || data["mbgs"] != nil { + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date()) + triggered.append("BG") + } + + if data["devicestatus"] != nil { + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date()) + triggered.append("DeviceStatus") + } + + if data["treatments"] != nil { + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + triggered.append("Treatments") + } + + if data["profiles"] != nil { + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date()) + triggered.append("Profile") + } + + if !triggered.isEmpty { + LogManager.shared.log(category: .websocket, message: "Delta triggered: \(triggered.joined(separator: ", "))", isDebug: true) + } + } +} diff --git a/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift new file mode 100644 index 000000000..2508c94e2 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift @@ -0,0 +1,198 @@ +// LoopFollow +// NightscoutSocketManager.swift + +import Foundation +import SocketIO + +class NightscoutSocketManager { + static let shared = NightscoutSocketManager() + + enum ConnectionState: String { + case disconnected + case connecting + case connected + case authenticated + case error + } + + private(set) var connectionState: ConnectionState = .disconnected { + didSet { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .nightscoutSocketStateChanged, object: nil) + } + } + } + + private var manager: SocketManager? + private var socket: SocketIOClient? + private var currentURL: String = "" + private var currentToken: String = "" + + var onDataUpdate: (([String: Any]) -> Void)? + + private init() {} + + // MARK: - Public API + + func connectIfNeeded() { + guard Storage.shared.webSocketEnabled.value else { + disconnect() + return + } + + let url = Storage.shared.url.value + let token = Storage.shared.token.value + + guard !url.isEmpty else { + disconnect() + return + } + + // Already connected to the same URL + if connectionState == .authenticated || connectionState == .connecting || connectionState == .connected { + if url == currentURL, token == currentToken { + return + } + // URL or token changed, reconnect + disconnect() + } + + currentURL = url + currentToken = token + connect() + } + + func disconnect() { + let wasAuthenticated = connectionState == .authenticated + + socket?.removeAllHandlers() + socket?.disconnect() + manager?.disconnect() + manager = nil + socket = nil + connectionState = .disconnected + currentURL = "" + currentToken = "" + + // While WS was delivering, each Nightscout poll was rescheduled with a multiplier on + // the assumption WS would publish before the next REST tick. With WS gone, those + // long delays would leave the user on stale data for ~10–15 minutes. Fire each poll + // now; their actions will reschedule on the normal (un-multiplied) cadence going + // forward, since connectionState is no longer .authenticated. + // + // Only catch up if we were actually authenticated — skip the no-op disconnect paths + // (already-disconnected, never-connected) and the connectIfNeeded() reconnect dance, + // where polls were already on a sensible schedule. + if wasAuthenticated { + let now = Date() + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: now) + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: now) + TaskScheduler.shared.rescheduleTask(id: .treatments, to: now) + TaskScheduler.shared.rescheduleTask(id: .profile, to: now) + } + } + + // MARK: - Private + + private func connect() { + guard let url = URL(string: currentURL) else { + LogManager.shared.log(category: .websocket, message: "Invalid Nightscout URL for WebSocket") + connectionState = .error + return + } + + connectionState = .connecting + + var config: SocketIOClientConfiguration = [ + .log(false), + .compress, + .forceWebsockets(false), + .reconnects(true), + .reconnectWait(5), + .reconnectWaitMax(30), + ] + + if !currentToken.isEmpty { + config.insert(.connectParams(["token": currentToken])) + } + + manager = SocketManager(socketURL: url, config: config) + + guard let mgr = manager else { return } + socket = mgr.defaultSocket + + setupEventHandlers() + socket?.connect() + + LogManager.shared.log(category: .websocket, message: "Connecting to Nightscout WebSocket at \(currentURL)") + } + + private func setupEventHandlers() { + guard let socket = socket else { return } + + socket.on(clientEvent: .connect) { [weak self] _, _ in + guard let self = self else { return } + LogManager.shared.log(category: .websocket, message: "Socket connected, authorizing...") + self.connectionState = .connected + self.authorize() + } + + socket.on(clientEvent: .disconnect) { [weak self] data, _ in + guard let self = self else { return } + let reason = (data.first as? String) ?? "unknown" + LogManager.shared.log(category: .websocket, message: "Socket disconnected: \(reason)") + self.connectionState = .disconnected + // Immediately restore normal polling intervals + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } + + socket.on(clientEvent: .reconnect) { _, _ in + LogManager.shared.log(category: .websocket, message: "Socket reconnecting...") + } + + socket.on(clientEvent: .error) { [weak self] data, _ in + let errorMsg = (data.first as? String) ?? "unknown error" + LogManager.shared.log(category: .websocket, message: "Socket error: \(errorMsg)") + self?.connectionState = .error + } + + socket.on("connected") { [weak self] _, _ in + guard let self = self else { return } + LogManager.shared.log(category: .websocket, message: "Authorized and receiving data") + self.connectionState = .authenticated + } + + socket.on("dataUpdate") { [weak self] data, _ in + guard let self = self, + let payload = data.first as? [String: Any] + else { return } + + LogManager.shared.log(category: .websocket, message: "Received dataUpdate (delta: \(payload["delta"] as? Bool ?? false))", isDebug: true) + + DispatchQueue.main.async { + self.onDataUpdate?(payload) + } + } + } + + private func authorize() { + var authPayload: [String: Any] = [ + "client": "LoopFollow", + "history": 1, + ] + + // Nightscout's authorization.resolve() expects: + // - "token" field for JWT tokens (verified via verifyJWT) + // - "secret" field for access tokens (checked via doesAccessTokenExist) + // LoopFollow uses access tokens (e.g. "readable-xxxx"), so pass as "secret". + if !currentToken.isEmpty { + authPayload["secret"] = currentToken + } + + socket?.emit("authorize", authPayload) + } +} + +extension Notification.Name { + static let nightscoutSocketStateChanged = Notification.Name("nightscoutSocketStateChanged") +} diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index f76c74a4c..d88c453fa 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -6,9 +6,19 @@ import Foundation extension MainViewController { // NS Profile Web Call func webLoadNSProfile() { - NightscoutUtils.executeRequest(eventType: .profile, parameters: [:]) { (result: Result) in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let parameters: [String: String] = [ + "count": "1", + "find[startDate][$lte]": formatter.string(from: Date().addingTimeInterval(60)), + ] + NightscoutUtils.executeRequest(eventType: .profile, parameters: parameters) { (result: Result<[NSProfile], Error>) in switch result { - case let .success(profileData): + case let .success(profiles): + guard let profileData = profiles.first else { + LogManager.shared.log(category: .nightscout, message: "webLoadNSProfile, no profile records returned") + return + } self.updateProfile(profileData: profileData) case let .failure(error): LogManager.shared.log(category: .nightscout, message: "webLoadNSProfile, error fetching profile data: \(error.localizedDescription)") diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index 322d5c1df..3b61dad8e 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -80,7 +80,7 @@ extension MainViewController { } Observable.shared.override.value = activeOverrideNote - if Storage.shared.device.value == "Trio" { + if Storage.shared.device.value != "Loop" { if let note = activeOverrideNote { infoManager.updateInfoData(type: .override, value: note) } else { diff --git a/LoopFollow/Controllers/StatsDisplayModel.swift b/LoopFollow/Controllers/StatsDisplayModel.swift new file mode 100644 index 000000000..72e60560a --- /dev/null +++ b/LoopFollow/Controllers/StatsDisplayModel.swift @@ -0,0 +1,18 @@ +// LoopFollow +// StatsDisplayModel.swift + +import Foundation + +class StatsDisplayModel: ObservableObject { + @Published var lowPercent: String = "" + @Published var inRangePercent: String = "" + @Published var highPercent: String = "" + @Published var avgBG: String = "" + @Published var estA1C: String = "" + @Published var estA1CTitle: String = "Est A1C:" + @Published var stdDev: String = "" + @Published var stdDevTitle: String = "Std Dev:" + @Published var pieLow: Double = 0 + @Published var pieRange: Double = 0 + @Published var pieHigh: Double = 0 +} diff --git a/LoopFollow/Controllers/StatsDisplayView.swift b/LoopFollow/Controllers/StatsDisplayView.swift new file mode 100644 index 000000000..f19be5543 --- /dev/null +++ b/LoopFollow/Controllers/StatsDisplayView.swift @@ -0,0 +1,84 @@ +// LoopFollow +// StatsDisplayView.swift + +import Charts +import SwiftUI + +struct StatsDisplayView: View { + @ObservedObject var model: StatsDisplayModel + var onTap: (() -> Void)? + + var body: some View { + HStack { + StatsPieChartView( + pieLow: model.pieLow, + pieRange: model.pieRange, + pieHigh: model.pieHigh + ) + .frame(width: 100, height: 100) + + VStack(spacing: 10) { + HStack { + statColumn(title: "Low:", value: model.lowPercent) + statColumn(title: "In Range:", value: model.inRangePercent) + statColumn(title: "High:", value: model.highPercent) + } + HStack { + statColumn(title: "Avg BG:", value: model.avgBG) + statColumn(title: model.estA1CTitle, value: model.estA1C) + statColumn(title: model.stdDevTitle, value: model.stdDev) + } + } + .frame(maxWidth: .infinity) + } + .frame(height: 100) + .background(Color(.secondarySystemBackground)) + .contentShape(Rectangle()) + .onTapGesture { onTap?() } + } + + private func statColumn(title: String, value: String) -> some View { + VStack { + Text(title) + .font(.system(size: 15)) + Text(value) + .font(.system(size: 15)) + } + .frame(maxWidth: .infinity) + } +} + +struct StatsPieChartView: UIViewRepresentable { + var pieLow: Double + var pieRange: Double + var pieHigh: Double + + func makeUIView(context _: Context) -> PieChartView { + let chart = PieChartView() + chart.legend.enabled = false + chart.drawEntryLabelsEnabled = false + chart.drawHoleEnabled = false + chart.rotationEnabled = false + chart.isUserInteractionEnabled = false + chart.backgroundColor = .clear + return chart + } + + func updateUIView(_ chart: PieChartView, context _: Context) { + let entries = [ + PieChartDataEntry(value: max(pieLow, 0.1)), + PieChartDataEntry(value: max(pieRange, 0.1)), + PieChartDataEntry(value: max(pieHigh, 0.1)), + ] + + let dataSet = PieChartDataSet(entries: entries, label: "") + dataSet.drawIconsEnabled = false + dataSet.sliceSpace = 0 + dataSet.drawValuesEnabled = false + dataSet.valueLineWidth = 0 + dataSet.formLineWidth = 0 + dataSet.colors = [.systemRed, .systemGreen, .systemYellow] + + chart.data = PieChartData(dataSet: dataSet) + } +} diff --git a/LoopFollow/Extensions/UIViewExtension.swift b/LoopFollow/Extensions/UIViewExtension.swift deleted file mode 100644 index 90fb15cb6..000000000 --- a/LoopFollow/Extensions/UIViewExtension.swift +++ /dev/null @@ -1,24 +0,0 @@ -// LoopFollow -// UIViewExtension.swift - -import Foundation -import UIKit - -extension UIView { - enum ViewSide { - case Left, Right, Top, Bottom - } - - func addBorder(toSide side: ViewSide, withColor color: CGColor, andThickness thickness: CGFloat) { - let border = CALayer() - border.backgroundColor = color - - switch side { - case .Left: border.frame = CGRect(x: 0, y: 0, width: thickness, height: frame.height) - case .Right: border.frame = CGRect(x: frame.width - thickness, y: 0, width: thickness, height: frame.height) - case .Top: border.frame = CGRect(x: 0, y: 0, width: frame.width, height: thickness) - case .Bottom: border.frame = CGRect(x: 0, y: frame.height - thickness, width: frame.width, height: thickness) - } - layer.addSublayer(border) - } -} diff --git a/LoopFollow/Helpers/AppearanceMode.swift b/LoopFollow/Helpers/AppearanceMode.swift index 2e3c7bc64..09feb73ba 100644 --- a/LoopFollow/Helpers/AppearanceMode.swift +++ b/LoopFollow/Helpers/AppearanceMode.swift @@ -3,10 +3,6 @@ import SwiftUI -extension Notification.Name { - static let appearanceDidChange = Notification.Name("appearanceDidChange") -} - enum AppearanceMode: String, CaseIterable, Codable { case system case light diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index a1168174d..ee34a4d3c 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -61,36 +61,6 @@ class BackgroundRefreshManager { } private func getMainViewController() -> MainViewController? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController - else { - return nil - } - - if let mainVC = rootVC as? MainViewController { - return mainVC - } - - if let navVC = rootVC as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - - if let tabVC = rootVC as? UITabBarController { - for vc in tabVC.viewControllers ?? [] { - if let mainVC = vc as? MainViewController { - return mainVC - } - if let navVC = vc as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - } - } - - return nil + MainViewController.shared } } diff --git a/LoopFollow/Helpers/Globals.swift b/LoopFollow/Helpers/Globals.swift index 37fcfab83..4fe8005fe 100644 --- a/LoopFollow/Helpers/Globals.swift +++ b/LoopFollow/Helpers/Globals.swift @@ -18,4 +18,10 @@ enum globalVariables { // BG readings and prediction values on the graph. static let minDisplayGlucose: Int = 39 static let maxDisplayGlucose: Int = 400 + + // Number of apps that may upload BG to the same account (a looping system, + // the Dexcom app, Apple Watch, ...). Each one writes a duplicate reading per + // slot, so the Nightscout entry-count request is multiplied by this to avoid + // truncating history before the date filter bounds the window. + static let maxExpectedUploaders = 4 } diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 34f8bcb08..04c5ff14b 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -52,7 +52,7 @@ class NightscoutUtils { case .sgv: return "/api/v1/entries.json" case .profile: - return "/api/v1/profile/current.json" + return "/api/v1/profiles.json" case .deviceStatus: return "/api/v1/devicestatus.json" case .temporaryOverride, .temporaryOverrideCancel: diff --git a/LoopFollow/Helpers/Views/NavigationRow.swift b/LoopFollow/Helpers/Views/NavigationRow.swift index be38ebfe4..2198a83e2 100644 --- a/LoopFollow/Helpers/Views/NavigationRow.swift +++ b/LoopFollow/Helpers/Views/NavigationRow.swift @@ -3,23 +3,18 @@ import SwiftUI -struct NavigationRow: View { +struct NavigationRow: View { let title: String let icon: String var iconTint: Color = .white - let action: () -> Void + let value: Value var body: some View { - Button(action: action) { + NavigationLink(value: value) { HStack { Glyph(symbol: icon, tint: iconTint) Text(title) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(Color(uiColor: .tertiaryLabel)) } - .contentShape(Rectangle()) } - .buttonStyle(.plain) } } diff --git a/LoopFollow/Helpers/Views/QuickPickSectionHeader.swift b/LoopFollow/Helpers/Views/QuickPickSectionHeader.swift new file mode 100644 index 000000000..e966de81d --- /dev/null +++ b/LoopFollow/Helpers/Views/QuickPickSectionHeader.swift @@ -0,0 +1,69 @@ +// LoopFollow +// QuickPickSectionHeader.swift + +import SwiftUI + +struct QuickPickSectionHeader: View { + let title: String + let infoText: String + @State private var showInfo = false + + var body: some View { + HStack(spacing: 4) { + Text(title) + Button { + showInfo = true + } label: { + Image(systemName: "info.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + .sheet(isPresented: $showInfo) { + QuickPickInfoSheet(title: title, text: infoText) + } + } +} + +private struct QuickPickInfoSheet: View { + let title: String + let text: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + Text(text) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } +} + +extension QuickPickSectionHeader { + static let bolusInfoText = """ + These buttons show your most-used recent bolus amounts. + + They're based on what you've sent before at similar times on similar days — so if you usually give 4 units before breakfast on weekdays, that button will show up on weekday mornings. + + Tap a button to fill in the amount. Nothing is sent until you review and confirm. + """ + + static let mealInfoText = """ + These buttons show your most-used recent meals. + + They're based on what you've sent before at similar times on similar days — so if you usually send the same breakfast on weekday mornings, it'll appear as an option. + + Tap a button to fill in the details. Nothing is sent until you review and confirm. + """ +} diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 9e0f99340..898d09616 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -66,25 +66,6 @@ NSSupportsLiveActivities - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - UIBackgroundModes audio @@ -97,22 +78,6 @@ UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main - UIRequiredDeviceCapabilities - - armv7 - - UIStatusBarTintParameters - - UINavigationBar - - Style - UIBarStyleDefault - Translucent - - - UISupportedInterfaceOrientations UIInterfaceOrientationLandscapeLeft diff --git a/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift b/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift index 84d28c165..d3b589f5d 100644 --- a/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift +++ b/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift @@ -7,38 +7,36 @@ struct InfoDisplaySettingsView: View { @ObservedObject var viewModel: InfoDisplaySettingsViewModel var body: some View { - NavigationView { - Form { - Section(header: Text("General")) { - Toggle(isOn: Binding( - get: { Storage.shared.hideInfoTable.value }, - set: { Storage.shared.hideInfoTable.value = $0 } - )) { - Text("Hide Information Table") - } + Form { + Section(header: Text("General")) { + Toggle(isOn: Binding( + get: { Storage.shared.hideInfoTable.value }, + set: { Storage.shared.hideInfoTable.value = $0 } + )) { + Text("Hide Information Table") } + } - Section(header: Text("Information Display Settings")) { - ForEach(viewModel.infoSort, id: \.self) { sortedIndex in - HStack { - Text(viewModel.getName(for: sortedIndex)) - Spacer() - Toggle("", isOn: Binding( - get: { viewModel.infoVisible[sortedIndex] }, - set: { _ in - viewModel.toggleVisibility(for: sortedIndex) - } - )) - .labelsHidden() - } + Section(header: Text("Information Display Settings")) { + ForEach(viewModel.infoSort, id: \.self) { sortedIndex in + HStack { + Text(viewModel.getName(for: sortedIndex)) + Spacer() + Toggle("", isOn: Binding( + get: { viewModel.infoVisible[sortedIndex] }, + set: { _ in + viewModel.toggleVisibility(for: sortedIndex) + } + )) + .labelsHidden() } - .onMove(perform: viewModel.move) } + .onMove(perform: viewModel.move) } - .environment(\.editMode, .constant(.active)) - .onDisappear { - NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) - } + } + .environment(\.editMode, .constant(.active)) + .onDisappear { + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Information Display Settings", displayMode: .inline) diff --git a/LoopFollow/InfoTable/InfoData.swift b/LoopFollow/InfoTable/InfoData.swift index f98e58b80..30f5bf9e9 100644 --- a/LoopFollow/InfoTable/InfoData.swift +++ b/LoopFollow/InfoTable/InfoData.swift @@ -3,11 +3,13 @@ import Foundation -class InfoData { - var name: String +class InfoData: Identifiable { + let id: Int + let name: String var value: String - init(name: String, value: String = "") { + init(id: Int, name: String, value: String = "") { + self.id = id self.name = name self.value = value } diff --git a/LoopFollow/InfoTable/InfoManager.swift b/LoopFollow/InfoTable/InfoManager.swift index f6af82629..f3205511e 100644 --- a/LoopFollow/InfoTable/InfoManager.swift +++ b/LoopFollow/InfoTable/InfoManager.swift @@ -1,22 +1,20 @@ // LoopFollow // InfoManager.swift +import Combine import Foundation import HealthKit -import UIKit -class InfoManager { - var tableData: [InfoData] - weak var tableView: UITableView? +class InfoManager: ObservableObject { + @Published var tableData: [InfoData] - init(tableView: UITableView) { - tableData = InfoType.allCases.map { InfoData(name: $0.name) } - self.tableView = tableView + init() { + tableData = InfoType.allCases.map { InfoData(id: $0.rawValue, name: $0.name) } } func updateInfoData(type: InfoType, value: String) { tableData[type.rawValue].value = value - tableView?.reloadData() + objectWillChange.send() } func updateInfoData(type: InfoType, value: HKQuantity) { @@ -55,33 +53,22 @@ class InfoManager { func clearInfoData(type: InfoType) { tableData[type.rawValue].value = "" - tableView?.reloadData() + objectWillChange.send() } func clearInfoData(types: [InfoType]) { for type in types { tableData[type.rawValue].value = "" } - tableView?.reloadData() + objectWillChange.send() } - func numberOfRows() -> Int { - return Storage.shared.infoSort.value.filter { Storage.shared.infoVisible.value[$0] }.count - } - - func dataForIndexPath(_ indexPath: IndexPath) -> InfoData? { - let sortedAndVisibleIndexes = Storage.shared.infoSort.value.filter { Storage.shared.infoVisible.value[$0] } - - guard indexPath.row < sortedAndVisibleIndexes.count else { - return nil - } - - let infoIndex = sortedAndVisibleIndexes[indexPath.row] - - guard infoIndex < tableData.count else { - return nil - } - - return tableData[infoIndex] + var visibleRows: [InfoData] { + Storage.shared.infoSort.value + .filter { $0 < Storage.shared.infoVisible.value.count && Storage.shared.infoVisible.value[$0] } + .compactMap { index in + guard index < tableData.count else { return nil } + return tableData[index] + } } } diff --git a/LoopFollow/InfoTable/InfoTableView.swift b/LoopFollow/InfoTable/InfoTableView.swift new file mode 100644 index 000000000..948cd94d2 --- /dev/null +++ b/LoopFollow/InfoTable/InfoTableView.swift @@ -0,0 +1,54 @@ +// LoopFollow +// InfoTableView.swift + +import SwiftUI + +struct InfoTableView: View { + @ObservedObject var infoManager: InfoManager + var timeZoneOverride: String? + + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = 17 + @ScaledMetric(relativeTo: .body) private var rowHeight: CGFloat = 21 + + var body: some View { + List { + if let tz = timeZoneOverride { + row(name: "Time Zone", value: tz) + } + ForEach(infoManager.visibleRows) { item in + row(name: item.name, value: item.value) + } + } + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, rowHeight) + } + + private func row(name: String, value: String) -> some View { + // Show a placeholder for any field that has no value yet, + // so the row reads as "no data" rather than appearing empty. + let displayValue = value.isEmpty ? "—" : value + + return ViewThatFits(in: .horizontal) { + // Preferred: compact single line (label — value) + HStack { + Text(name) + Spacer() + Text(displayValue) + .foregroundStyle(.primary) + } + + // Fallback when the single line won't fit: label over value + VStack(alignment: .leading, spacing: 0) { + Text(name) + Text(displayValue) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .font(.system(size: fontSize)) + .lineLimit(1) + .minimumScaleFactor(0.5) + .frame(minHeight: rowHeight) + .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) + } +} diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 3a58d12e1..c3b354a82 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -174,6 +174,17 @@ final class LiveActivityManager { Storage.shared.laRenewalFailed.value = false cancelRenewalFailedNotification() dismissedByUser = false + // A fresh LA invalidates any latched foreground-restart intent — the + // condition that prompted the latch (overlay showing / renewal failed) + // is resolved by adoption itself, so a deferred restart on the next + // didBecomeActive would needlessly tear down the just-adopted LA. + if pendingForegroundRestart { + LogManager.shared.log( + category: .general, + message: "[LA] adoption clears stale pendingForegroundRestart (LA already replaced via push-to-start)" + ) + pendingForegroundRestart = false + } bind(to: activity, logReason: "push-to-start-adopt") } @@ -291,10 +302,30 @@ final class LiveActivityManager { } private func performForegroundRestart() { + // Re-check the conditions that latched the intent. The latch can outlive its + // trigger — e.g. if the user briefly foregrounds the app while the renewal + // overlay is up, then backgrounds before didBecomeActive runs, the background + // renewal can replace the LA before the next foreground entry. By the time + // didBecomeActive eventually fires, the freshly-renewed LA is healthy and a + // restart would be gratuitous. + let renewalFailed = Storage.shared.laRenewalFailed.value + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + let pushToStartLooksStuck = pushToStartSendsWithoutAdoption >= LiveActivityManager.pushToStartForceRestartThreshold + guard renewalFailed || overlayIsShowing || pushToStartLooksStuck else { + LogManager.shared.log( + category: .general, + message: "[LA] deferred foreground restart skipped — conditions no longer hold (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), pushToStartLooksStuck=\(pushToStartLooksStuck))" + ) + return + } + // Mark restart intent BEFORE clearing storage flags, so any late .dismissed // from the old activity is never misclassified as a user swipe. endingForRestart = true dismissedByUser = false + nextStartReasonOverride = "deferred-foreground-restart" // Stop any observers/tasks tied to the previous activity instance. In the // current=nil branch below, the old observer can otherwise deliver a late @@ -458,6 +489,12 @@ final class LiveActivityManager { /// new `pushToStartToken` when the current one has gone silent /// (Apple FB21158660). private var pushToStartSendsWithoutAdoption: Int = 0 + /// Single-shot override for the next push-to-start reason tag. Consumed by + /// `startIfNeeded`. Lets the deferred-foreground-restart path tag its + /// push-to-start with a distinct label instead of "user-start", which made + /// the 8:25 stale-latch event indistinguishable from a real user start in + /// the log. + private var nextStartReasonOverride: String? // MARK: - Public API @@ -475,6 +512,9 @@ final class LiveActivityManager { return } + let startReason = nextStartReasonOverride ?? "user-start" + nextStartReasonOverride = nil + if #available(iOS 17.2, *) { // iOS 17.2+ uses push-to-start for every creation path. If an // activity is already running and not stale we adopt/reuse it @@ -495,10 +535,10 @@ final class LiveActivityManager { category: .general, message: "[LA] existing activity is stale on startIfNeeded (iOS 17.2+) — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))" ) - attemptPushToStartCreate(reason: "user-start", oldActivity: existing) + attemptPushToStartCreate(reason: startReason, oldActivity: existing) return } - attemptPushToStartCreate(reason: "user-start", oldActivity: nil) + attemptPushToStartCreate(reason: startReason, oldActivity: nil) } else { startIfNeededLegacy() } @@ -1093,7 +1133,13 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)") return } - if current == nil, let existing = Activity.activities.first { + if current == nil, + let existing = Activity.activities.first(where: { $0.activityState == .active }) + { + // Skip activities already in .ended/.dismissed — those are corpses + // (typically post-410 ends pending iOS dismissal). Binding to them + // would clear endingForRestart and turn the eventual iOS dismissal + // into a misclassified user swipe. bind(to: existing, logReason: "bind-existing") } if let _ = current { @@ -1118,7 +1164,9 @@ final class LiveActivityManager { } func update(snapshot: GlucoseSnapshot, reason: String) { - if current == nil, let existing = Activity.activities.first { + if current == nil, + let existing = Activity.activities.first(where: { $0.activityState == .active }) + { bind(to: existing, logReason: "bind-existing") } @@ -1249,10 +1297,20 @@ final class LiveActivityManager { ) // Mark as system-initiated so the `.dismissed` delivered by end() // is not classified as a user swipe — that would set dismissedByUser=true - // and block the auto-restart promised by the comment below. + // and block the restart kicked off below. endingForRestart = true end() - // Activity will restart on next BG refresh via refreshFromCurrentState() + + // Waiting for the next BG refresh is unreliable: end() nulls `current` + // and clears laRenewBy, so renewIfNeeded short-circuits and performRefresh's + // bind-existing path rebinds to the just-ended activity — clearing + // endingForRestart and turning the eventual iOS dismissal into a misclassified + // user swipe. Drive the restart synchronously instead. + if #available(iOS 17.2, *) { + Task { @MainActor [weak self] in + self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil) + } + } } // MARK: - Renewal Notifications @@ -1318,7 +1376,13 @@ final class LiveActivityManager { for await state in activity.activityStateUpdates { LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true) if state == .ended || state == .dismissed { - if current?.id == activity.id { + // Capture whether this delivery is for the activity we currently track + // BEFORE clearing `current` below. The classifier needs this signal to + // distinguish a real user swipe of the foreground LA from a late + // .dismissed delivered by a stale observer for an activity we already + // ended programmatically. + let wasCurrentActivity = current?.id == activity.id + if wasCurrentActivity { current = nil // Do NOT clear laRenewBy here. Preserving it means handleForeground() // can detect the renewal window on the next foreground event and restart @@ -1330,6 +1394,20 @@ final class LiveActivityManager { // • the user disables LA or calls forceRestart LogManager.shared.log(category: .general, message: "[LA] activity cleared id=\(activity.id) state=\(state)", isDebug: true) } + if state == .ended, wasCurrentActivity, !endingForRestart { + // iOS terminated the activity itself — typically the ~8h lifetime + // cap reached before renewal fired. The .dismissed path below + // already handles iOS-initiated dismissals via renewalFailed / + // pastDeadline, but .ended bypasses that branch entirely. Without + // a signal here, handleForeground() sees `renewalFailed=false` and + // `renewBy` still in the future, returns "no action needed", and + // startIfNeeded keeps re-binding the corpse — the LA stays dark + // until the user manually force-restarts. Mark renewalFailed so + // the next foreground entry runs performForegroundRestart, which + // sweeps any leftover ended activity and pushes a fresh one. + Storage.shared.laRenewalFailed.value = true + LogManager.shared.log(category: .general, message: "[LA] ended by iOS (not our restart) — marked renewalFailed=true, auto-restart on next foreground") + } if state == .dismissed { // Three possible sources of .dismissed — only the third blocks restart: // @@ -1348,17 +1426,27 @@ final class LiveActivityManager { // auto-restart until forceRestart() is called. Clear laRenewBy so // handleForeground() does NOT re-enter the renewal path on the next // foreground — the renewal intent is cancelled by the user's choice. + // + // Gated on `wasCurrentActivity`: the user can only swipe the + // foreground LA. A .dismissed for an activity we no longer track is a + // stale observer (the activity was ended programmatically and iOS is + // just now cleaning up) — must not latch dismissedByUser=true. let now = Date().timeIntervalSince1970 let renewBy = Storage.shared.laRenewBy.value let renewalFailed = Storage.shared.laRenewalFailed.value let pastDeadline = renewBy > 0 && now >= renewBy - LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), renewBy=\(renewBy), now=\(now)") + LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), wasCurrent=\(wasCurrentActivity), renewBy=\(renewBy), now=\(now)") if endingForRestart { // (a) Our own restart — do nothing, Task handles the rest. LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action") } else if renewalFailed || pastDeadline { // (b) iOS system force-dismiss — allow auto-restart on next foreground. LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground") + } else if !wasCurrentActivity { + // (d) Stale observer for an activity we no longer track (e.g. a + // post-410 end whose iOS-side dismissal landed hours later). + // Not a user swipe — no flags to set. + LogManager.shared.log(category: .general, message: "[LA] dismissed by stale observer (id=\(activity.id) is not current) — no action") } else { // (c) User decision — cancel renewal intent, block auto-restart. dismissedByUser = true diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 094fdde26..5bb40eeb6 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -30,6 +30,7 @@ class LogManager { case calendar = "Calendar" case deviceStatus = "Device Status" case remote = "Remote" + case websocket = "WebSocket" case telemetry = "Telemetry" } diff --git a/LoopFollow/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift index e1a9c00ef..a5245e23c 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsView.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -16,6 +16,7 @@ struct NightscoutSettingsView: View { urlSection tokenSection statusSection + webSocketSection if viewModel.isFreshSetup { continueSection @@ -96,6 +97,59 @@ struct NightscoutSettingsView: View { } } + @State private var showWebSocketInfo = false + + private var webSocketSection: some View { + Section(header: webSocketSectionHeader) { + Toggle("Enable WebSocket", isOn: $viewModel.webSocketEnabled) + if viewModel.webSocketEnabled { + HStack { + Text("Status") + Spacer() + Text(viewModel.webSocketStatus) + .foregroundColor(viewModel.webSocketStatusColor) + } + } + } + .sheet(isPresented: $showWebSocketInfo) { + NavigationStack { + ScrollView { + Text(""" + When enabled, LoopFollow maintains a live connection to your Nightscout server using WebSocket while the app is in the foreground. Data updates (new glucose readings, treatments, device status) arrive within seconds instead of waiting for the next polling cycle. + + The WebSocket disconnects when LoopFollow moves to the background and reconnects when you return to the app. Polling continues to handle updates while the app is in the background. + + In the foreground, polling continues at a reduced frequency as a safety net. If the WebSocket connection drops, normal polling resumes immediately. + """) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("Real-time Updates") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showWebSocketInfo = false } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + } + + private var webSocketSectionHeader: some View { + HStack(spacing: 4) { + Text("Real-time Updates") + Button { + showWebSocketInfo = true + } label: { + Image(systemName: "info.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + } + private var importSection: some View { Section(header: Text("Import Settings")) { if let onImportSettings { diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index 9b336be2b..559a12916 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -3,14 +3,9 @@ import Combine import Foundation - -protocol NightscoutSettingsViewModelDelegate: AnyObject { - func nightscoutSettingsDidFinish() -} +import SwiftUI class NightscoutSettingsViewModel: ObservableObject { - weak var delegate: NightscoutSettingsViewModelDelegate? - private var initialURL: String private var initialToken: String @@ -40,6 +35,29 @@ class NightscoutSettingsViewModel: ObservableObject { @Published var nightscoutStatus: String = "Checking..." + @Published var webSocketEnabled: Bool = Storage.shared.webSocketEnabled.value { + didSet { + Storage.shared.webSocketEnabled.value = webSocketEnabled + if webSocketEnabled { + NightscoutSocketManager.shared.connectIfNeeded() + } else { + NightscoutSocketManager.shared.disconnect() + triggerRefresh() + } + } + } + + @Published var webSocketStatus: String = "Disconnected" + + var webSocketStatusColor: Color { + switch NightscoutSocketManager.shared.connectionState { + case .authenticated: return .green + case .connecting, .connected: return .orange + case .disconnected: return .secondary + case .error: return .red + } + } + private var cancellables = Set() private var checkStatusSubject = PassthroughSubject() private var checkStatusWorkItem: DispatchWorkItem? @@ -51,6 +69,7 @@ class NightscoutSettingsViewModel: ObservableObject { setupDebounce() checkNightscoutStatus() + observeWebSocketState() } private func setupDebounce() { @@ -131,6 +150,7 @@ class NightscoutSettingsViewModel: ObservableObject { case .emptyAddress: nightscoutStatus = "Address Empty" } + NightscoutSocketManager.shared.disconnect() } else { isConnected = true let authStatus: String @@ -148,7 +168,27 @@ class NightscoutSettingsViewModel: ObservableObject { } } - func dismiss() { - delegate?.nightscoutSettingsDidFinish() + private func triggerRefresh() { + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } + + private func observeWebSocketState() { + updateWebSocketStatus() + NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateWebSocketStatus() + } + .store(in: &cancellables) + } + + private func updateWebSocketStatus() { + switch NightscoutSocketManager.shared.connectionState { + case .disconnected: webSocketStatus = "Disconnected" + case .connecting: webSocketStatus = "Connecting..." + case .connected: webSocketStatus = "Connected" + case .authenticated: webSocketStatus = "Connected" + case .error: webSocketStatus = "Error" + } } } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index c78a655f9..2cc834fc3 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -13,6 +13,7 @@ struct LoopAPNSBolusView: View { @State private var alertMessage = "" @State private var alertType: AlertType = .success + @ObservedObject private var quickPickBoluses = QuickPickBolusesManager.shared @FocusState private var insulinFieldIsFocused: Bool // Add state for recommended bolus and warning @@ -72,6 +73,30 @@ struct LoopAPNSBolusView: View { } } + if !quickPickBoluses.quickPickBoluses.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Boluses", infoText: QuickPickSectionHeader.bolusInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickBoluses.quickPickBoluses) { bolus in + Button { + insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: bolus.units) + } label: { + Text("\(InsulinFormatter.shared.string(bolus.units))U") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section { HKQuantityInputView( label: "Insulin Amount", @@ -186,6 +211,10 @@ struct LoopAPNSBolusView: View { showAlert = true } + let step = Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()) + let maxBolus = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()) + quickPickBoluses.refresh(stepIncrement: max(0.001, step), maxBolus: maxBolus) + loadRecommendedBolus() // Reset timer state so it shows '-' until first tick otpTimeRemaining = nil @@ -374,6 +403,10 @@ struct LoopAPNSBolusView: View { DispatchQueue.main.async { self.isLoading = false if success { + let sentUnits = insulinAmount.doubleValue(for: .internationalUnit()) + if sentUnits > 0 { + QuickPickBolusesManager.shared.recordBolus(units: sentUnits) + } // Mark TOTP code as used TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) self.alertMessage = "Insulin sent successfully!" diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 50b47dcd7..32db0f9c9 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -8,6 +8,7 @@ struct LoopAPNSCarbsView: View { private typealias AbsorptionPreset = (hours: Int, minutes: Int) @Environment(\.presentationMode) var presentationMode + @ObservedObject private var quickPickMeals = QuickPickMealsManager.shared @State private var carbsAmount = HKQuantity(unit: .gram(), doubleValue: 0.0) @State private var absorptionHours = 3 @State private var absorptionMinutes = 0 @@ -102,6 +103,30 @@ struct LoopAPNSCarbsView: View { NavigationView { VStack { Form { + if !quickPickMeals.quickPickMeals.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Meals", infoText: QuickPickSectionHeader.mealInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickMeals.quickPickMeals) { meal in + Button { + carbsAmount = HKQuantity(unit: .gram(), doubleValue: meal.carbs) + } label: { + Text("\(Int(meal.carbs))g") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section { HKQuantityInputView( label: "Carbs Amount", @@ -384,6 +409,12 @@ struct LoopAPNSCarbsView: View { alertType = .error showAlert = true } + + quickPickMeals.refresh( + maxCarbs: Storage.shared.maxCarbs.value.doubleValue(for: .gram()), + includeFatProtein: false + ) + // Reset timer state so it shows '-' until first tick otpTimeRemaining = nil // Don't reset TOTP usage flag here - let the timer handle it @@ -534,6 +565,10 @@ struct LoopAPNSCarbsView: View { DispatchQueue.main.async { self.isLoading = false if success { + let sentCarbs = carbsAmount.doubleValue(for: .gram()) + if sentCarbs > 0 { + QuickPickMealsManager.shared.recordMeal(carbs: sentCarbs) + } // Mark TOTP code as used TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) let timeFormatter = DateFormatter() diff --git a/LoopFollow/Remote/Nightscout/TrioNightscoutRemoteView.swift b/LoopFollow/Remote/Nightscout/TrioNightscoutRemoteView.swift deleted file mode 100644 index b0e1dfe01..000000000 --- a/LoopFollow/Remote/Nightscout/TrioNightscoutRemoteView.swift +++ /dev/null @@ -1,303 +0,0 @@ -// LoopFollow -// TrioNightscoutRemoteView.swift - -import HealthKit -import SwiftUI - -struct TrioNightscoutRemoteView: View { - private let remoteController = TrioNightscoutRemoteController() - - @ObservedObject var nightscoutURL = Storage.shared.url - @ObservedObject var device = Storage.shared.device - @ObservedObject var nsWriteAuth = Storage.shared.nsWriteAuth - @ObservedObject var tempTarget = Observable.shared.tempTarget - - @State private var newHKTarget = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 0.0) - @State private var duration = HKQuantity(unit: .minute(), doubleValue: 0.0) - @State private var showAlert: Bool = false - @State private var alertType: AlertType? = nil - @State private var alertMessage: String? = nil - @State private var isLoading: Bool = false - @State private var statusMessage: String? = nil - - @State private var showPresetSheet: Bool = false - @State private var presetName = "" - @ObservedObject var presetManager = TempTargetPresetManager.shared - - @FocusState private var targetFieldIsFocused: Bool - @FocusState private var durationFieldIsFocused: Bool - - enum AlertType { - case confirmCommand - case status - case validation - case confirmCancellation - } - - var body: some View { - NavigationView { - VStack { - if nightscoutURL.value.isEmpty { - ErrorMessageView( - message: "Remote commands are currently only available for Trio. It requires you to enter your Nightscout address and a token with the careportal role in the settings." - ) - } else if device.value != "Trio" { - ErrorMessageView( - message: "Remote commands are currently only available for Trio." - ) - } else if !nsWriteAuth.value { - ErrorMessageView( - message: "Please update your token to include the 'careportal' and 'readable' roles in order to do remote commands with Trio." - ) - } else { - Form { - if let tempTargetValue = tempTarget.value { - Section(header: Text("Existing Temp Target")) { - HStack { - Text("Current Target") - Spacer() - Text(Localizer.formatQuantity(tempTargetValue)) - Text(Localizer.getPreferredUnit().localizedShortUnitString).foregroundColor(.secondary) - } - Button { - alertType = .confirmCancellation - showAlert = true - } label: { - HStack { - Text("Cancel Temp Target") - Spacer() - Image(systemName: "xmark.app") - .font(.title) - } - } - .tint(.red) - } - } - Section(header: Text("Temporary Target")) { - HStack { - Text("Target") - Spacer() - TextFieldWithToolBar( - quantity: $newHKTarget, - maxLength: 4, - unit: Localizer.getPreferredUnit(), - minValue: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80), - maxValue: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 200), - onValidationError: { message in - handleValidationError(message) - } - ) - .focused($targetFieldIsFocused) - Text(Localizer.getPreferredUnit().localizedShortUnitString).foregroundColor(.secondary) - } - HStack { - Text("Duration") - Spacer() - TextFieldWithToolBar( - quantity: $duration, - maxLength: 4, - unit: HKUnit.minute(), - minValue: HKQuantity(unit: .minute(), doubleValue: 5), - onValidationError: { message in - handleValidationError(message) - } - ) - .focused($durationFieldIsFocused) - Text("minutes").foregroundColor(.secondary) - } - HStack { - Button { - alertType = .confirmCommand - showAlert = true - targetFieldIsFocused = false - durationFieldIsFocused = false - } label: { - Text("Enact") - } - .disabled(isButtonDisabled) - .buttonStyle(BorderlessButtonStyle()) - .font(.callout) - .controlSize(.mini) - - Spacer() - - Button { - showPresetSheet = true - targetFieldIsFocused = false - durationFieldIsFocused = false - } label: { - Text("Save as Preset") - } - .disabled(isButtonDisabled) - .buttonStyle(BorderlessButtonStyle()) - .font(.callout) - .controlSize(.mini) - } - } - - if !presetManager.presets.isEmpty { - Section(header: Text("Presets")) { - ForEach(presetManager.presets) { preset in - HStack { - Text(preset.name) - Spacer() - } - .contentShape(Rectangle()) - .onTapGesture { - alertType = .confirmCommand - newHKTarget = preset.target - duration = preset.duration - showAlert = true - targetFieldIsFocused = false - durationFieldIsFocused = false - } - .swipeActions { - Button(role: .destructive) { - if let index = presetManager.presets.firstIndex(where: { $0.id == preset.id }) { - presetManager.deletePreset(at: index) - } - targetFieldIsFocused = false - durationFieldIsFocused = false - } label: { - Label("Delete", systemImage: "trash") - } - } - } - } - } - } - - if isLoading { - ProgressView("Please wait...") - .padding() - } - } - } - .navigationTitle("Remote") - .navigationBarTitleDisplayMode(.inline) - .alert(isPresented: $showAlert) { - switch alertType { - case .confirmCommand: - return Alert( - title: Text("Confirm Command"), - message: Text("New Target: \(Localizer.formatQuantity(newHKTarget)) \(Localizer.getPreferredUnit().localizedShortUnitString)\nDuration: \(Int(duration.doubleValue(for: HKUnit.minute()))) minutes"), - primaryButton: .default(Text("Confirm"), action: { - enactTempTarget() - }), - secondaryButton: .cancel() - ) - case .status: - return Alert( - title: Text("Status"), - message: Text(statusMessage ?? ""), - dismissButton: .default(Text("OK"), action: { - showAlert = false - }) - ) - case .confirmCancellation: - return Alert( - title: Text("Confirm Cancellation"), - message: Text("Are you sure you want to cancel the existing temp target?"), - primaryButton: .default(Text("Confirm"), action: { - cancelTempTarget() - }), - secondaryButton: .cancel() - ) - case .validation: - return Alert( - title: Text("Validation Error"), - message: Text(alertMessage ?? "Invalid input."), - dismissButton: .default(Text("OK")) - ) - case .none: - return Alert(title: Text("Unknown Alert")) - } - } - .sheet(isPresented: $showPresetSheet) { - VStack { - Text("Save Preset") - .font(.headline) - .padding() - TextField("Preset Name", text: $presetName) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .padding() - HStack { - Button("Cancel") { - showPresetSheet = false - } - .padding() - Spacer() - Button("Save") { - presetManager.addPreset(name: presetName, target: newHKTarget, duration: duration) - presetName = "" - showPresetSheet = false - } - .disabled(presetName.isEmpty) - .padding() - } - Spacer() - } - .padding() - } - } - } - - private var isButtonDisabled: Bool { - return newHKTarget.doubleValue(for: Localizer.getPreferredUnit()) == 0 || - duration.doubleValue(for: HKUnit.minute()) == 0 || isLoading - } - - private func enactTempTarget() { - isLoading = true - remoteController.sendTempTarget(newTarget: newHKTarget, duration: duration) { success in - DispatchQueue.main.async { - self.isLoading = false - if success { - self.statusMessage = "Command successfully sent to Nightscout." - LogManager.shared.log( - category: .nightscout, - message: "sendTempTarget succeeded - New Target: \(Localizer.formatQuantity(newHKTarget)) \(Localizer.getPreferredUnit().localizedShortUnitString), Duration: \(Int(duration.doubleValue(for: HKUnit.minute()))) minutes" - ) - } else { - self.statusMessage = "Failed to enact target." - LogManager.shared.log( - category: .nightscout, - message: "sendTempTarget failed - New Target: \(Localizer.formatQuantity(newHKTarget)) \(Localizer.getPreferredUnit().localizedShortUnitString), Duration: \(Int(duration.doubleValue(for: HKUnit.minute()))) minutes" - ) - } - self.alertType = .status - self.showAlert = true - } - } - } - - private func cancelTempTarget() { - isLoading = true - remoteController.cancelExistingTarget { success in - DispatchQueue.main.async { - self.isLoading = false - if success { - self.statusMessage = "Cancellation request successfully sent to Nightscout." - LogManager.shared.log( - category: .nightscout, - message: "cancelExistingTarget succeeded" - ) - } else { - self.statusMessage = "Failed to cancel temp target." - LogManager.shared.log( - category: .nightscout, - message: "cancelExistingTarget failed" - ) - } - self.alertType = .status - self.showAlert = true - } - } - } - - private func handleValidationError(_ message: String) { - alertMessage = message - alertType = .validation - showAlert = true - } -} diff --git a/LoopFollow/Remote/NoRemoteView.swift b/LoopFollow/Remote/NoRemoteView.swift deleted file mode 100644 index 9daf12c51..000000000 --- a/LoopFollow/Remote/NoRemoteView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// LoopFollow -// NoRemoteView.swift - -import SwiftUI - -struct NoRemoteView: View { - private let remoteController = TrioNightscoutRemoteController() - - var body: some View { - NavigationView { - VStack { - ErrorMessageView( - message: "Remote commands are currently only available for Trio and Loop." - ) - } - } - } -} diff --git a/LoopFollow/Remote/QuickPickBoluses/QuickPickBolusesManager.swift b/LoopFollow/Remote/QuickPickBoluses/QuickPickBolusesManager.swift new file mode 100644 index 000000000..84c2c6ab3 --- /dev/null +++ b/LoopFollow/Remote/QuickPickBoluses/QuickPickBolusesManager.swift @@ -0,0 +1,133 @@ +// LoopFollow +// QuickPickBolusesManager.swift + +import Foundation + +struct QuickPickBolus: Identifiable, Equatable { + let id = UUID() + let units: Double + + static func == (lhs: QuickPickBolus, rhs: QuickPickBolus) -> Bool { + lhs.id == rhs.id + } +} + +final class QuickPickBolusesManager: ObservableObject { + static let shared = QuickPickBolusesManager() + + @Published private(set) var quickPickBoluses: [QuickPickBolus] = [] + + private static let maxEntries = 500 + private static let maxAgeDays = 90.0 + private static let sigma: Double = 60.0 + private static let halfLife: Double = 10.0 + private static let minScore: Double = 0.1 + private static let maxResults = 5 + + private init() {} + + // MARK: - Public API + + func recordBolus(units: Double, at date: Date = Date()) { + let entry = RemoteBolusHistoryEntry(units: units, date: date) + var history = Storage.shared.remoteBolusHistory.value + history.append(entry) + history = Self.pruned(history, now: date) + Storage.shared.remoteBolusHistory.value = history + } + + func refresh(now: Date = Date(), stepIncrement: Double, maxBolus: Double) { + let history = Storage.shared.remoteBolusHistory.value + quickPickBoluses = Self.computeQuickPickBoluses( + from: history, + now: now, + stepIncrement: stepIncrement, + maxBolus: maxBolus + ) + } + + // MARK: - Scoring (static for testability) + + static func computeQuickPickBoluses( + from history: [RemoteBolusHistoryEntry], + now: Date, + stepIncrement: Double, + maxBolus: Double + ) -> [QuickPickBolus] { + guard stepIncrement > 0 else { return [] } + + let nowMinute = { + let cal = Calendar.current + return cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now) + }() + let nowDOW = Calendar.current.component(.weekday, from: now) + + var groups: [Double: Double] = [:] + + for entry in history { + let rounded = (entry.units / stepIncrement).rounded(.down) * stepIncrement + let amount = roundToFraction(rounded, stepIncrement: stepIncrement) + guard amount > 0, amount <= maxBolus else { continue } + + let t = timeOfDayScore(entryMinute: entry.minuteOfDay, nowMinute: nowMinute) + let d = dayOfWeekScore(entryDOW: entry.dayOfWeek, nowDOW: nowDOW) + let daysAgo = now.timeIntervalSince(entry.date) / 86400.0 + let r = recencyScore(daysAgo: daysAgo) + + groups[amount, default: 0] += t * d * r + } + + return groups + .filter { $0.value >= minScore } + .sorted { $0.value > $1.value } + .prefix(maxResults) + .map { QuickPickBolus(units: $0.key) } + } + + static func timeOfDayScore(entryMinute: Int, nowMinute: Int) -> Double { + let diff = abs(entryMinute - nowMinute) + let circularDiff = Double(min(diff, 1440 - diff)) + return exp(-(circularDiff * circularDiff) / (2 * sigma * sigma)) + } + + static func dayOfWeekScore(entryDOW: Int, nowDOW: Int) -> Double { + if entryDOW == nowDOW { return 1.0 } + let nowWeekend = nowDOW == 1 || nowDOW == 7 + let entryWeekend = entryDOW == 1 || entryDOW == 7 + if nowWeekend == entryWeekend { return 0.7 } + return 0.15 + } + + static func recencyScore(daysAgo: Double) -> Double { + pow(0.5, daysAgo / halfLife) + } + + // MARK: - Helpers + + private static func pruned(_ history: [RemoteBolusHistoryEntry], now: Date) -> [RemoteBolusHistoryEntry] { + let cutoff = now.addingTimeInterval(-maxAgeDays * 86400) + var filtered = history.filter { $0.date > cutoff } + if filtered.count > maxEntries { + filtered.sort { $0.date > $1.date } + filtered = Array(filtered.prefix(maxEntries)) + } + return filtered + } + + private static func roundToFraction(_ value: Double, stepIncrement: Double) -> Double { + let digits = fractionDigits(for: stepIncrement) + let p = pow(10.0, Double(digits)) + return (value * p).rounded() / p + } + + private static func fractionDigits(for step: Double) -> Int { + if step >= 1 { return 0 } + var v = step + var digits = 0 + while digits < 6, abs(v.rounded() - v) > 1e-10 { + v *= 10 + digits += 1 + } + return min(max(digits, 0), 5) + } +} diff --git a/LoopFollow/Remote/QuickPickBoluses/RemoteBolusHistoryEntry.swift b/LoopFollow/Remote/QuickPickBoluses/RemoteBolusHistoryEntry.swift new file mode 100644 index 000000000..896b7459b --- /dev/null +++ b/LoopFollow/Remote/QuickPickBoluses/RemoteBolusHistoryEntry.swift @@ -0,0 +1,27 @@ +// LoopFollow +// RemoteBolusHistoryEntry.swift + +import Foundation + +/// A record of a remotely-sent bolus, stored locally for pattern-based suggestions. +struct RemoteBolusHistoryEntry: Codable, Equatable { + /// Bolus amount in international units + let units: Double + + /// When the bolus was sent + let date: Date + + /// Day of week: 1=Sunday ... 7=Saturday (Calendar.component(.weekday)) + let dayOfWeek: Int + + /// Minute of day: 0...1439 (hour * 60 + minute) + let minuteOfDay: Int + + init(units: Double, date: Date) { + self.units = units + self.date = date + let cal = Calendar.current + dayOfWeek = cal.component(.weekday, from: date) + minuteOfDay = cal.component(.hour, from: date) * 60 + cal.component(.minute, from: date) + } +} diff --git a/LoopFollow/Remote/QuickPickMeals/QuickPickMealsManager.swift b/LoopFollow/Remote/QuickPickMeals/QuickPickMealsManager.swift new file mode 100644 index 000000000..cd3f4de25 --- /dev/null +++ b/LoopFollow/Remote/QuickPickMeals/QuickPickMealsManager.swift @@ -0,0 +1,139 @@ +// LoopFollow +// QuickPickMealsManager.swift + +import Foundation + +struct QuickPickMeal: Identifiable, Equatable { + let id = UUID() + let carbs: Double + let fat: Double + let protein: Double + let bolus: Double + + static func == (lhs: QuickPickMeal, rhs: QuickPickMeal) -> Bool { + lhs.id == rhs.id + } +} + +final class QuickPickMealsManager: ObservableObject { + static let shared = QuickPickMealsManager() + + @Published private(set) var quickPickMeals: [QuickPickMeal] = [] + + private static let maxEntries = 500 + private static let maxAgeDays = 90.0 + private static let sigma: Double = 60.0 + private static let halfLife: Double = 10.0 + private static let minScore: Double = 0.1 + private static let maxResults = 5 + + private init() {} + + // MARK: - Public API + + func recordMeal(carbs: Double, fat: Double = 0, protein: Double = 0, bolus: Double = 0, at date: Date = Date()) { + let entry = RemoteMealHistoryEntry(carbs: carbs, fat: fat, protein: protein, bolus: bolus, date: date) + var history = Storage.shared.remoteMealHistory.value + history.append(entry) + history = Self.pruned(history, now: date) + Storage.shared.remoteMealHistory.value = history + } + + func refresh(now: Date = Date(), carbStep: Double = 1.0, maxCarbs: Double, includeFatProtein: Bool) { + let history = Storage.shared.remoteMealHistory.value + quickPickMeals = Self.computeQuickPickMeals( + from: history, + now: now, + carbStep: carbStep, + maxCarbs: maxCarbs, + includeFatProtein: includeFatProtein + ) + } + + // MARK: - Scoring (static for testability) + + static func computeQuickPickMeals( + from history: [RemoteMealHistoryEntry], + now: Date, + carbStep: Double, + maxCarbs: Double, + includeFatProtein: Bool + ) -> [QuickPickMeal] { + guard carbStep > 0 else { return [] } + + let nowMinute = { + let cal = Calendar.current + return cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now) + }() + let nowDOW = Calendar.current.component(.weekday, from: now) + + // Group by rounded carbs; track score + best entry (highest scored) for fat/protein + var groupScores: [Double: Double] = [:] + var groupBestEntry: [Double: (entry: RemoteMealHistoryEntry, score: Double)] = [:] + + for entry in history { + let rounded = (entry.carbs / carbStep).rounded() * carbStep + guard rounded > 0, rounded <= maxCarbs else { continue } + + let t = timeOfDayScore(entryMinute: entry.minuteOfDay, nowMinute: nowMinute) + let d = dayOfWeekScore(entryDOW: entry.dayOfWeek, nowDOW: nowDOW) + let daysAgo = now.timeIntervalSince(entry.date) / 86400.0 + let r = recencyScore(daysAgo: daysAgo) + + let score = t * d * r + groupScores[rounded, default: 0] += score + + if let current = groupBestEntry[rounded] { + if score > current.score { + groupBestEntry[rounded] = (entry, score) + } + } else { + groupBestEntry[rounded] = (entry, score) + } + } + + return groupScores + .filter { $0.value >= minScore } + .sorted { $0.value > $1.value } + .prefix(maxResults) + .map { item in + let best = groupBestEntry[item.key]?.entry + return QuickPickMeal( + carbs: item.key, + fat: includeFatProtein ? (best?.fat ?? 0) : 0, + protein: includeFatProtein ? (best?.protein ?? 0) : 0, + bolus: best?.bolus ?? 0 + ) + } + } + + static func timeOfDayScore(entryMinute: Int, nowMinute: Int) -> Double { + let diff = abs(entryMinute - nowMinute) + let circularDiff = Double(min(diff, 1440 - diff)) + return exp(-(circularDiff * circularDiff) / (2 * sigma * sigma)) + } + + static func dayOfWeekScore(entryDOW: Int, nowDOW: Int) -> Double { + if entryDOW == nowDOW { return 1.0 } + let nowWeekend = nowDOW == 1 || nowDOW == 7 + let entryWeekend = entryDOW == 1 || entryDOW == 7 + if nowWeekend == entryWeekend { return 0.7 } + return 0.15 + } + + static func recencyScore(daysAgo: Double) -> Double { + pow(0.5, daysAgo / halfLife) + } + + // MARK: - Helpers + + private static func pruned(_ history: [RemoteMealHistoryEntry], now: Date) -> [RemoteMealHistoryEntry] { + let cutoff = now.addingTimeInterval(-maxAgeDays * 86400) + var filtered = history.filter { $0.date > cutoff } + if filtered.count > maxEntries { + filtered.sort { $0.date > $1.date } + filtered = Array(filtered.prefix(maxEntries)) + } + return filtered + } +} diff --git a/LoopFollow/Remote/QuickPickMeals/RemoteMealHistoryEntry.swift b/LoopFollow/Remote/QuickPickMeals/RemoteMealHistoryEntry.swift new file mode 100644 index 000000000..65ffb1e31 --- /dev/null +++ b/LoopFollow/Remote/QuickPickMeals/RemoteMealHistoryEntry.swift @@ -0,0 +1,39 @@ +// LoopFollow +// RemoteMealHistoryEntry.swift + +import Foundation + +/// A record of a remotely-sent meal, stored locally for pattern-based common meals. +struct RemoteMealHistoryEntry: Codable, Equatable { + /// Carbs in grams + let carbs: Double + + /// Fat in grams (0 if not applicable) + let fat: Double + + /// Protein in grams (0 if not applicable) + let protein: Double + + /// Bolus in units (0 if no bolus with meal) + let bolus: Double + + /// When the meal was sent + let date: Date + + /// Day of week: 1=Sunday ... 7=Saturday (Calendar.component(.weekday)) + let dayOfWeek: Int + + /// Minute of day: 0...1439 (hour * 60 + minute) + let minuteOfDay: Int + + init(carbs: Double, fat: Double = 0, protein: Double = 0, bolus: Double = 0, date: Date = Date()) { + self.carbs = carbs + self.fat = fat + self.protein = protein + self.bolus = bolus + self.date = date + let cal = Calendar.current + dayOfWeek = cal.component(.weekday, from: date) + minuteOfDay = cal.component(.hour, from: date) * 60 + cal.component(.minute, from: date) + } +} diff --git a/LoopFollow/Remote/RemoteContentView.swift b/LoopFollow/Remote/RemoteContentView.swift new file mode 100644 index 000000000..6cc534e70 --- /dev/null +++ b/LoopFollow/Remote/RemoteContentView.swift @@ -0,0 +1,28 @@ +// LoopFollow +// RemoteContentView.swift + +import SwiftUI + +struct RemoteContentView: View { + @ObservedObject private var device = Storage.shared.device + @ObservedObject private var remoteType = Storage.shared.remoteType + + var body: some View { + Group { + switch remoteType.value { + case .trc: + if device.value == "Trio" { + TrioRemoteControlView(viewModel: TrioRemoteControlViewModel()) + } else { + Text("Trio Remote Control is only supported for 'Trio'") + } + + case .loopAPNS: + LoopAPNSRemoteView() + + case .none: + Text("Please select a Remote Type in Settings.") + } + } + } +} diff --git a/LoopFollow/Remote/RemoteType.swift b/LoopFollow/Remote/RemoteType.swift index 1e4b958dd..4ace7c41f 100644 --- a/LoopFollow/Remote/RemoteType.swift +++ b/LoopFollow/Remote/RemoteType.swift @@ -5,7 +5,6 @@ import Foundation enum RemoteType: String, Codable { case none = "None" - case nightscout = "Nightscout" case trc = "Trio Remote Control" case loopAPNS = "Loop APNS" } diff --git a/LoopFollow/Remote/RemoteViewController.swift b/LoopFollow/Remote/RemoteViewController.swift deleted file mode 100644 index 56e317da3..000000000 --- a/LoopFollow/Remote/RemoteViewController.swift +++ /dev/null @@ -1,126 +0,0 @@ -// LoopFollow -// RemoteViewController.swift - -import Combine -import SwiftUI -import UIKit - -class RemoteViewController: UIViewController { - private var cancellables = Set() - private var hostingController: UIHostingController? - - override func viewDidLoad() { - super.viewDidLoad() - - // Apply initial appearance - overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - Storage.shared.device.$value - .removeDuplicates() - .sink { [weak self] _ in - DispatchQueue.main.async { - self?.updateView() - } - } - .store(in: &cancellables) - - // Listen for appearance setting changes - Storage.shared.appearanceMode.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] mode in - self?.overrideUserInterfaceStyle = mode.userInterfaceStyle - self?.hostingController?.overrideUserInterfaceStyle = mode.userInterfaceStyle - } - .store(in: &cancellables) - - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - let style = Storage.shared.appearanceMode.value.userInterfaceStyle - self?.overrideUserInterfaceStyle = style - self?.hostingController?.overrideUserInterfaceStyle = style - } - .store(in: &cancellables) - } - - private func updateView() { - let remoteType = Storage.shared.remoteType.value - - if let existingHostingController = hostingController { - existingHostingController.willMove(toParent: nil) - existingHostingController.view.removeFromSuperview() - existingHostingController.removeFromParent() - } - - if remoteType == .nightscout { - var remoteView: AnyView - - switch Storage.shared.device.value { - case "Trio": - remoteView = AnyView(TrioNightscoutRemoteView()) - default: - remoteView = AnyView(NoRemoteView()) - } - - hostingController = UIHostingController(rootView: remoteView) - } else if remoteType == .trc { - if Storage.shared.device.value != "Trio" { - hostingController = UIHostingController( - rootView: AnyView( - Text("Trio Remote Control is only supported for 'Trio'") - ) - ) - } else { - let trioRemoteControlViewModel = TrioRemoteControlViewModel() - let trioRemoteControlView = TrioRemoteControlView(viewModel: trioRemoteControlViewModel) - hostingController = UIHostingController(rootView: AnyView(trioRemoteControlView)) - } - } else if remoteType == .loopAPNS { - hostingController = UIHostingController(rootView: AnyView(LoopAPNSRemoteView())) - } else { - hostingController = UIHostingController(rootView: AnyView(Text("Please select a Remote Type in Settings."))) - } - - if let hostingController = hostingController { - addChild(hostingController) - view.addSubview(hostingController.view) - - hostingController.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), - hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - - hostingController.didMove(toParent: self) - } - - if remoteType == .nightscout, !Storage.shared.nsWriteAuth.value { - NightscoutUtils.verifyURLAndToken { _, _, nsWriteAuth, nsAdminAuth in - DispatchQueue.main.async { - Storage.shared.nsWriteAuth.value = nsWriteAuth - Storage.shared.nsAdminAuth.value = nsAdminAuth - } - } - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - updateView() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - let style = Storage.shared.appearanceMode.value.userInterfaceStyle - overrideUserInterfaceStyle = style - hostingController?.overrideUserInterfaceStyle = style - } - } -} diff --git a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift index 56c5686fb..ddfe9203b 100644 --- a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift +++ b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift @@ -111,10 +111,6 @@ struct RemoteCommandSettings: Codable { storage.device.value = "Loop" case .trc: storage.device.value = "Trio" - case .nightscout: - // For Nightscout, we don't automatically set device type - // as it should be determined by the actual connection - break case .none: // For none, we don't change the device type break @@ -149,8 +145,6 @@ struct RemoteCommandSettings: Codable { switch remoteType { case .none: return true - case .nightscout: - return !user.isEmpty case .trc: return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty case .loopAPNS: diff --git a/LoopFollow/Remote/Settings/RemoteDiagnostics.swift b/LoopFollow/Remote/Settings/RemoteDiagnostics.swift new file mode 100644 index 000000000..fc2bf8e5b --- /dev/null +++ b/LoopFollow/Remote/Settings/RemoteDiagnostics.swift @@ -0,0 +1,44 @@ +// LoopFollow +// RemoteDiagnostics.swift + +import Foundation + +struct RemoteDiagnostics { + enum Status: Equatable { + case unknown + case running + case ok + case failed(String) + } + + var status: Status = .unknown + var bundleMismatch: BundleMismatch? + var bouncingTokens: BouncingTokens? + var futureStartDate: FutureStartDate? + + var hasAnyWarning: Bool { + bundleMismatch != nil || bouncingTokens != nil || futureStartDate != nil + } + + struct BundleMismatch: Equatable { + let expectedDevice: String + let observedBundleId: String + } + + struct BouncingTokens: Equatable { + let distinctCount: Int + let recordsScanned: Int + let shifts: [TokenShift] + } + + struct TokenShift: Equatable { + let when: Date + let fromToken: String + let toToken: String + let bundleIdentifier: String? + } + + struct FutureStartDate: Equatable { + let startDate: Date + } +} diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 532061013..879ca047a 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -29,7 +29,20 @@ struct RemoteSettingsView: View { self.viewModel = viewModel } + private let diagnosticsAnchorID = "remoteDiagnostics" + var body: some View { + ScrollViewReader { proxy in + formContent + .onChange(of: viewModel.diagnostics.status) { _ in + withAnimation { + proxy.scrollTo(diagnosticsAnchorID, anchor: .top) + } + } + } + } + + private var formContent: some View { Form { // MARK: - Remote Type Section (Custom Rows) @@ -51,16 +64,6 @@ struct RemoteSettingsView: View { label: "Trio Remote Control", isEnabled: viewModel.isTrioDevice ) - - remoteTypeRow( - type: .nightscout, - label: "Nightscout", - isEnabled: viewModel.isTrioDevice - ) - - Text("Nightscout should be used for Trio 0.2.x.") - .font(.footnote) - .foregroundColor(.secondary) } // MARK: - Import/Export Settings Section @@ -118,7 +121,7 @@ struct RemoteSettingsView: View { // MARK: - User Information Section - if viewModel.remoteType != .none && viewModel.remoteType != .loopAPNS { + if viewModel.remoteType == .trc { Section(header: Text("User Information")) { HStack { Text("User") @@ -175,6 +178,8 @@ struct RemoteSettingsView: View { if Storage.shared.bolusIncrementDetected.value { Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U") } + diagnosticsRows + .id(diagnosticsAnchorID) } } @@ -277,6 +282,8 @@ struct RemoteSettingsView: View { if Storage.shared.bolusIncrementDetected.value { Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U") } + diagnosticsRows + .id(diagnosticsAnchorID) } } } @@ -465,4 +472,119 @@ struct RemoteSettingsView: View { } } } + + // MARK: - Diagnostics + + @ViewBuilder + private var diagnosticsRows: some View { + switch viewModel.diagnostics.status { + case .running: + HStack { + ProgressView() + Text("Checking Nightscout profile history…") + .foregroundColor(.secondary) + } + case .unknown: + Button(action: { viewModel.runDiagnostics() }) { + HStack { + Image(systemName: "stethoscope") + Text("Run diagnostics") + } + } + case let .failed(message): + Button(action: { viewModel.runDiagnostics() }) { + HStack { + Image(systemName: "stethoscope") + Text("Run diagnostics again") + } + } + Text("Diagnostics unavailable: \(message)") + .font(.footnote) + .foregroundColor(.secondary) + case .ok: + Button(action: { viewModel.runDiagnostics() }) { + HStack { + Image(systemName: "stethoscope") + Text("Run diagnostics again") + } + } + if let mismatch = viewModel.diagnostics.bundleMismatch { + diagnosticWarning( + title: "Profile uploaded by a different app", + detail: "The current Nightscout profile was uploaded by \(mismatch.observedBundleId), but you're configured for \(mismatch.expectedDevice). When Loop and Trio share a Nightscout, they overwrite each other's profile." + ) + } + if let bouncing = viewModel.diagnostics.bouncingTokens { + bouncingTokensWarning(bouncing) + } + if let future = viewModel.diagnostics.futureStartDate { + diagnosticWarning( + title: "Future-dated profile record found", + detail: "A profile record has startDate \(dateTimeUtils.formattedDate(from: future.startDate)). LoopFollow ignores future-dated records, but it will still appear as the current profile in your Nightscout dashboard. Consider deleting it — it usually means a phone with the wrong system clock is uploading." + ) + } + if !viewModel.diagnostics.hasAnyWarning { + HStack { + Image(systemName: "checkmark.seal") + .foregroundColor(.green) + Text("No issues detected") + .foregroundColor(.secondary) + } + } + } + } + + private func diagnosticWarning(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + Text(title) + .fontWeight(.semibold) + .foregroundColor(.orange) + } + Text(detail) + .font(.footnote) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + + @ViewBuilder + private func bouncingTokensWarning(_ bouncing: RemoteDiagnostics.BouncingTokens) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + Text("Multiple devices uploading profiles") + .fontWeight(.semibold) + .foregroundColor(.orange) + } + Text("Device tokens are alternating in recent profile uploads (\(bouncing.distinctCount) tokens involved across \(bouncing.recordsScanned) records). This usually means more than one app installation is uploading to the same Nightscout. Remove the app from spare or unused phones.") + .font(.footnote) + .foregroundColor(.secondary) + if !bouncing.shifts.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(bouncing.shifts.enumerated()), id: \.offset) { _, shift in + Text("\(shiftTimestampFormatter.string(from: shift.when)) \(abbreviateToken(shift.fromToken)) → \(abbreviateToken(shift.toToken))") + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + } + } + .padding(.top, 2) + } + } + .padding(.vertical, 4) + } + + private var shiftTimestampFormatter: DateFormatter { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm" + return f + } + + private func abbreviateToken(_ token: String) -> String { + guard token.count > 16 else { return token } + return "\(token.prefix(7))…\(token.suffix(6))" + } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index c05f041a2..2b3ab9af0 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -37,6 +37,12 @@ class RemoteSettingsViewModel: ObservableObject { @Published var shouldPromptForURL: Bool = false @Published var shouldPromptForToken: Bool = false + // MARK: - Diagnostics + + @Published var diagnostics = RemoteDiagnostics() + private let diagnosticsHistoryCap = 1000 + private let futureStartDateTolerance: TimeInterval = 60 + let loopFollowTeamId: String = BuildDetails.default.teamID ?? "Unknown" /// Determines if the target app's Team ID is different from this app's build Team ID. @@ -57,7 +63,7 @@ class RemoteSettingsViewModel: ObservableObject { } return loopFollowTeamID != targetTeamId - case .none, .nightscout: + case .none: return false } } @@ -233,4 +239,111 @@ class RemoteSettingsViewModel: ObservableObject { isTrioDevice = (storage.device.value == "Trio") isLoopDevice = (storage.device.value == "Loop") } + + // MARK: - Diagnostics + + func runDiagnostics() { + diagnostics = RemoteDiagnostics(status: .running) + + guard !storage.url.value.isEmpty else { + diagnostics = RemoteDiagnostics(status: .ok) + return + } + + let parameters: [String: String] = [ + "count": "\(diagnosticsHistoryCap)", + ] + NightscoutUtils.executeRequest( + eventType: .profile, + parameters: parameters + ) { [weak self] (result: Result<[NSProfile], Error>) in + guard let self = self else { return } + switch result { + case let .success(history): + let evaluated = self.evaluateDiagnostics(history: history) + DispatchQueue.main.async { + self.diagnostics = evaluated + LogManager.shared.log( + category: .nightscout, + message: "Remote diagnostics evaluated: records=\(history.count) bundleMismatch=\(evaluated.bundleMismatch != nil) bouncingTokens=\(evaluated.bouncingTokens != nil) futureStartDate=\(evaluated.futureStartDate != nil)" + ) + } + case let .failure(error): + DispatchQueue.main.async { + self.diagnostics = RemoteDiagnostics(status: .failed(error.localizedDescription)) + } + } + } + } + + private func evaluateDiagnostics(history: [NSProfile]) -> RemoteDiagnostics { + var result = RemoteDiagnostics(status: .ok) + let device = storage.device.value + + if let current = history.first, !device.isEmpty { + let topLevel = current.bundleIdentifier?.trimmingCharacters(in: .whitespaces) ?? "" + let nested = current.loopSettings?.bundleIdentifier?.trimmingCharacters(in: .whitespaces) ?? "" + + if device == "Loop", nested.isEmpty, !topLevel.isEmpty { + result.bundleMismatch = .init(expectedDevice: "Loop", observedBundleId: topLevel) + } else if device == "Trio", topLevel.isEmpty, !nested.isEmpty { + result.bundleMismatch = .init(expectedDevice: "Trio", observedBundleId: nested) + } + } + + let chronological = history.sorted { lhs, rhs in + profileTimestamp(lhs) < profileTimestamp(rhs) + } + struct CompressedEntry { + let token: String + let when: Date + let bundle: String? + } + var compressed: [CompressedEntry] = [] + for record in chronological { + guard let token = record.deviceToken ?? record.loopSettings?.deviceToken, + !token.isEmpty else { continue } + if compressed.last?.token != token { + compressed.append( + CompressedEntry( + token: token, + when: profileTimestamp(record), + bundle: record.bundleIdentifier ?? record.loopSettings?.bundleIdentifier + ) + ) + } + } + let distinctTokens = Set(compressed.map { $0.token }) + if compressed.count > distinctTokens.count { + var shifts: [RemoteDiagnostics.TokenShift] = [] + for pair in zip(compressed, compressed.dropFirst()) { + shifts.append( + RemoteDiagnostics.TokenShift( + when: pair.1.when, + fromToken: pair.0.token, + toToken: pair.1.token, + bundleIdentifier: pair.1.bundle + ) + ) + } + result.bouncingTokens = .init( + distinctCount: distinctTokens.count, + recordsScanned: history.count, + shifts: shifts + ) + } + + let dates = history.compactMap { $0.startDate.flatMap(NightscoutUtils.parseDate) } + if let maxDate = dates.max(), maxDate > Date().addingTimeInterval(futureStartDateTolerance) { + result.futureStartDate = .init(startDate: maxDate) + } + + return result + } + + private func profileTimestamp(_ profile: NSProfile) -> Date { + if let s = profile.startDate, let d = NightscoutUtils.parseDate(s) { return d } + if let s = profile.createdAt, let d = NightscoutUtils.parseDate(s) { return d } + return .distantPast + } } diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 28fbb30a1..7a59c87de 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -14,6 +14,7 @@ struct BolusView: View { @ObservedObject private var deviceRecBolus = Observable.shared.deviceRecBolus @ObservedObject private var enactedOrSuggested = Observable.shared.enactedOrSuggested + @ObservedObject private var quickPickBoluses = QuickPickBolusesManager.shared @FocusState private var bolusFieldIsFocused: Bool @State private var showAlert = false @@ -67,6 +68,30 @@ struct BolusView: View { Form { recommendedBlocks(now: context.date) + if !quickPickBoluses.quickPickBoluses.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Boluses", infoText: QuickPickSectionHeader.bolusInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickBoluses.quickPickBoluses) { bolus in + Button { + applyQuickPickBolus(bolus.units) + } label: { + Text("\(InsulinFormatter.shared.string(bolus.units))U") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section { HKQuantityInputView( label: "Bolus Amount", @@ -118,6 +143,12 @@ struct BolusView: View { .navigationTitle("Bolus") .navigationBarTitleDisplayMode(.inline) } + .onAppear { + quickPickBoluses.refresh( + stepIncrement: stepU, + maxBolus: maxBolus.value.doubleValue(for: .internationalUnit()) + ) + } .alert(isPresented: $showAlert) { switch alertType { case .confirmBolus: @@ -258,6 +289,13 @@ struct BolusView: View { } } + private func applyQuickPickBolus(_ units: Double) { + let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) + let clamped = min(units, maxU) + let stepped = roundedToStep(clamped) + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: stepped) + } + private func applyRecommendedBolus(_ rec: Double) { let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) let clamped = min(rec, maxU) @@ -280,6 +318,10 @@ struct BolusView: View { DispatchQueue.main.async { isLoading = false if success { + let sentUnits = bolusAmount.doubleValue(for: .internationalUnit()) + if sentUnits > 0 { + QuickPickBolusesManager.shared.recordBolus(units: sentUnits) + } statusMessage = "Bolus command sent successfully." LogManager.shared.log( category: .apns, diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index ec35e508c..5938735d1 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -20,6 +20,7 @@ struct MealView: View { @ObservedObject private var mealWithBolus = Storage.shared.mealWithBolus @ObservedObject private var mealWithFatProtein = Storage.shared.mealWithFatProtein @ObservedObject private var maxBolus = Storage.shared.maxBolus + @ObservedObject private var quickPickMeals = QuickPickMealsManager.shared @FocusState private var carbsFieldIsFocused: Bool @FocusState private var proteinFieldIsFocused: Bool @@ -46,6 +47,40 @@ struct MealView: View { NavigationView { VStack { Form { + if !quickPickMeals.quickPickMeals.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Meals", infoText: QuickPickSectionHeader.mealInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickMeals.quickPickMeals) { meal in + Button { + applyQuickPickMeal(meal) + } label: { + VStack(spacing: 2) { + Text("\(Int(meal.carbs))g") + .font(.subheadline.weight(.medium)) + if mealWithFatProtein.value, meal.fat > 0 || meal.protein > 0 { + Text("F\(Int(meal.fat)) P\(Int(meal.protein))") + .font(.caption2) + } + if mealWithBolus.value, meal.bolus > 0 { + Text("\(InsulinFormatter.shared.string(meal.bolus))U") + .font(.caption2) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section(header: Text("Meal Data")) { // TODO: This banner can be deleted in March 2027. Check the commit for other places to cleanup. if showFatProteinOrderBanner { @@ -186,6 +221,11 @@ struct MealView: View { selectedTime = nil isScheduling = false + quickPickMeals.refresh( + maxCarbs: maxCarbs.value.doubleValue(for: .gram()), + includeFatProtein: mealWithFatProtein.value + ) + if !Storage.shared.hasSeenFatProteinOrderChange.value && Storage.shared.mealWithFatProtein.value { showFatProteinOrderBanner = true } @@ -313,6 +353,15 @@ struct MealView: View { DispatchQueue.main.async { isLoading = false if success { + let sentCarbs = carbs.doubleValue(for: .gram()) + if sentCarbs > 0 { + QuickPickMealsManager.shared.recordMeal( + carbs: sentCarbs, + fat: fat.doubleValue(for: .gram()), + protein: protein.doubleValue(for: .gram()), + bolus: bolusAmount.doubleValue(for: .internationalUnit()) + ) + } statusMessage = "Meal command sent successfully." LogManager.shared.log( category: .apns, @@ -345,6 +394,21 @@ struct MealView: View { return formatter.string(from: date) } + private func applyQuickPickMeal(_ meal: QuickPickMeal) { + let maxC = maxCarbs.value.doubleValue(for: .gram()) + carbs = HKQuantity(unit: .gram(), doubleValue: min(meal.carbs, maxC)) + if mealWithFatProtein.value { + let maxF = maxFat.value.doubleValue(for: .gram()) + let maxP = maxProtein.value.doubleValue(for: .gram()) + fat = HKQuantity(unit: .gram(), doubleValue: min(meal.fat, maxF)) + protein = HKQuantity(unit: .gram(), doubleValue: min(meal.protein, maxP)) + } + if mealWithBolus.value, meal.bolus > 0 { + let maxB = maxBolus.value.doubleValue(for: .internationalUnit()) + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: min(meal.bolus, maxB)) + } + } + private func handleValidationError(_ message: String) { alertMessage = message alertType = .validationError diff --git a/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift b/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift deleted file mode 100644 index 594619690..000000000 --- a/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift +++ /dev/null @@ -1,51 +0,0 @@ -// LoopFollow -// TrioNightscoutRemoteController.swift - -import Foundation -import HealthKit - -class TrioNightscoutRemoteController { - func cancelExistingTarget(completion: @escaping (Bool) -> Void) { - Task { - let tempTargetBody: [String: Any] = [ - "enteredBy": "LoopFollow", - "eventType": "Temporary Target", - "reason": "Manual", - "duration": 0, - "created_at": ISO8601DateFormatter().string(from: Date()), - ] - - do { - let response: [TreatmentCancelResponse] = try await NightscoutUtils.executePostRequest(eventType: .treatments, body: tempTargetBody) - Observable.shared.tempTarget.value = nil - NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) - completion(true) - } catch { - completion(false) - } - } - } - - func sendTempTarget(newTarget: HKQuantity, duration: HKQuantity, completion: @escaping (Bool) -> Void) { - let tempTargetBody: [String: Any] = [ - "enteredBy": "LoopFollow", - "eventType": "Temporary Target", - "reason": "Manual", - "targetTop": newTarget.doubleValue(for: .milligramsPerDeciliter), - "targetBottom": newTarget.doubleValue(for: .milligramsPerDeciliter), - "duration": Int(duration.doubleValue(for: .minute())), - "created_at": ISO8601DateFormatter().string(from: Date()), - ] - - Task { - do { - let response: [TreatmentResponse] = try await NightscoutUtils.executePostRequest(eventType: .treatments, body: tempTargetBody) - Observable.shared.tempTarget.value = newTarget - NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) - completion(true) - } catch { - completion(false) - } - } - } -} diff --git a/LoopFollow/Settings/AdvancedSettingsView.swift b/LoopFollow/Settings/AdvancedSettingsView.swift index 1873aabf5..9665df882 100644 --- a/LoopFollow/Settings/AdvancedSettingsView.swift +++ b/LoopFollow/Settings/AdvancedSettingsView.swift @@ -7,24 +7,22 @@ struct AdvancedSettingsView: View { @ObservedObject var viewModel: AdvancedSettingsViewModel var body: some View { - NavigationView { - Form { - Section(header: Text("Advanced Settings")) { - Toggle("Download Treatments", isOn: $viewModel.downloadTreatments) - Toggle("Download Prediction", isOn: $viewModel.downloadPrediction) - Toggle("Graph Basal", isOn: $viewModel.graphBasal) - Toggle("Graph Bolus", isOn: $viewModel.graphBolus) - Toggle("Graph Carbs", isOn: $viewModel.graphCarbs) - Toggle("Graph Other Treatments", isOn: $viewModel.graphOtherTreatments) + Form { + Section(header: Text("Advanced Settings")) { + Toggle("Download Treatments", isOn: $viewModel.downloadTreatments) + Toggle("Download Prediction", isOn: $viewModel.downloadPrediction) + Toggle("Graph Basal", isOn: $viewModel.graphBasal) + Toggle("Graph Bolus", isOn: $viewModel.graphBolus) + Toggle("Graph Carbs", isOn: $viewModel.graphCarbs) + Toggle("Graph Other Treatments", isOn: $viewModel.graphOtherTreatments) - Stepper(value: $viewModel.bgUpdateDelay, in: 1 ... 30, step: 1) { - Text("BG Update Delay (Sec): \(viewModel.bgUpdateDelay)") - } + Stepper(value: $viewModel.bgUpdateDelay, in: 1 ... 30, step: 1) { + Text("BG Update Delay (Sec): \(viewModel.bgUpdateDelay)") } + } - Section(header: Text("Logging Options")) { - Toggle("Debug Log Level", isOn: $viewModel.debugLogLevel) - } + Section(header: Text("Logging Options")) { + Toggle("Debug Log Level", isOn: $viewModel.debugLogLevel) } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) diff --git a/LoopFollow/Settings/CalendarSettingsView.swift b/LoopFollow/Settings/CalendarSettingsView.swift index 704ef5e2f..40f882582 100644 --- a/LoopFollow/Settings/CalendarSettingsView.swift +++ b/LoopFollow/Settings/CalendarSettingsView.swift @@ -20,59 +20,57 @@ struct CalendarSettingsView: View { // MARK: Body var body: some View { - NavigationView { - Form { - // ------------- Calendar write ------------- - Section { - Toggle("Save BG to Calendar", - isOn: $writeCalendarEvent.value) - .disabled(accessDenied) // prevent use when no access - } footer: { - Text(""" - Add the Apple-Calendar complication to your watch or CarPlay \ - to see BG readings. Create a separate calendar (e.g. “Follow”) \ - — this view will **delete** events on the same calendar each time \ - it writes new readings. - """) - } + Form { + // ------------- Calendar write ------------- + Section { + Toggle("Save BG to Calendar", + isOn: $writeCalendarEvent.value) + .disabled(accessDenied) // prevent use when no access + } footer: { + Text(""" + Add the Apple-Calendar complication to your watch or CarPlay \ + to see BG readings. Create a separate calendar (e.g. “Follow”) \ + — this view will **delete** events on the same calendar each time \ + it writes new readings. + """) + } - // ------------- Access / calendar picker ------------- - if accessDenied { - Text("Calendar access denied") - .foregroundColor(.red) - } else { - if !calendars.isEmpty { - Picker("Calendar", - selection: $calendarIdentifier.value) - { - ForEach(calendars, id: \.calendarIdentifier) { cal in - Text(cal.title).tag(cal.calendarIdentifier) - } + // ------------- Access / calendar picker ------------- + if accessDenied { + Text("Calendar access denied") + .foregroundColor(.red) + } else { + if !calendars.isEmpty { + Picker("Calendar", + selection: $calendarIdentifier.value) + { + ForEach(calendars, id: \.calendarIdentifier) { cal in + Text(cal.title).tag(cal.calendarIdentifier) } } } + } - // ------------- Template lines ------------- - Section("Calendar Text") { - TextField("Line 1", text: $watchLine1.value) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) + // ------------- Template lines ------------- + Section("Calendar Text") { + TextField("Line 1", text: $watchLine1.value) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) - TextField("Line 2", text: $watchLine2.value) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - } + TextField("Line 2", text: $watchLine2.value) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + } - // ------------- Variable cheat-sheet ------------- - Section("Available Variables") { - ForEach(variableDescriptions, id: \.self) { desc in - Text(desc) - } + // ------------- Variable cheat-sheet ------------- + Section("Available Variables") { + ForEach(variableDescriptions, id: \.self) { desc in + Text(desc) } } - .task { // runs once on appear - await requestCalendarAccessAndLoad() - } + } + .task { // runs once on appear + await requestCalendarAccessAndLoad() } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Calendar", displayMode: .inline) diff --git a/LoopFollow/Settings/ContactSettingsView.swift b/LoopFollow/Settings/ContactSettingsView.swift index 111668fae..5ace358db 100644 --- a/LoopFollow/Settings/ContactSettingsView.swift +++ b/LoopFollow/Settings/ContactSettingsView.swift @@ -12,117 +12,115 @@ struct ContactSettingsView: View { @State private var alertMessage: String = "" var body: some View { - NavigationView { - Form { - Section(header: Text("Contact Integration")) { - Text("Add the contact named '\(viewModel.contactName)' to your watch face to show the current BG value in real time. Make sure to give the app full access to Contacts when prompted.") + Form { + Section(header: Text("Contact Integration")) { + Text("Add the contact named '\(viewModel.contactName)' to your watch face to show the current BG value in real time. Make sure to give the app full access to Contacts when prompted.") + .font(.footnote) + .foregroundColor(.secondary) + .padding(.vertical, 4) + + Toggle("Enable Contact BG Updates", isOn: $viewModel.contactEnabled) + .toggleStyle(SwitchToggleStyle()) + .onChange(of: viewModel.contactEnabled) { isEnabled in + if isEnabled { + requestContactAccess() + } + } + } + + if viewModel.contactEnabled { + Section(header: Text("Color Options")) { + Text("Select the colors for your BG values. Note: not all watch faces allow control over colors. Recommend options like Activity or Modular Duo if you want to customize colors.") .font(.footnote) .foregroundColor(.secondary) .padding(.vertical, 4) - Toggle("Enable Contact BG Updates", isOn: $viewModel.contactEnabled) - .toggleStyle(SwitchToggleStyle()) - .onChange(of: viewModel.contactEnabled) { isEnabled in - if isEnabled { - requestContactAccess() - } + Picker("Background Color", selection: $viewModel.contactBackgroundColor) { + ForEach(ContactColorOption.allCases, id: \.rawValue) { option in + Text(option.rawValue.capitalized).tag(option.rawValue) } - } + } - if viewModel.contactEnabled { - Section(header: Text("Color Options")) { - Text("Select the colors for your BG values. Note: not all watch faces allow control over colors. Recommend options like Activity or Modular Duo if you want to customize colors.") - .font(.footnote) - .foregroundColor(.secondary) - .padding(.vertical, 4) + Picker("Color Mode", selection: $viewModel.contactColorMode) { + ForEach(ContactColorMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } - Picker("Background Color", selection: $viewModel.contactBackgroundColor) { + if viewModel.contactColorMode == .staticColor { + Picker("Text Color", selection: $viewModel.contactTextColor) { ForEach(ContactColorOption.allCases, id: \.rawValue) { option in Text(option.rawValue.capitalized).tag(option.rawValue) } } - - Picker("Color Mode", selection: $viewModel.contactColorMode) { - ForEach(ContactColorMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } - - if viewModel.contactColorMode == .staticColor { - Picker("Text Color", selection: $viewModel.contactTextColor) { - ForEach(ContactColorOption.allCases, id: \.rawValue) { option in - Text(option.rawValue.capitalized).tag(option.rawValue) - } - } - } else { - Text("Dynamic mode colors text based on BG range: Green (in range), Yellow (high), Red (low)") - .font(.footnote) - .foregroundColor(.secondary) - } - } - - Section(header: Text("Additional Information")) { - Text("To see your trend, delta, or IOB, include them in another contact or create separate contacts. When using 'Include', select which contact to add the value to.") + } else { + Text("Dynamic mode colors text based on BG range: Green (in range), Yellow (high), Red (low)") .font(.footnote) .foregroundColor(.secondary) - .padding(.vertical, 4) + } + } - Text("Trend") - .font(.subheadline) - Picker("Show Trend", selection: $viewModel.contactTrend) { - ForEach(ContactIncludeOption.allCases, id: \.self) { option in - Text(option.rawValue).tag(option) - } + Section(header: Text("Additional Information")) { + Text("To see your trend, delta, or IOB, include them in another contact or create separate contacts. When using 'Include', select which contact to add the value to.") + .font(.footnote) + .foregroundColor(.secondary) + .padding(.vertical, 4) + + Text("Trend") + .font(.subheadline) + Picker("Show Trend", selection: $viewModel.contactTrend) { + ForEach(ContactIncludeOption.allCases, id: \.self) { option in + Text(option.rawValue).tag(option) } - .pickerStyle(SegmentedPickerStyle()) + } + .pickerStyle(SegmentedPickerStyle()) - if viewModel.contactTrend == .include { - Picker("Include Trend in", selection: $viewModel.contactTrendTarget) { - ForEach(viewModel.availableTargets(for: .Trend), id: \.self) { target in - Text(target.rawValue).tag(target) - } + if viewModel.contactTrend == .include { + Picker("Include Trend in", selection: $viewModel.contactTrendTarget) { + ForEach(viewModel.availableTargets(for: .Trend), id: \.self) { target in + Text(target.rawValue).tag(target) } } + } - Text("Delta") - .font(.subheadline) - Picker("Show Delta", selection: $viewModel.contactDelta) { - ForEach(ContactIncludeOption.allCases, id: \.self) { option in - Text(option.rawValue).tag(option) - } + Text("Delta") + .font(.subheadline) + Picker("Show Delta", selection: $viewModel.contactDelta) { + ForEach(ContactIncludeOption.allCases, id: \.self) { option in + Text(option.rawValue).tag(option) } - .pickerStyle(SegmentedPickerStyle()) + } + .pickerStyle(SegmentedPickerStyle()) - if viewModel.contactDelta == .include { - Picker("Include Delta in", selection: $viewModel.contactDeltaTarget) { - ForEach(viewModel.availableTargets(for: .Delta), id: \.self) { target in - Text(target.rawValue).tag(target) - } + if viewModel.contactDelta == .include { + Picker("Include Delta in", selection: $viewModel.contactDeltaTarget) { + ForEach(viewModel.availableTargets(for: .Delta), id: \.self) { target in + Text(target.rawValue).tag(target) } } + } - Text("IOB") - .font(.subheadline) - Picker("Show IOB", selection: $viewModel.contactIOB) { - ForEach(ContactIncludeOption.allCases, id: \.self) { option in - Text(option.rawValue).tag(option) - } + Text("IOB") + .font(.subheadline) + Picker("Show IOB", selection: $viewModel.contactIOB) { + ForEach(ContactIncludeOption.allCases, id: \.self) { option in + Text(option.rawValue).tag(option) } - .pickerStyle(SegmentedPickerStyle()) + } + .pickerStyle(SegmentedPickerStyle()) - if viewModel.contactIOB == .include { - Picker("Include IOB in", selection: $viewModel.contactIOBTarget) { - ForEach(viewModel.availableTargets(for: .IOB), id: \.self) { target in - Text(target.rawValue).tag(target) - } + if viewModel.contactIOB == .include { + Picker("Include IOB in", selection: $viewModel.contactIOBTarget) { + ForEach(viewModel.availableTargets(for: .IOB), id: \.self) { target in + Text(target.rawValue).tag(target) } } } } } - .alert(isPresented: $showAlert) { - Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) - } + } + .alert(isPresented: $showAlert) { + Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Contact", displayMode: .inline) diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 9949f3b93..0ac3acc69 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -32,118 +32,116 @@ struct GeneralSettingsView: View { @ObservedObject var telemetryEnabled = Storage.shared.telemetryEnabled var body: some View { - NavigationView { - Form { - Section("App Settings") { - Toggle("Display App Badge", isOn: $appBadge.value) - Toggle("Persistent Notification", isOn: $persistentNotification.value) - } + Form { + Section("App Settings") { + Toggle("Display App Badge", isOn: $appBadge.value) + Toggle("Persistent Notification", isOn: $persistentNotification.value) + } - Section("Display") { - Picker("Appearance", selection: $appearanceMode.value) { - ForEach(AppearanceMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } + Section("Display") { + Picker("Appearance", selection: $appearanceMode.value) { + ForEach(AppearanceMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) } - Toggle("Display Stats", isOn: $showStats.value) - Toggle("Display Small Graph", isOn: $showSmallGraph.value) - Toggle("Color BG Text", isOn: $colorBGText.value) - Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value) - Toggle("Show Display Name", isOn: $showDisplayName.value) - Toggle("Snoozer emoji", isOn: $snoozerEmoji.value) - Toggle("Force portrait mode", isOn: $forcePortraitMode.value) - .onChange(of: forcePortraitMode.value) { _ in - let window = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap { $0.windows } - .first - - window?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() - } } + Toggle("Display Stats", isOn: $showStats.value) + Toggle("Display Small Graph", isOn: $showSmallGraph.value) + Toggle("Color BG Text", isOn: $colorBGText.value) + Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value) + Toggle("Show Display Name", isOn: $showDisplayName.value) + Toggle("Snoozer emoji", isOn: $snoozerEmoji.value) + Toggle("Force portrait mode", isOn: $forcePortraitMode.value) + .onChange(of: forcePortraitMode.value) { _ in + let window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first + + window?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + } + } - Section("Time Zone") { - Toggle("Time Zone Override", isOn: $graphTimeZoneEnabled.value) - .onChange(of: graphTimeZoneEnabled.value) { _ in markChartSettingsDirty() } + Section("Time Zone") { + Toggle("Time Zone Override", isOn: $graphTimeZoneEnabled.value) + .onChange(of: graphTimeZoneEnabled.value) { _ in markChartSettingsDirty() } - if graphTimeZoneEnabled.value { - Picker("Time Zone", selection: $graphTimeZoneIdentifier.value) { - ForEach(Self.sortedTimeZones, id: \.identifier) { tz in - Text(Self.timeZoneLabel(tz)).tag(tz.identifier) - } + if graphTimeZoneEnabled.value { + Picker("Time Zone", selection: $graphTimeZoneIdentifier.value) { + ForEach(Self.sortedTimeZones, id: \.identifier) { tz in + Text(Self.timeZoneLabel(tz)).tag(tz.identifier) } - .onChange(of: graphTimeZoneIdentifier.value) { _ in markChartSettingsDirty() } } + .onChange(of: graphTimeZoneIdentifier.value) { _ in markChartSettingsDirty() } } + } - Section("Speak BG") { - Toggle("Speak BG", isOn: $speakBG.value.animation()) + Section("Speak BG") { + Toggle("Speak BG", isOn: $speakBG.value.animation()) - if speakBG.value { - Picker("Language", selection: $speakLanguage.value) { - Text("English").tag("en") - Text("French").tag("fr") - Text("Italian").tag("it") - Text("Slovak").tag("sk") - Text("Swedish").tag("sv") - } + if speakBG.value { + Picker("Language", selection: $speakLanguage.value) { + Text("English").tag("en") + Text("French").tag("fr") + Text("Italian").tag("it") + Text("Slovak").tag("sk") + Text("Swedish").tag("sv") + } - Toggle("Always", isOn: $speakBGAlways.value.animation()) + Toggle("Always", isOn: $speakBGAlways.value.animation()) - if !speakBGAlways.value { - Toggle("Low", isOn: $speakLowBG.value.animation()) - .onChange(of: speakLowBG.value) { newValue in - if newValue { - speakProactiveLowBG.value = false - } + if !speakBGAlways.value { + Toggle("Low", isOn: $speakLowBG.value.animation()) + .onChange(of: speakLowBG.value) { newValue in + if newValue { + speakProactiveLowBG.value = false } + } - Toggle("Proactive Low", isOn: $speakProactiveLowBG.value.animation()) - .onChange(of: speakProactiveLowBG.value) { newValue in - if newValue { - speakLowBG.value = false - } + Toggle("Proactive Low", isOn: $speakProactiveLowBG.value.animation()) + .onChange(of: speakProactiveLowBG.value) { newValue in + if newValue { + speakLowBG.value = false } - - if speakLowBG.value || speakProactiveLowBG.value { - BGPicker( - title: "Low BG Limit", - range: 40 ... 108, - value: $speakLowBGLimit.value - ) } - if speakProactiveLowBG.value { - BGPicker( - title: "Fast Drop Delta", - range: 3 ... 20, - value: $speakFastDropDelta.value - ) - } + if speakLowBG.value || speakProactiveLowBG.value { + BGPicker( + title: "Low BG Limit", + range: 40 ... 108, + value: $speakLowBGLimit.value + ) + } - Toggle("High", isOn: $speakHighBG.value.animation()) + if speakProactiveLowBG.value { + BGPicker( + title: "Fast Drop Delta", + range: 3 ... 20, + value: $speakFastDropDelta.value + ) + } - if speakHighBG.value { - BGPicker( - title: "High BG Limit", - range: 140 ... 300, - value: $speakHighBGLimit.value - ) - } + Toggle("High", isOn: $speakHighBG.value.animation()) + + if speakHighBG.value { + BGPicker( + title: "High BG Limit", + range: 140 ... 300, + value: $speakHighBGLimit.value + ) } } } + } - Section("Diagnostics") { - Toggle("Send anonymous usage stats", isOn: $telemetryEnabled.value) - .onChange(of: telemetryEnabled.value) { newValue in - if newValue { - TelemetryClient.shared.scheduleRecurring() - } + Section("Diagnostics") { + Toggle("Send anonymous usage stats", isOn: $telemetryEnabled.value) + .onChange(of: telemetryEnabled.value) { newValue in + if newValue { + TelemetryClient.shared.scheduleRecurring() } - NavigationLink("What's sent") { TelemetryPreviewView() } - NavigationLink("Privacy") { TelemetryPrivacyView() } - } + } + NavigationLink("What's sent") { TelemetryPreviewView() } + NavigationLink("Privacy") { TelemetryPrivacyView() } } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 534e00698..d1de43c97 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -12,6 +12,7 @@ struct GraphSettingsView: View { @ObservedObject private var show30MinLine = Storage.shared.show30MinLine @ObservedObject private var show90MinLine = Storage.shared.show90MinLine @ObservedObject private var showMidnightLines = Storage.shared.showMidnightLines + @ObservedObject private var showYesterdayLine = Storage.shared.showYesterdayLine @ObservedObject private var smallGraphTreatments = Storage.shared.smallGraphTreatments @ObservedObject private var smallGraphHeight = Storage.shared.smallGraphHeight @@ -24,106 +25,107 @@ struct GraphSettingsView: View { private var nightscoutEnabled: Bool { IsNightscoutEnabled() } var body: some View { - NavigationView { - Form { - // ── Graph Display ──────────────────────────────────────────── - Section("Graph Display") { - Toggle("Display Dots", isOn: $showDots.value) - .onChange(of: showDots.value) { _ in markDirty() } + Form { + // ── Graph Display ──────────────────────────────────────────── + Section("Graph Display") { + Toggle("Display Dots", isOn: $showDots.value) + .onChange(of: showDots.value) { _ in markDirty() } - Toggle("Display Lines", isOn: $showLines.value) - .onChange(of: showLines.value) { _ in markDirty() } + Toggle("Display Lines", isOn: $showLines.value) + .onChange(of: showLines.value) { _ in markDirty() } - if nightscoutEnabled { - Toggle("Show DIA Lines", isOn: $showDIALines.value) - .onChange(of: showDIALines.value) { _ in markDirty() } + if nightscoutEnabled { + Toggle("Show DIA Lines", isOn: $showDIALines.value) + .onChange(of: showDIALines.value) { _ in markDirty() } - Toggle("Show −30 min Line", isOn: $show30MinLine.value) - .onChange(of: show30MinLine.value) { _ in markDirty() } + Toggle("Show −30 min Line", isOn: $show30MinLine.value) + .onChange(of: show30MinLine.value) { _ in markDirty() } - Toggle("Show −90 min Line", isOn: $show90MinLine.value) - .onChange(of: show90MinLine.value) { _ in markDirty() } - } + Toggle("Show −90 min Line", isOn: $show90MinLine.value) + .onChange(of: show90MinLine.value) { _ in markDirty() } - Toggle("Show Midnight Lines", isOn: $showMidnightLines.value) - .onChange(of: showMidnightLines.value) { _ in markDirty() } + Toggle("Show Yesterday's BG", isOn: $showYesterdayLine.value) + .onChange(of: showYesterdayLine.value) { _ in markDirty() } } - // ── Treatments ─────────────────────────────────────────────── - if nightscoutEnabled { - Section("Treatments") { - Toggle("Show Carb/Bolus Values", isOn: $showValues.value) - Toggle("Show Carb Absorption", isOn: $showAbsorption.value) - Toggle("Treatments on Small Graph", - isOn: $smallGraphTreatments.value) - } + Toggle("Show Midnight Lines", isOn: $showMidnightLines.value) + .onChange(of: showMidnightLines.value) { _ in markDirty() } + } + + // ── Treatments ─────────────────────────────────────────────── + if nightscoutEnabled { + Section("Treatments") { + Toggle("Show Carb/Bolus Values", isOn: $showValues.value) + Toggle("Show Carb Absorption", isOn: $showAbsorption.value) + Toggle("Treatments on Small Graph", + isOn: $smallGraphTreatments.value) } + } + + // ── Small Graph ────────────────────────────────────────────── + Section("Small Graph") { + SettingsStepperRow( + title: "Height", + range: 40 ... 80, + step: 5, + value: $smallGraphHeight.value, + format: { "\(Int($0)) pt" } + ) + .onChange(of: smallGraphHeight.value) { _ in markDirty() } + } - // ── Small Graph ────────────────────────────────────────────── - Section("Small Graph") { + // ── Prediction ─────────────────────────────────────────────── + if nightscoutEnabled { + Section("Prediction") { SettingsStepperRow( - title: "Height", - range: 40 ... 80, - step: 5, - value: $smallGraphHeight.value, - format: { "\(Int($0)) pt" } + title: "Hours of Prediction", + range: 0 ... 6, + step: 0.25, + value: $predictionToLoad.value, + format: { "\($0.localized(maxFractionDigits: 2)) h" } ) - .onChange(of: smallGraphHeight.value) { _ in markDirty() } - } - // ── Prediction ─────────────────────────────────────────────── - if nightscoutEnabled { - Section("Prediction") { - SettingsStepperRow( - title: "Hours of Prediction", - range: 0 ... 6, - step: 0.25, - value: $predictionToLoad.value, - format: { "\($0.localized(maxFractionDigits: 2)) h" } - ) - - if Storage.shared.device.value != "Loop" { - Picker("Prediction Style", selection: $predictionDisplayType.value) { - ForEach(PredictionDisplayType.allCases, id: \.self) { type in - Text(type.displayName).tag(type) - } + if Storage.shared.device.value != "Loop" { + Picker("Prediction Style", selection: $predictionDisplayType.value) { + ForEach(PredictionDisplayType.allCases, id: \.self) { type in + Text(type.displayName).tag(type) } - .onChange(of: predictionDisplayType.value) { _ in markDirty() } } + .onChange(of: predictionDisplayType.value) { _ in markDirty() } } } + } - // ── Basal / BG scale ───────────────────────────────────────── - if nightscoutEnabled { - Section("Basal / BG Scale") { - SettingsStepperRow( - title: "Min Basal", - range: 0.5 ... 20, - step: 0.5, - value: $minBasalScale.value, - format: { "\($0.localized(maxFractionDigits: 1)) U/h" } - ) - - BGPicker( - title: "Min BG Scale", - range: 40 ... 400, - value: $minBGScale.value - ) - .onChange(of: minBGScale.value) { _ in markDirty() } - } + // ── Basal / BG scale ───────────────────────────────────────── + if nightscoutEnabled { + Section("Basal / BG Scale") { + SettingsStepperRow( + title: "Min Basal", + range: 0.5 ... 20, + step: 0.5, + value: $minBasalScale.value, + format: { "\($0.localized(maxFractionDigits: 1)) U/h" } + ) + + BGPicker( + title: "Min BG Scale", + range: 40 ... 400, + value: $minBGScale.value + ) + .onChange(of: minBGScale.value) { _ in markDirty() } } + } - // ── History window ─────────────────────────────────────────── - if nightscoutEnabled { - Section("History") { - SettingsStepperRow( - title: "Show Days Back", - range: 1 ... 4, - step: 1, - value: $downloadDays.value, - format: { "\(Int($0)) d" } - ) - } + // ── History window ─────────────────────────────────────────── + if nightscoutEnabled { + Section("History") { + SettingsStepperRow( + title: "Show Days Back", + range: 1 ... 4, + step: 1, + value: $downloadDays.value, + format: { "\(Int($0)) d" } + ) } } } diff --git a/LoopFollow/Settings/HomeContentView.swift b/LoopFollow/Settings/HomeContentView.swift index fe88cb569..4088e8061 100644 --- a/LoopFollow/Settings/HomeContentView.swift +++ b/LoopFollow/Settings/HomeContentView.swift @@ -6,36 +6,36 @@ import UIKit /// A SwiftUI wrapper around MainViewController that displays the full Home screen. /// This can be used both in the tab bar and as a modal from the Menu. -struct HomeContentView: UIViewControllerRepresentable { +struct HomeContentView: View { let isModal: Bool init(isModal: Bool = false) { self.isModal = isModal } - func makeUIViewController(context _: Context) -> UIViewController { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - - // Get the MainViewController from storyboard - guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else { - let fallbackVC = UIViewController() - fallbackVC.view.backgroundColor = .systemBackground - let label = UILabel() - label.text = "Unable to load Home screen" - label.textAlignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - fallbackVC.view.addSubview(label) - NSLayoutConstraint.activate([ - label.centerXAnchor.constraint(equalTo: fallbackVC.view.centerXAnchor), - label.centerYAnchor.constraint(equalTo: fallbackVC.view.centerYAnchor), - ]) - return fallbackVC - } + var body: some View { + MainViewControllerRepresentable() + // Home has no text input, yet iOS sometimes replays a stale keyboard + // frame when the app returns to the foreground, which squeezes the + // whole screen up by a keyboard's height until a rotation forces the + // safe area to recompute. Opting out of keyboard avoidance prevents it. + .ignoresSafeArea(.keyboard) + } +} +private struct MainViewControllerRepresentable: UIViewControllerRepresentable { + func makeUIViewController(context _: Context) -> UIViewController { + // Reuse the single long-lived instance rather than creating a new one, + // so there is exactly one data pipeline and MainViewController.shared is + // never displaced. bootstrap() is a no-op if it already exists. + MainViewController.bootstrap() + let mainVC = MainViewController.shared! + // Detach from any previous SwiftUI host (e.g. after a Menu push was + // popped and is now being re-pushed) before this representable embeds it. + mainVC.willMove(toParent: nil) + mainVC.removeFromParent() + mainVC.view.removeFromSuperview() mainVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - mainVC.isPresentedAsModal = isModal - return mainVC } @@ -50,7 +50,7 @@ struct HomeModalView: View { @Environment(\.dismiss) private var dismiss var body: some View { - NavigationView { + NavigationStack { HomeContentView(isModal: true) .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) diff --git a/LoopFollow/Settings/ImportExport/ExportableSettings.swift b/LoopFollow/Settings/ImportExport/ExportableSettings.swift index a8c3fdd7a..cd222417c 100644 --- a/LoopFollow/Settings/ImportExport/ExportableSettings.swift +++ b/LoopFollow/Settings/ImportExport/ExportableSettings.swift @@ -294,10 +294,6 @@ struct RemoteSettingsExport: Codable { storage.device.value = "Loop" case .trc: storage.device.value = "Trio" - case .nightscout: - // For Nightscout, we don't automatically set device type - // as it should be determined by the actual connection - break case .none: break } @@ -317,8 +313,6 @@ struct RemoteSettingsExport: Codable { switch remoteType { case .none: return true - case .nightscout: - return !user.isEmpty case .trc: return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty case .loopAPNS: diff --git a/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift b/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift index adf334884..6082b2187 100644 --- a/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift +++ b/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift @@ -9,67 +9,65 @@ struct ImportExportSettingsView: View { @StateObject private var viewModel = ImportExportSettingsViewModel() var body: some View { - NavigationView { - List { - // MARK: - Import Section + List { + // MARK: - Import Section - Section("Import Settings") { - Button(action: { - viewModel.isShowingQRCodeScanner = true - }) { - HStack { - Image(systemName: "qrcode.viewfinder") - .foregroundColor(.blue) - Text("Scan QR Code to Import Settings") - } + Section("Import Settings") { + Button(action: { + viewModel.isShowingQRCodeScanner = true + }) { + HStack { + Image(systemName: "qrcode.viewfinder") + .foregroundColor(.blue) + Text("Scan QR Code to Import Settings") } - .buttonStyle(.plain) } + .buttonStyle(.plain) + } - // MARK: - Export Section + // MARK: - Export Section - Section("Export Settings To QR Code") { - ForEach(ImportExportSettingsViewModel.ExportType.allCases, id: \.self) { exportType in - Button(action: { - if exportType == .alarms { - viewModel.showAlarmSelection() - } else { - viewModel.exportType = exportType - if let qrString = viewModel.generateQRCodeForExport() { - viewModel.qrCodeString = qrString - viewModel.isShowingQRCodeDisplay = true - } - } - }) { - HStack { - Image(systemName: exportType.icon) - .foregroundColor(.blue) - Text("Export \(exportType.rawValue)") - Spacer() - Image(systemName: exportType == .alarms ? "list.bullet" : "qrcode") - .foregroundColor(.secondary) + Section("Export Settings To QR Code") { + ForEach(ImportExportSettingsViewModel.ExportType.allCases, id: \.self) { exportType in + Button(action: { + if exportType == .alarms { + viewModel.showAlarmSelection() + } else { + viewModel.exportType = exportType + if let qrString = viewModel.generateQRCodeForExport() { + viewModel.qrCodeString = qrString + viewModel.isShowingQRCodeDisplay = true } } - .buttonStyle(.plain) + }) { + HStack { + Image(systemName: exportType.icon) + .foregroundColor(.blue) + Text("Export \(exportType.rawValue)") + Spacer() + Image(systemName: exportType == .alarms ? "list.bullet" : "qrcode") + .foregroundColor(.secondary) + } } + .buttonStyle(.plain) } + } - // MARK: - Status Message + // MARK: - Status Message - if !viewModel.qrCodeErrorMessage.isEmpty { - Section { - let isSuccess = viewModel.qrCodeErrorMessage.contains("successfully") || viewModel.qrCodeErrorMessage.contains("Successfully imported") - let displayText = isSuccess ? "✅ \(viewModel.qrCodeErrorMessage)" : viewModel.qrCodeErrorMessage + if !viewModel.qrCodeErrorMessage.isEmpty { + Section { + let isSuccess = viewModel.qrCodeErrorMessage.contains("successfully") || viewModel.qrCodeErrorMessage.contains("Successfully imported") + let displayText = isSuccess ? "✅ \(viewModel.qrCodeErrorMessage)" : viewModel.qrCodeErrorMessage - Text(displayText) - .foregroundColor(isSuccess ? .green : .red) - .font(.caption) - } + Text(displayText) + .foregroundColor(isSuccess ? .green : .red) + .font(.caption) } } - .navigationTitle("Import/Export Settings") - .navigationBarTitleDisplayMode(.inline) } + .navigationTitle("Import/Export Settings") + .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: $viewModel.isShowingQRCodeScanner) { SimpleQRCodeScannerView { result in viewModel.handleQRCodeScanResult(result) diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 548cd0964..f354c51b6 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -5,138 +5,85 @@ import SwiftUI import UIKit struct SettingsMenuView: View { - // MARK: - Observed Objects - @ObservedObject private var nightscoutURL = Storage.shared.url - @ObservedObject private var settingsPath = Observable.shared.settingsPath - - // MARK: – Local state - - var onBack: (() -> Void)? - - // MARK: – Observed objects - - @ObservedObject private var url = Storage.shared.url - - // MARK: – Body var body: some View { - NavigationStack(path: $settingsPath.value) { - List { - dataSection - - Section("Display Settings") { - NavigationRow(title: "General", - icon: "gearshape") - { - settingsPath.value.append(Sheet.general) - } - - NavigationRow(title: "Graph", - icon: "chart.xyaxis.line") - { - settingsPath.value.append(Sheet.graph) - } - - if !nightscoutURL.value.isEmpty { - NavigationRow(title: "Information Display", - icon: "info.circle") - { - settingsPath.value.append(Sheet.infoDisplay) - } - } - NavigationRow(title: "Units and Metrics", - icon: "scalemass") - { - settingsPath.value.append(Sheet.units) - } - - NavigationRow(title: "Tabs", - icon: "rectangle.3.group") - { - settingsPath.value.append(Sheet.tabSettings) - } + List { + dataSection + + Section("Display Settings") { + NavigationRow(title: "General", + icon: "gearshape", + value: SettingsRoute.general) + NavigationRow(title: "Graph", + icon: "chart.xyaxis.line", + value: SettingsRoute.graph) + + if !nightscoutURL.value.isEmpty { + NavigationRow(title: "Information Display", + icon: "info.circle", + value: SettingsRoute.infoDisplay) } - Section("App Settings") { - NavigationRow(title: "Background Refresh", - icon: "arrow.clockwise") - { - settingsPath.value.append(Sheet.backgroundRefresh) - } - - NavigationRow(title: "Import/Export", - icon: "square.and.arrow.down") - { - settingsPath.value.append(Sheet.importExport) - } - - NavigationRow(title: "APN", - icon: "bell.and.waves.left.and.right") - { - settingsPath.value.append(Sheet.apn) - } - - #if !targetEnvironment(macCatalyst) - NavigationRow(title: "Live Activity", - icon: "dot.radiowaves.left.and.right") - { - settingsPath.value.append(Sheet.liveActivity) - } - #endif + NavigationRow(title: "Units and Metrics", + icon: "scalemass", + value: SettingsRoute.units) - if !nightscoutURL.value.isEmpty { - NavigationRow(title: "Remote", - icon: "antenna.radiowaves.left.and.right") - { - settingsPath.value.append(Sheet.remote) - } - } - } + NavigationRow(title: "Tabs", + icon: "rectangle.3.group", + value: SettingsRoute.tabSettings) + } - Section("Alarms") { - NavigationRow(title: "Alarms", - icon: "bell.badge") - { - settingsPath.value.append(Sheet.alarmSettings) - } + Section("App Settings") { + NavigationRow(title: "Background Refresh", + icon: "arrow.clockwise", + value: SettingsRoute.backgroundRefresh) + + NavigationRow(title: "Import/Export", + icon: "square.and.arrow.down", + value: SettingsRoute.importExport) + + NavigationRow(title: "APN", + icon: "bell.and.waves.left.and.right", + value: SettingsRoute.apn) + + #if !targetEnvironment(macCatalyst) + NavigationRow(title: "Live Activity", + icon: "dot.radiowaves.left.and.right", + value: SettingsRoute.liveActivity) + #endif + + if !nightscoutURL.value.isEmpty { + NavigationRow(title: "Remote", + icon: "antenna.radiowaves.left.and.right", + value: SettingsRoute.remote) } + } - Section("Integrations") { - NavigationRow(title: "Calendar", - icon: "calendar") - { - settingsPath.value.append(Sheet.calendar) - } + Section("Alarms") { + NavigationRow(title: "Alarms", + icon: "bell.badge", + value: SettingsRoute.alarmSettings) + } - NavigationRow(title: "Contact", - icon: "person.circle") - { - settingsPath.value.append(Sheet.contact) - } - } + Section("Integrations") { + NavigationRow(title: "Calendar", + icon: "calendar", + value: SettingsRoute.calendar) - Section("Advanced Settings") { - NavigationRow(title: "Advanced", - icon: "exclamationmark.shield") - { - settingsPath.value.append(Sheet.advanced) - } - } + NavigationRow(title: "Contact", + icon: "person.circle", + value: SettingsRoute.contact) } - .navigationTitle("Settings") - .navigationBarTitleDisplayMode(.large) - .navigationDestination(for: Sheet.self) { $0.destination } - .toolbar { - if let onBack { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: onBack) { - Image(systemName: "chevron.left") - } - } - } + + Section("Advanced Settings") { + NavigationRow(title: "Advanced", + icon: "exclamationmark.shield", + value: SettingsRoute.advanced) } } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.large) } // MARK: – Section builders @@ -145,23 +92,20 @@ struct SettingsMenuView: View { private var dataSection: some View { Section("Data Settings") { NavigationRow(title: "Nightscout", - icon: "network") - { - settingsPath.value.append(Sheet.nightscout) - } + icon: "network", + value: SettingsRoute.nightscout) NavigationRow(title: "Dexcom", - icon: "sensor.tag.radiowaves.forward") - { - settingsPath.value.append(Sheet.dexcom) - } + icon: "sensor.tag.radiowaves.forward", + value: SettingsRoute.dexcom) } } } // MARK: – Sheet routing -private enum Sheet: Hashable, Identifiable { +enum SettingsRoute: Hashable, Identifiable { + case settings case units case nightscout, dexcom case backgroundRefresh @@ -184,6 +128,7 @@ private enum Sheet: Hashable, Identifiable { @ViewBuilder var destination: some View { switch self { + case .settings: SettingsMenuView() case .units: UnitsSettingsView() case .nightscout: NightscoutSettingsView(viewModel: .init()) case .dexcom: DexcomSettingsView(viewModel: .init()) @@ -226,37 +171,7 @@ struct AggregatedStatsViewWrapper: View { } private func getMainViewController() -> MainViewController? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController - else { - return nil - } - - if let mainVC = rootVC as? MainViewController { - return mainVC - } - - if let navVC = rootVC as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - - if let tabVC = rootVC as? UITabBarController { - for vc in tabVC.viewControllers ?? [] { - if let mainVC = vc as? MainViewController { - return mainVC - } - if let navVC = vc as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - } - } - - return nil + MainViewController.shared } } @@ -266,7 +181,14 @@ import UIKit extension UIApplication { var topMost: UIViewController? { - guard var top = keyWindow?.rootViewController else { return nil } + // `keyWindow` is deprecated and returns nil on Mac Catalyst / multi-window iPad. + // Walk connected scenes instead and prefer the foreground-active one. + let windowScenes = connectedScenes.compactMap { $0 as? UIWindowScene } + let activeScene = windowScenes.first { $0.activationState == .foregroundActive } + ?? windowScenes.first + let rootVC = activeScene?.windows.first(where: \.isKeyWindow)?.rootViewController + ?? activeScene?.windows.first?.rootViewController + guard var top = rootVC else { return nil } while let presented = top.presentedViewController { top = presented } diff --git a/LoopFollow/Settings/ShareLogNoticeView.swift b/LoopFollow/Settings/ShareLogNoticeView.swift new file mode 100644 index 000000000..5b6b3fe3b --- /dev/null +++ b/LoopFollow/Settings/ShareLogNoticeView.swift @@ -0,0 +1,37 @@ +// LoopFollow +// ShareLogNoticeView.swift + +import SwiftUI + +struct ShareLogNoticeView: View { + @State private var noticeText: String = "" + let onCancel: () -> Void + let onShare: (String) -> Void + + var body: some View { + NavigationView { + Form { + Section { + Text("Thanks for sharing these logs to help us find the problem. Please describe it in as much detail as possible — what time did it happen, what did you do, and what did you expect to happen that didn't?") + .font(.callout) + .foregroundColor(.secondary) + } + + Section(header: Text("Description")) { + TextEditor(text: $noticeText) + .frame(minHeight: 180) + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationBarTitle("Share Logs", displayMode: .inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: onCancel) + } + ToolbarItem(placement: .confirmationAction) { + Button("Share") { onShare(noticeText) } + } + } + } + } +} diff --git a/LoopFollow/Snoozer/SnoozerViewController.swift b/LoopFollow/Snoozer/SnoozerViewController.swift deleted file mode 100644 index ea63d8e0f..000000000 --- a/LoopFollow/Snoozer/SnoozerViewController.swift +++ /dev/null @@ -1,65 +0,0 @@ -// LoopFollow -// SnoozerViewController.swift - -import Combine -import SwiftUI -import UIKit - -class SnoozerViewController: UIViewController { - private var hostingController: UIHostingController? - private var cancellables = Set() - - @State private var snoozeMinutes = 15 - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .black - - let snoozerView = SnoozerView() - - let hosting = UIHostingController(rootView: snoozerView) - hostingController = hosting - - // Apply initial appearance - hosting.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - // Listen for appearance setting changes - Storage.shared.appearanceMode.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] mode in - self?.hostingController?.overrideUserInterfaceStyle = mode.userInterfaceStyle - } - .store(in: &cancellables) - - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.hostingController?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - .store(in: &cancellables) - - addChild(hosting) - view.addSubview(hosting.view) - hosting.view.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hosting.view.topAnchor.constraint(equalTo: view.topAnchor), - hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - - hosting.didMove(toParent: self) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - hostingController?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - } -} diff --git a/LoopFollow/Stats/StatsDataFetcher.swift b/LoopFollow/Stats/StatsDataFetcher.swift index ff61d6eef..18353dce0 100644 --- a/LoopFollow/Stats/StatsDataFetcher.swift +++ b/LoopFollow/Stats/StatsDataFetcher.swift @@ -4,11 +4,17 @@ import Foundation class StatsDataFetcher { - weak var mainViewController: MainViewController? + /// See StatsDataService.mainViewController — the injected reference can be + /// nil at cold launch, so fall back to the shared engine. + private weak var injectedMainViewController: MainViewController? + var mainViewController: MainViewController? { + injectedMainViewController ?? MainViewController.shared + } + weak var dataService: StatsDataService? init(mainViewController: MainViewController?) { - self.mainViewController = mainViewController + injectedMainViewController = mainViewController } func fetchBGData(days: Int, completion: @escaping () -> Void) { @@ -20,7 +26,7 @@ class StatsDataFetcher { var parameters: [String: String] = [:] let utcISODateFormatter = ISO8601DateFormatter() let startDate = dataService?.startDate ?? dateTimeUtils.displayCalendar().date(byAdding: .day, value: -1 * days, to: Date())! - parameters["count"] = "\(days * 2 * 24 * 60 / 5)" + parameters["count"] = "\(days * globalVariables.maxExpectedUploaders * 24 * 60 / 5)" parameters["find[dateString][$gte]"] = utcISODateFormatter.string(from: startDate) parameters["find[type][$ne]"] = "cal" @@ -88,9 +94,20 @@ class StatsDataFetcher { return } - NightscoutUtils.executeRequest(eventType: .profile, parameters: [:]) { (result: Result) in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let parameters: [String: String] = [ + "count": "1", + "find[startDate][$lte]": formatter.string(from: Date().addingTimeInterval(60)), + ] + NightscoutUtils.executeRequest(eventType: .profile, parameters: parameters) { (result: Result<[NSProfile], Error>) in switch result { - case let .success(profileData): + case let .success(profiles): + guard let profileData = profiles.first else { + LogManager.shared.log(category: .nightscout, message: "ensureBasalProfileLoaded, no profile records returned") + DispatchQueue.main.async { completion() } + return + } let profileStore = profileData.store["default"] ?? profileData.store["Default"] ?? profileData.store[profileData.defaultProfile] diff --git a/LoopFollow/Stats/StatsDataService.swift b/LoopFollow/Stats/StatsDataService.swift index 4aa5b7c7f..a9f560b29 100644 --- a/LoopFollow/Stats/StatsDataService.swift +++ b/LoopFollow/Stats/StatsDataService.swift @@ -4,7 +4,13 @@ import Foundation class StatsDataService { - weak var mainViewController: MainViewController? + /// The injected reference can be nil when the stats UI is built before + /// MainViewController.bootstrap() has run (cold launch with Statistics as + /// the selected tab), so resolve lazily and fall back to the shared engine. + private weak var injectedMainViewController: MainViewController? + var mainViewController: MainViewController? { + injectedMainViewController ?? MainViewController.shared + } var daysToAnalyze: Int = 14 // Keep for backward compatibility var startDate: Date = dateTimeUtils.displayCalendar().date(byAdding: .day, value: -14, to: Date()) ?? Date() @@ -23,7 +29,7 @@ class StatsDataService { } init(mainViewController: MainViewController?) { - self.mainViewController = mainViewController + injectedMainViewController = mainViewController dataFetcher = StatsDataFetcher(mainViewController: mainViewController) dataFetcher.dataService = self } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index f32887523..4128918db 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -24,6 +24,12 @@ class Observable { var deltaText = ObservableValue(default: "+0") var iobText = ObservableValue(default: "--") + var serverText = ObservableValue(default: "Server") + var loopStatusText = ObservableValue(default: "") + var loopStatusColor = ObservableValue(default: .primary) + var predictionText = ObservableValue(default: "") + var predictionColor = ObservableValue(default: .purple) + var currentAlarm = ObservableValue(default: nil) var alarmSoundPlaying = ObservableValue(default: false) @@ -38,13 +44,14 @@ class Observable { var pumpBatteryLevel = ObservableValue(default: nil) var enactedOrSuggested = ObservableValue(default: nil) - var settingsPath = ObservableValue(default: NavigationPath()) - var lastSentTOTP = ObservableValue(default: nil) var loopFollowDeviceToken = ObservableValue(default: "") var isNotLooping = ObservableValue(default: false) + /// Selected tab index used by SwiftUI TabView — set from MainViewController to switch tabs + var selectedTabIndex = ObservableValue(default: 0) + private init() {} } diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index d74fc0625..b2274675d 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -60,6 +60,13 @@ extension Storage { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs) } + func migrateStep9() { + // Default for debugLogLevel changed from false to true so users ship useful + // logs when they report a problem. Force-enable for existing users. + LogManager.shared.log(category: .general, message: "Running migrateStep9 — enabling debug log level") + debugLogLevel.value = true + } + func migrateStep6() { // APNs credential separation LogManager.shared.log(category: .general, message: "Running migrateStep6 — APNs credential separation") diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 6ee01522b..4dd7370a8 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -39,7 +39,7 @@ class Storage { var selectedBLEDevice = StorageValue(key: "selectedBLEDevice", defaultValue: nil) - var debugLogLevel = StorageValue(key: "debugLogLevel", defaultValue: false) + var debugLogLevel = StorageValue(key: "debugLogLevel", defaultValue: true) var contactTrend = StorageValue(key: "contactTrend", defaultValue: .off) var contactDelta = StorageValue(key: "contactDelta", defaultValue: .off) @@ -129,6 +129,7 @@ class Storage { var show30MinLine = StorageValue(key: "show30MinLine", defaultValue: false) var show90MinLine = StorageValue(key: "show90MinLine", defaultValue: false) var showMidnightLines = StorageValue(key: "showMidnightMarkers", defaultValue: false) + var showYesterdayLine = StorageValue(key: "showYesterdayLine", defaultValue: false) var smallGraphTreatments = StorageValue(key: "smallGraphTreatments", defaultValue: true) var smallGraphHeight = StorageValue(key: "smallGraphHeight", defaultValue: 40) @@ -209,11 +210,12 @@ class Storage { var device = StorageValue(key: "device", defaultValue: "") var nsWriteAuth = StorageValue(key: "nsWriteAuth", defaultValue: false) var nsAdminAuth = StorageValue(key: "nsAdminAuth", defaultValue: false) + var webSocketEnabled = StorageValue(key: "webSocketEnabled", defaultValue: true) // When adding a new migration step in `runMigrationsIfNeeded()`, bump this default // to the new latest step number so fresh installs skip all migrations. Other defaults // in this file must reflect the post-migration final state for a fresh install. - var migrationStep = StorageValue(key: "migrationStep", defaultValue: 7) + var migrationStep = StorageValue(key: "migrationStep", defaultValue: 9) var persistentNotification = StorageValue(key: "persistentNotification", defaultValue: false) var persistentNotificationLastBGTime = StorageValue(key: "persistentNotificationLastBGTime", defaultValue: .distantPast) @@ -235,6 +237,9 @@ class Storage { var bolusIncrement = SecureStorageValue(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)) var bolusIncrementDetected = StorageValue(key: "bolusIncrementDetected", defaultValue: false) + + var remoteBolusHistory = StorageValue<[RemoteBolusHistoryEntry]>(key: "remoteBolusHistory", defaultValue: []) + var remoteMealHistory = StorageValue<[RemoteMealHistoryEntry]>(key: "remoteMealHistory", defaultValue: []) // Statistics display preferences var showGMI = StorageValue(key: "showGMI", defaultValue: true) var showStdDev = StorageValue(key: "showStdDev", defaultValue: true) @@ -429,6 +434,8 @@ class Storage { loopAPNSQrCodeURL.reload() bolusIncrementDetected.reload() + remoteBolusHistory.reload() + remoteMealHistory.reload() showGMI.reload() showStdDev.reload() showTITR.reload() diff --git a/LoopFollow/Task/MinAgoTask.swift b/LoopFollow/Task/MinAgoTask.swift index 0ea011cee..0ba964d99 100644 --- a/LoopFollow/Task/MinAgoTask.swift +++ b/LoopFollow/Task/MinAgoTask.swift @@ -2,7 +2,6 @@ // MinAgoTask.swift import Foundation -import UIKit extension MainViewController { func scheduleMinAgoTask(initialDelay: TimeInterval = 1.0) { @@ -15,9 +14,7 @@ extension MainViewController { func minAgoTaskAction() { guard bgData.count > 0, let lastBG = bgData.last else { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.MinAgoText.text = "" + DispatchQueue.main.async { Observable.shared.minAgoText.value = "" Observable.shared.bgText.value = "" } @@ -46,9 +43,7 @@ extension MainViewController { // Update UI only if the display text has changed if minAgoDisplayText != Observable.shared.minAgoText.value { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.MinAgoText.text = minAgoDisplayText + DispatchQueue.main.async { Observable.shared.minAgoText.value = minAgoDisplayText } } @@ -56,19 +51,12 @@ extension MainViewController { let deltaTime = secondsAgo / 60 Observable.shared.bgStale.value = deltaTime >= 12 - // Apply strikethrough to BGText based on the staleness of the data - // Also clear badge if bgvalue is stale - let bgTextStr = BGText.text ?? "" - let attributeString = NSMutableAttributedString(string: bgTextStr) - attributeString.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: 0, length: attributeString.length)) - if Observable.shared.bgStale.value { // Data is stale - attributeString.addAttribute(.strikethroughColor, value: UIColor.systemRed, range: NSRange(location: 0, length: attributeString.length)) + // Update badge based on staleness + if Observable.shared.bgStale.value { updateBadge(val: 0) - } else { // Data is fresh - attributeString.addAttribute(.strikethroughColor, value: UIColor.clear, range: NSRange(location: 0, length: attributeString.length)) + } else { updateBadge(val: Observable.shared.bg.value ?? 0) } - BGText.attributedText = attributeString // Determine the next run interval based on the current state let nextUpdateInterval: TimeInterval diff --git a/LoopFollow/Task/ProfileTask.swift b/LoopFollow/Task/ProfileTask.swift index 72de336fb..6b4358ca7 100644 --- a/LoopFollow/Task/ProfileTask.swift +++ b/LoopFollow/Task/ProfileTask.swift @@ -21,6 +21,10 @@ extension MainViewController { webLoadNSProfile() - TaskScheduler.shared.rescheduleTask(id: .profile, to: Date().addingTimeInterval(10 * 60)) + var interval: TimeInterval = 10 * 60 + if NightscoutSocketManager.shared.connectionState == .authenticated { + interval = 30 * 60 + } + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date().addingTimeInterval(interval)) } } diff --git a/LoopFollow/Task/TreatmentsTask.swift b/LoopFollow/Task/TreatmentsTask.swift index d3a0c620e..4e7aa0bc9 100644 --- a/LoopFollow/Task/TreatmentsTask.swift +++ b/LoopFollow/Task/TreatmentsTask.swift @@ -21,7 +21,11 @@ extension MainViewController { WebLoadNSTreatments() - TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date().addingTimeInterval(2 * 60)) + var interval: TimeInterval = 2 * 60 + if NightscoutSocketManager.shared.connectionState == .authenticated { + interval = 10 * 60 + } + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date().addingTimeInterval(interval)) TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3)) } } diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index da6d97182..f1b1c7595 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -826,25 +826,7 @@ class TreatmentDetailViewModel: ObservableObject { } private func getMainViewController() -> MainViewController? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let tabBarController = window.rootViewController as? UITabBarController - else { - return nil - } - - for vc in tabBarController.viewControllers ?? [] { - if let mainVC = vc as? MainViewController { - return mainVC - } - if let navVC = vc as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - } - - return nil + MainViewController.shared } } @@ -1417,26 +1399,7 @@ class TreatmentsViewModel: ObservableObject { } private func getMainViewController() -> MainViewController? { - // Try to find MainViewController in the app's window hierarchy - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let tabBarController = window.rootViewController as? UITabBarController - else { - return nil - } - - for vc in tabBarController.viewControllers ?? [] { - if let mainVC = vc as? MainViewController { - return mainVC - } - if let navVC = vc as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - } - - return nil + MainViewController.shared } } diff --git a/LoopFollow/ViewControllers/AlarmViewController.swift b/LoopFollow/ViewControllers/AlarmViewController.swift deleted file mode 100644 index 1b3c4d60b..000000000 --- a/LoopFollow/ViewControllers/AlarmViewController.swift +++ /dev/null @@ -1,60 +0,0 @@ -// LoopFollow -// AlarmViewController.swift - -import Combine -import SwiftUI -import UIKit - -class AlarmViewController: UIViewController { - private var hostingController: UIHostingController! - private var cancellables = Set() - - override func viewDidLoad() { - super.viewDidLoad() - - let alarmsView = AlarmsContainerView() - hostingController = UIHostingController(rootView: alarmsView) - - // Apply initial appearance - hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - // Listen for appearance setting changes - Storage.shared.appearanceMode.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] mode in - self?.hostingController.overrideUserInterfaceStyle = mode.userInterfaceStyle - } - .store(in: &cancellables) - - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - .store(in: &cancellables) - - addChild(hostingController) - view.addSubview(hostingController.view) - - hostingController.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), - hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - - hostingController.didMove(toParent: self) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - } -} diff --git a/LoopFollow/ViewControllers/BGDisplayView.swift b/LoopFollow/ViewControllers/BGDisplayView.swift new file mode 100644 index 000000000..ae626fa47 --- /dev/null +++ b/LoopFollow/ViewControllers/BGDisplayView.swift @@ -0,0 +1,74 @@ +// LoopFollow +// BGDisplayView.swift + +import SwiftUI + +struct BGDisplayView: View { + @ObservedObject var serverText = Observable.shared.serverText + @ObservedObject var bgText = Observable.shared.bgText + @ObservedObject var bgTextColor = Observable.shared.bgTextColor + @ObservedObject var bgStale = Observable.shared.bgStale + @ObservedObject var directionText = Observable.shared.directionText + @ObservedObject var deltaText = Observable.shared.deltaText + @ObservedObject var minAgoText = Observable.shared.minAgoText + @ObservedObject var loopStatusText = Observable.shared.loopStatusText + @ObservedObject var loopStatusColor = Observable.shared.loopStatusColor + @ObservedObject var predictionText = Observable.shared.predictionText + @ObservedObject var predictionColor = Observable.shared.predictionColor + @ObservedObject var isNotLooping = Observable.shared.isNotLooping + + var onRefresh: (() -> Void)? + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Text(serverText.value) + .font(.system(size: 13)) + + Text(bgText.value) + .font(.system(size: 85, weight: .black)) + .foregroundColor(bgTextColor.value) + .strikethrough( + bgStale.value, + pattern: .solid, + color: bgStale.value ? .red : .clear + ) + .frame(maxWidth: .infinity) + .lineLimit(1) + .minimumScaleFactor(0.5) + + HStack { + Text(directionText.value) + .font(.system(size: 60, weight: .black)) + Text(deltaText.value) + .font(.system(size: 32)) + } + .lineLimit(1) + .minimumScaleFactor(0.5) + + Text(minAgoText.value) + .font(.system(size: 17)) + + if isNotLooping.value { + Text(loopStatusText.value) + .font(.system(size: 18, weight: .bold)) + .foregroundColor(loopStatusColor.value) + .frame(maxWidth: .infinity) + } else { + HStack { + Spacer() + Text(loopStatusText.value) + .foregroundColor(loopStatusColor.value) + Text(predictionText.value) + .foregroundColor(predictionColor.value) + Spacer() + } + .font(.system(size: 17)) + } + } + } + .refreshable { + onRefresh?() + } + } +} diff --git a/LoopFollow/ViewControllers/LineChartWrapper.swift b/LoopFollow/ViewControllers/LineChartWrapper.swift new file mode 100644 index 000000000..b08edf8a7 --- /dev/null +++ b/LoopFollow/ViewControllers/LineChartWrapper.swift @@ -0,0 +1,20 @@ +// LoopFollow +// LineChartWrapper.swift + +import Charts +import SwiftUI + +struct LineChartWrapper: UIViewRepresentable { + let chartView: LineChartView + + func makeUIView(context _: Context) -> LineChartView { + chartView + } + + func updateUIView(_: LineChartView, context _: Context) { + // Intentionally empty. MainViewController owns the chart and calls + // notifyDataSetChanged itself whenever it mutates the data; doing it + // here too would redo that work on every unrelated SwiftUI re-render + // of MainHomeView (e.g. the once-a-second minAgoText tick). + } +} diff --git a/LoopFollow/ViewControllers/MainHomeView.swift b/LoopFollow/ViewControllers/MainHomeView.swift new file mode 100644 index 000000000..e8509d94d --- /dev/null +++ b/LoopFollow/ViewControllers/MainHomeView.swift @@ -0,0 +1,71 @@ +// LoopFollow +// MainHomeView.swift + +import Charts +import SwiftUI + +struct MainHomeView: View { + let bgChart: LineChartView + let bgChartFull: LineChartView + @ObservedObject var infoManager: InfoManager + @ObservedObject var statsModel: StatsDisplayModel + + @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph + @ObservedObject var showStats = Storage.shared.showStats + @ObservedObject var hideInfoTable = Storage.shared.hideInfoTable + @ObservedObject var smallGraphHeight = Storage.shared.smallGraphHeight + @ObservedObject var url = Storage.shared.url + @ObservedObject var graphTimeZoneEnabled = Storage.shared.graphTimeZoneEnabled + @ObservedObject var graphTimeZoneIdentifier = Storage.shared.graphTimeZoneIdentifier + + var onRefresh: (() -> Void)? + var onStatsTap: (() -> Void)? + + private var timeZoneOverride: String? { + guard graphTimeZoneEnabled.value, + let tz = TimeZone(identifier: graphTimeZoneIdentifier.value) + else { return nil } + return tz.identifier + } + + private var isNightscoutEnabled: Bool { + !url.value.isEmpty + } + + var body: some View { + VStack(spacing: 8) { + // Top section: BG display + info table + HStack(spacing: 10) { + BGDisplayView(onRefresh: onRefresh) + + if isNightscoutEnabled && !hideInfoTable.value { + InfoTableView(infoManager: infoManager, timeZoneOverride: timeZoneOverride) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .frame(minWidth: 160, maxWidth: 250) + .overlay( + Rectangle() + .fill(Color(UIColor.darkGray)) + .frame(width: 2), + alignment: .leading + ) + } + } + .fixedSize(horizontal: false, vertical: true) + + // Main chart (fills remaining space) + LineChartWrapper(chartView: bgChart) + + // Small overview chart + if showSmallGraph.value { + LineChartWrapper(chartView: bgChartFull) + .frame(height: CGFloat(smallGraphHeight.value)) + } + + // Statistics + if showStats.value { + StatsDisplayView(model: statsModel, onTap: onStatsTap) + } + } + .padding(8) + } +} diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 6abea5ab4..105873f1c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -23,34 +23,30 @@ private struct APNSCredentialSnapshot: Equatable { let lfKeyId: String } -class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { - var isPresentedAsModal: Bool = false - - @IBOutlet var BGText: UILabel! - @IBOutlet var DeltaText: UILabel! - @IBOutlet var DirectionText: UILabel! - @IBOutlet var BGChart: LineChartView! - @IBOutlet var BGChartFull: LineChartView! - @IBOutlet var MinAgoText: UILabel! - @IBOutlet var infoTable: UITableView! - @IBOutlet var Console: UITableViewCell! - @IBOutlet var DragBar: UIImageView! - @IBOutlet var PredictionLabel: UILabel! - @IBOutlet var LoopStatusLabel: UILabel! - @IBOutlet var statsPieChart: PieChartView! - @IBOutlet var statsLowPercent: UILabel! - @IBOutlet var statsInRangePercent: UILabel! - @IBOutlet var statsHighPercent: UILabel! - @IBOutlet var statsAvgBG: UILabel! - @IBOutlet var statsEstA1CTitle: UILabel! - @IBOutlet var statsEstA1C: UILabel! - @IBOutlet var statsStdDevTitle: UILabel! - @IBOutlet var statsStdDev: UILabel! - @IBOutlet var serverText: UILabel! - @IBOutlet var statsView: UIView! - @IBOutlet var smallGraphHeightConstraint: NSLayoutConstraint! - var refreshScrollView: UIScrollView! - var refreshControl: UIRefreshControl! +class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificationCenterDelegate { + /// The single, long-lived MainViewController that owns the app's data + /// pipeline (scheduleAllTasks). Held strongly so it stays alive — and the + /// engine keeps running — regardless of which tabs are visible or whether + /// Home has been opened. Created once via bootstrap() on first foreground. + private(set) static var shared: MainViewController? + + /// Creates and force-loads the shared instance if it does not yet exist. + /// loadViewIfNeeded() triggers viewDidLoad, which starts scheduleAllTasks. + /// Idempotent and main-thread only. Called from MainTabView on appear so + /// the engine runs even when Home lives in the Menu rather than a tab. + static func bootstrap() { + guard shared == nil else { return } + let vc = MainViewController() + shared = vc + vc.loadViewIfNeeded() + } + + var BGChart: LineChartView! + var BGChartFull: LineChartView! + var statsDisplayModel = StatsDisplayModel() + + /// The hosting controller's view — hidden during loading / first-time setup. + private var mainContentView: UIView! // Setup buttons for first-time configuration private var setupNightscoutButton: UIButton! @@ -77,6 +73,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele var profileManager = ProfileManager.shared var bgData: [ShareGlucoseData] = [] + var yesterdayBGData: [ShareGlucoseData] = [] // readings already shifted +24h for the comparison overlay var basalProfile: [basalProfileStruct] = [] var basalData: [basalGraphStruct] = [] var basalScheduleData: [basalGraphStruct] = [] @@ -137,7 +134,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let contactImageUpdater = ContactImageUpdater() private var cancellables = Set() - private var isViewHierarchyReady = false // Loading state management private var loadingOverlay: UIView? @@ -149,8 +145,58 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele ] private var loadingTimeoutTimer: Timer? + // MARK: - Programmatic UI Setup + + private func setupUI() { + view.backgroundColor = .systemBackground + + BGChart = LineChartView() + BGChart.backgroundColor = .systemBackground + + BGChartFull = LineChartView() + BGChartFull.backgroundColor = .systemBackground + + infoManager = InfoManager() + + let mainView = MainHomeView( + bgChart: BGChart, + bgChartFull: BGChartFull, + infoManager: infoManager, + statsModel: statsDisplayModel, + onRefresh: { [weak self] in self?.refresh() }, + onStatsTap: { [weak self] in self?.statsViewTapped() } + ) + let hosting = UIHostingController(rootView: mainView) + // Exclude the keyboard from the hosting controller's safe area. Home has + // no text input, but a stale keyboard frame replayed on foregrounding can + // otherwise compress the layout until a rotation recomputes the safe area. + hosting.safeAreaRegions = .container + hosting.view.translatesAutoresizingMaskIntoConstraints = false + hosting.view.backgroundColor = .clear + + addChild(hosting) + view.addSubview(hosting.view) + let safeArea = view.safeAreaLayoutGuide + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: safeArea.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor), + ]) + hosting.didMove(toParent: self) + mainContentView = hosting.view + } + override func viewDidLoad() { super.viewDidLoad() + // Adopt the singleton only if it is not already set (normally bootstrap() + // has set it to this same instance). Guarding prevents a stray instance + // from displacing the long-lived engine and spawning a second pipeline. + if MainViewController.shared == nil { + MainViewController.shared = self + } + + setupUI() loadDebugData() @@ -160,29 +206,13 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() - infoTable.rowHeight = 21 - infoTable.dataSource = self - infoTable.tableFooterView = UIView(frame: .zero) - infoTable.bounces = false - infoTable.addBorder(toSide: .Left, withColor: UIColor.darkGray.cgColor, andThickness: 2) - - infoManager = InfoManager(tableView: infoTable) - - smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value) - view.layoutIfNeeded() - let shareUserName = Storage.shared.shareUserName.value let sharePassword = Storage.shared.sharePassword.value let shareServer = Storage.shared.shareServer.value == "US" ?KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue dexShare = ShareClient(username: shareUserName, password: sharePassword, shareServer: shareServer) - // setup show/hide small graph and stats + // setup show/hide graphs (first-time setup check) updateGraphVisibility() - statsView.isHidden = !Storage.shared.showStats.value - - // Tap on stats view to open full statistics screen - let statsTap = UITapGestureRecognizer(target: self, action: #selector(statsViewTapped)) - statsView.addGestureRecognizer(statsTap) BGChart.delegate = self BGChartFull.delegate = self @@ -198,6 +228,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // when runMigrationsIfNeeded() is called. This catches migrations deferred by a // background BGAppRefreshTask launch in Before-First-Unlock state. notificationCenter.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) + // Posted by AppDelegate after Storage.reloadAll has refreshed every StorageValue + // following a BFU launch. If we're alive when this fires, our scheduled tasks + // were set up with BFU defaults (url='') and need to be redone. + notificationCenter.addObserver(self, selector: #selector(handleBFUReloadCompleted), name: .bfuReloadCompleted, object: nil) #if !targetEnvironment(macCatalyst) notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil) @@ -213,60 +247,19 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele showHideNSDetails() scheduleAllTasks() + setupNightscoutSocket() - // Set up refreshScrollView for BGText - refreshScrollView = UIScrollView() - refreshScrollView.translatesAutoresizingMaskIntoConstraints = false - refreshScrollView.alwaysBounceVertical = true - view.addSubview(refreshScrollView) - - NSLayoutConstraint.activate([ - refreshScrollView.leadingAnchor.constraint(equalTo: BGText.leadingAnchor), - refreshScrollView.trailingAnchor.constraint(equalTo: BGText.trailingAnchor), - refreshScrollView.topAnchor.constraint(equalTo: BGText.topAnchor), - refreshScrollView.bottomAnchor.constraint(equalTo: BGText.bottomAnchor), - ]) - - refreshControl = UIRefreshControl() - refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) - refreshScrollView.addSubview(refreshControl) - refreshScrollView.alwaysBounceVertical = true - - refreshScrollView.delegate = self NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil) - Observable.shared.bgText.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] newValue in - self?.BGText.text = newValue - } - .store(in: &cancellables) - - Observable.shared.directionText.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] newValue in - self?.DirectionText.text = newValue - } - .store(in: &cancellables) - - Observable.shared.deltaText.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] newValue in - self?.DeltaText.text = newValue - } - .store(in: &cancellables) - /// When an alarm is triggered, go to the snoozer tab Observable.shared.currentAlarm.$value .receive(on: DispatchQueue.main) .compactMap { $0 } - .sink { [weak self] _ in - guard let self = self, - let tabBarController = self.tabBarController, - let vcs = tabBarController.viewControllers, !vcs.isEmpty, - let snoozerIndex = self.getSnoozerTabIndex(), - snoozerIndex < vcs.count else { return } - tabBarController.selectedIndex = snoozerIndex + .sink { _ in + let orderedItems = Storage.shared.orderedTabBarItems() + if let index = orderedItems.firstIndex(of: .snoozer) { + Observable.shared.selectedTabIndex.value = index + } } .store(in: &cancellables) @@ -285,13 +278,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Storage.shared.showStats.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.statsView.isHidden = !Storage.shared.showStats.value - } - .store(in: &cancellables) - Publishers.MergeMany( Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.useIFCC.$value.map { _ in () }.eraseToAnyPublisher(), @@ -312,13 +298,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Storage.shared.showSmallGraph.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateGraphVisibility() - } - .store(in: &cancellables) - Storage.shared.screenlockSwitchState.$value .receive(on: DispatchQueue.main) .sink { newValue in @@ -333,20 +312,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Storage.shared.graphTimeZoneEnabled.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.infoTable.reloadData() - } - .store(in: &cancellables) - - Storage.shared.graphTimeZoneIdentifier.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.infoTable.reloadData() - } - .store(in: &cancellables) - Storage.shared.speakBG.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -354,26 +319,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - // Observe all tab position changes with debouncing to handle batch updates - Publishers.MergeMany( - Storage.shared.homePosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.alarmsPosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.remotePosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.nightscoutPosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.snoozerPosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.statisticsPosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.treatmentsPosition.$value.map { _ in () }.eraseToAnyPublisher() - ) - .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - self?.setupTabBar() - } - .store(in: &cancellables) - Storage.shared.url.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.updateNightscoutTabState() self?.checkAndShowImportButtonIfNeeded() } .store(in: &cancellables) @@ -433,8 +381,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Check if current remote type is invalid for the device let shouldReset = (currentRemoteType == .loopAPNS && !isLoopDevice) || - (currentRemoteType == .trc && !isTrioDevice) || - (currentRemoteType == .nightscout && !isTrioDevice) + (currentRemoteType == .trc && !isTrioDevice) if shouldReset { Storage.shared.remoteType.value = .none @@ -458,13 +405,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele updateQuickActions() - // Delay initial tab setup to ensure view hierarchy is ready - // This prevents crashes when trying to modify tabs during viewWillAppear - DispatchQueue.main.async { [weak self] in - self?.isViewHierarchyReady = true - self?.setupTabBar() - } - speechSynthesizer.delegate = self // Check configuration and show appropriate UI @@ -577,199 +517,22 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } } - private func setupTabBar() { - guard isViewHierarchyReady else { return } - - guard !isPresentedAsModal else { return } - - var tbc = tabBarController - if tbc == nil { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController as? UITabBarController - { - tbc = rootVC - } - } - - guard let tabBarController = tbc else { return } - - // If settings modal is presented, skip rebuild - it will happen when settings is dismissed - if tabBarController.presentedViewController != nil { - return - } - - rebuildTabs(tabBarController: tabBarController) - } - - /// Static method to rebuild tabs from anywhere in the app - /// This is useful when the MainViewController instance may not be in the tab bar - static func rebuildTabsIfNeeded() { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let tabBarController = window.rootViewController as? UITabBarController - else { return } - - let previousSelectedIndex = tabBarController.selectedIndex - - let storyboard = UIStoryboard(name: "Main", bundle: nil) - var viewControllers: [UIViewController] = [] - - let orderedItems = Storage.shared.orderedTabBarItems() - - for (index, item) in orderedItems.prefix(4).enumerated() { - let position = TabPosition.customizablePositions[index] - if let vc = createViewControllerStatic(for: item, position: position, storyboard: storyboard) { - viewControllers.append(vc) - } - } - - // Preserve existing Menu nav controller to keep its push stack intact - let existingMenuNav = (tabBarController.viewControllers ?? []).first(where: { - $0.tabBarItem.title == "Menu" - }) - if let menuNav = existingMenuNav { - menuNav.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: 4) - viewControllers.append(menuNav) - } else { - viewControllers.append(Self.makeMenuViewController(tag: 4)) - } - - if let presented = tabBarController.presentedViewController { - presented.dismiss(animated: false) { - tabBarController.setViewControllers(viewControllers, animated: false) - guard !viewControllers.isEmpty else { return } - let targetIndex = min(previousSelectedIndex, viewControllers.count - 1) - tabBarController.selectedIndex = targetIndex - } - } else { - tabBarController.setViewControllers(viewControllers, animated: false) - guard !viewControllers.isEmpty else { return } - let targetIndex = min(previousSelectedIndex, viewControllers.count - 1) - tabBarController.selectedIndex = targetIndex - } - } - - /// Static helper to create view controllers - private static func createViewControllerStatic(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? { - let tag = position.tabIndex ?? 0 - - switch item { - case .home: - guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else { - return nil - } - mainVC.tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: item.icon), tag: tag) - return mainVC - - case .alarms: - let vc = storyboard.instantiateViewController(withIdentifier: "AlarmViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .remote: - let vc = storyboard.instantiateViewController(withIdentifier: "RemoteViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .nightscout: - let vc = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .snoozer: - let vc = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .treatments: - let treatmentsVC = UIHostingController(rootView: TreatmentsView()) - treatmentsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return treatmentsVC - - case .stats: - let statsVC = UIHostingController(rootView: AggregatedStatsContentView(mainViewController: nil)) - let navController = UINavigationController(rootViewController: statsVC) - navController.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return navController - } - } - - private func rebuildTabs(tabBarController: UITabBarController) { - let previousSelectedIndex = tabBarController.selectedIndex - - let storyboard = UIStoryboard(name: "Main", bundle: nil) - var viewControllers: [UIViewController] = [] - - let orderedItems = Storage.shared.orderedTabBarItems() - - for (index, item) in orderedItems.prefix(4).enumerated() { - let position = TabPosition.customizablePositions[index] - if let vc = createViewController(for: item, position: position, storyboard: storyboard) { - viewControllers.append(vc) - } - } - - // Preserve existing Menu nav controller to keep its push stack intact - let existingMenuNav = (tabBarController.viewControllers ?? []).first(where: { - $0.tabBarItem.title == "Menu" - }) - if let menuNav = existingMenuNav { - menuNav.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: 4) - viewControllers.append(menuNav) - } else { - viewControllers.append(Self.makeMenuViewController(tag: 4)) - } - - tabBarController.setViewControllers(viewControllers, animated: false) - - guard !viewControllers.isEmpty else { return } - let targetIndex = min(previousSelectedIndex, viewControllers.count - 1) - tabBarController.selectedIndex = targetIndex - - updateNightscoutTabState() - } - @objc private func navigateOnLAForeground() { - guard let tabBarController = tabBarController, - let vcs = tabBarController.viewControllers, !vcs.isEmpty else { return } - - let targetIndex: Int + let orderedItems = Storage.shared.orderedTabBarItems() if Observable.shared.currentAlarm.value != nil, - let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count + let snoozerIndex = orderedItems.firstIndex(of: .snoozer) { - targetIndex = snoozerIndex - } else { - targetIndex = 0 - } - - if let presented = tabBarController.presentedViewController { - presented.dismiss(animated: false) { - tabBarController.selectedIndex = targetIndex - } + Observable.shared.selectedTabIndex.value = snoozerIndex } else { - tabBarController.selectedIndex = targetIndex + Observable.shared.selectedTabIndex.value = 0 } } - private func getSnoozerTabIndex() -> Int? { - guard let tabBarController = tabBarController, - let viewControllers = tabBarController.viewControllers else { return nil } - - for (index, vc) in viewControllers.enumerated() { - if let _ = vc as? SnoozerViewController { - return index - } - } - - return nil - } - @objc private func statsViewTapped() { #if !targetEnvironment(macCatalyst) - let position = Storage.shared.position(for: .stats).normalized - if position != .menu, let tabIndex = position.tabIndex, let tbc = tabBarController { - tbc.selectedIndex = tabIndex + let orderedItems = Storage.shared.orderedTabBarItems() + if let statsIndex = orderedItems.firstIndex(of: .stats) { + Observable.shared.selectedTabIndex.value = statsIndex return } #endif @@ -781,94 +544,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele present(hostingController, animated: true) } - private func createViewController(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? { - let tag = position.tabIndex ?? 0 - - switch item { - case .home: - tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: item.icon), tag: tag) - return self - - case .alarms: - let vc = storyboard.instantiateViewController(withIdentifier: "AlarmViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .remote: - let vc = storyboard.instantiateViewController(withIdentifier: "RemoteViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .nightscout: - let vc = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .snoozer: - let vc = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .treatments: - let treatmentsVC = UIHostingController(rootView: TreatmentsView()) - treatmentsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return treatmentsVC - - case .stats: - let statsVC = UIHostingController(rootView: AggregatedStatsContentView(mainViewController: self)) - let navController = UINavigationController(rootViewController: statsVC) - navController.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return navController - } - } - - private static func makeMenuViewController(tag: Int) -> UIViewController { - let menuVC = MoreMenuViewController() - let navController = UINavigationController(rootViewController: menuVC) - navController.navigationBar.prefersLargeTitles = true - navController.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: tag) - navController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - return navController - } - - private func createComingSoonViewController(title: String, icon: String) -> UIViewController { - let vc = UIViewController() - vc.view.backgroundColor = .systemBackground - - let stackView = UIStackView() - stackView.axis = .vertical - stackView.alignment = .center - stackView.spacing = 16 - stackView.translatesAutoresizingMaskIntoConstraints = false - - let imageView = UIImageView(image: UIImage(systemName: icon)) - imageView.tintColor = .secondaryLabel - imageView.contentMode = .scaleAspectFit - imageView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: 60), - imageView.heightAnchor.constraint(equalToConstant: 60), - ]) - - let titleLabel = UILabel() - titleLabel.text = title - titleLabel.font = .preferredFont(forTextStyle: .title1) - titleLabel.textColor = .label - - stackView.addArrangedSubview(imageView) - stackView.addArrangedSubview(titleLabel) - - vc.view.addSubview(stackView) - NSLayoutConstraint.activate([ - stackView.centerXAnchor.constraint(equalTo: vc.view.centerXAnchor), - stackView.centerYAnchor.constraint(equalTo: vc.view.centerYAnchor), - ]) - - vc.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - return vc - } - // Update the Home Screen Quick Action for toggling the "Speak BG" feature based on the current speakBG setting. func updateQuickActions() { let iconName = Storage.shared.speakBG.value ? "pause.circle.fill" : "play.circle.fill" @@ -883,7 +558,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } deinit { - NotificationCenter.default.removeObserver(self, name: NSNotification.Name("refresh"), object: nil) + NotificationCenter.default.removeObserver(self) } // Clean all timers and start new ones when refreshing @@ -913,98 +588,31 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } } - MinAgoText.text = "Refreshing" Observable.shared.minAgoText.value = "Refreshing" scheduleAllTasks() + NightscoutSocketManager.shared.connectIfNeeded() currentCage = nil currentSage = nil currentIage = nil - refreshControl.endRefreshing() - } - - // Scroll down BGText when refreshing - func scrollViewDidScroll(_ scrollView: UIScrollView) { - if scrollView == refreshScrollView { - let yOffset = scrollView.contentOffset.y - if yOffset < 0 { - BGText.transform = CGAffineTransform(translationX: 0, y: -yOffset) - } else { - BGText.transform = CGAffineTransform.identity - } - } } - override func viewWillAppear(_: Bool) { + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value - infoTable.reloadData() if Observable.shared.chartSettingsChanged.value { updateBGGraphSettings() - - smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value) - view.layoutIfNeeded() - Observable.shared.chartSettingsChanged.value = false } } - private var timeZoneOverrideInfoValue: String? { - guard Storage.shared.graphTimeZoneEnabled.value, - let overrideTimeZone = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value) - else { - return nil - } - - return overrideTimeZone.identifier - } - - // Info Table Functions - func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { - guard let infoManager = infoManager else { - return 0 - } - let overrideRowCount = timeZoneOverrideInfoValue == nil ? 0 : 1 - return infoManager.numberOfRows() + overrideRowCount - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "LabelCell", for: indexPath) - - if indexPath.row == 0, let timeZoneOverrideInfoValue { - cell.textLabel?.text = "Time Zone" - cell.detailTextLabel?.text = timeZoneOverrideInfoValue - return cell - } - - let adjustedIndexPath: IndexPath - if timeZoneOverrideInfoValue != nil { - adjustedIndexPath = IndexPath(row: indexPath.row - 1, section: indexPath.section) - } else { - adjustedIndexPath = indexPath - } - - if let values = infoManager.dataForIndexPath(adjustedIndexPath) { - cell.textLabel?.text = values.name - cell.detailTextLabel?.text = values.value - } else { - cell.textLabel?.text = "" - cell.detailTextLabel?.text = "" - } - - return cell - } - @objc func appMovedToBackground() { // Allow screen to turn off UIApplication.shared.isIdleTimerDisabled = false // We want to always come back to the home screen - if let tabBarController = tabBarController, - let vcs = tabBarController.viewControllers, !vcs.isEmpty - { - tabBarController.selectedIndex = 0 - } + Observable.shared.selectedTabIndex.value = 0 if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.startBackgroundTask() @@ -1014,6 +622,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele if Storage.shared.backgroundRefreshType.value != .none { BackgroundAlertManager.shared.startBackgroundAlert() } + + NightscoutSocketManager.shared.disconnect() } // Migrations must only run when UserDefaults is accessible (i.e. after first unlock). @@ -1082,6 +692,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele Storage.shared.migrateStep8() Storage.shared.migrationStep.value = 8 } + + if Storage.shared.migrationStep.value < 9 { + Storage.shared.migrateStep9() + Storage.shared.migrationStep.value = 9 + } } @objc func appDidBecomeActive() { @@ -1091,28 +706,21 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele runMigrationsIfNeeded() } + @objc func handleBFUReloadCompleted() { + // Show the loading overlay so the user sees feedback during the 2-5s + // while tasks re-run with the now-correct credentials. Tasks scheduled + // before reload used url='' and rescheduled themselves 60s out — reset + // them so they run within their normal 2-5s initial delay. + loadingStates = ["bg": false, "profile": false, "deviceStatus": false] + isInitialLoad = true + setupLoadingState() + showLoadingOverlay() + scheduleAllTasks() + } + @objc func appCameToForeground() { - // If the app was cold-launched in Before-First-Unlock state (e.g. by BGAppRefreshTask - // after a reboot), all StorageValues were cached from encrypted UserDefaults and hold - // their defaults. Reload everything from disk now that the device is unlocked, firing - // Combine observers only for values that actually changed. - LogManager.shared.log(category: .general, message: "appCameToForeground: needsBFUReload=\(Storage.shared.needsBFUReload), url='\(LogRedactor.url(Storage.shared.url.value))'") - if Storage.shared.needsBFUReload { - Storage.shared.needsBFUReload = false - LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues") - Storage.shared.reloadAll() - LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(LogRedactor.url(Storage.shared.url.value))'") - // Show the loading overlay so the user sees feedback during the 2-5s - // while tasks re-run with the now-correct credentials. - loadingStates = ["bg": false, "profile": false, "deviceStatus": false] - isInitialLoad = true - setupLoadingState() - showLoadingOverlay() - // Tasks were scheduled during BFU viewDidLoad with url="" — they fired, found no - // data source, and rescheduled themselves 60s out. Reset them now so they run - // within their normal 2-5s initial delay using the now-correct credentials. - scheduleAllTasks() - } + // BFU recovery (Storage.reloadAll) is driven by AppDelegate; this controller + // reacts via .bfuReloadCompleted in handleBFUReloadCompleted() above. // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value @@ -1126,6 +734,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } TaskScheduler.shared.checkTasksNow() + NightscoutSocketManager.shared.connectIfNeeded() checkAndNotifyVersionStatus() checkAppExpirationStatus() @@ -1184,8 +793,25 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } } - @objc override func viewDidAppear(_: Bool) { + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) showHideNSDetails() + + // Re-render the graph every time Home appears. The single MainViewController + // is reused across tab/Menu hosts, so its chart view gets re-parented when + // the user switches to Home or moves it between the tab bar and the Menu — + // and Charts does not redraw itself after a re-parent. Rebuilding here keeps + // the curve visible regardless of how Home was reached. It also recovers the + // one-shot firstGraphLoad zoom that is skipped while the view is off-screen + // (force-loaded headless when Home lives in the Menu). Deferred one runloop + // so the nested SwiftUI chart has its final frame; updateBGGraph's own + // width>0 guard skips the initial zoom until it does. + if !bgData.isEmpty { + DispatchQueue.main.async { [weak self] in + self?.updateBGGraph() + } + } + #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.startFromCurrentState() #endif @@ -1198,42 +824,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele return String(format: "%02d:%02d", hours, minutes) } - private func updateNightscoutTabState() { - guard let tabBarController = tabBarController, - let viewControllers = tabBarController.viewControllers else { return } - - let isNightscoutEnabled = !Storage.shared.url.value.isEmpty - - for (index, vc) in viewControllers.enumerated() { - if vc is NightscoutViewController { - tabBarController.tabBar.items?[index].isEnabled = isNightscoutEnabled - } - } - } - func showHideNSDetails() { - if isInitialLoad || !isDataSourceConfigured() { - return - } - - var isHidden = false - if !IsNightscoutEnabled() { - isHidden = true - } - - LoopStatusLabel.isHidden = isHidden - if IsNotLooping { - PredictionLabel.isHidden = true - } else { - PredictionLabel.isHidden = isHidden - } - infoTable.isHidden = isHidden - - if Storage.shared.hideInfoTable.value { - infoTable.isHidden = true - } - - updateNightscoutTabState() + // Info table visibility is handled reactively by MainHomeView. } func updateBadge(val: Int) { @@ -1248,30 +840,18 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele func updateBGTextAppearance() { if bgData.count > 0 { let latestBG = bgData[bgData.count - 1].sgv - var color = NSUIColor.label if Storage.shared.colorBGText.value { let thresholds = UnitSettingsStore.shared.effectiveThresholds() if Double(latestBG) >= thresholds.high { - color = NSUIColor.systemYellow Observable.shared.bgTextColor.value = .yellow } else if Double(latestBG) <= thresholds.low { - color = NSUIColor.systemRed Observable.shared.bgTextColor.value = .red } else { - color = NSUIColor.systemGreen Observable.shared.bgTextColor.value = .green } } else { Observable.shared.bgTextColor.value = .primary } - - BGText.textColor = color - - if latestBG <= globalVariables.minDisplayGlucose || latestBG >= globalVariables.maxDisplayGlucose { - BGText.font = UIFont.systemFont(ofSize: 65, weight: .black) - } else { - BGText.font = UIFont.systemFont(ofSize: 85, weight: .black) - } } } @@ -1293,25 +873,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Update this view controller overrideUserInterfaceStyle = style - // Update the tab bar controller (affects all tabs) - tabBarController?.overrideUserInterfaceStyle = style - // Update the window (affects the entire app including modals) window.overrideUserInterfaceStyle = style } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - // When system appearance changes and we're in "System" mode, notify all observers - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - // Post notification so other view controllers can update if needed - NotificationCenter.default.post(name: .appearanceDidChange, object: nil) - } - } - func bgDirectionGraphic(_ value: String) -> String { let // graphics:[String:String]=["Flat":"\u{2192}","DoubleUp":"\u{21C8}","SingleUp":"\u{2191}","FortyFiveUp":"\u{2197}\u{FE0E}","FortyFiveDown":"\u{2198}\u{FE0E}","SingleDown":"\u{2193}","DoubleDown":"\u{21CA}","None":"-","NOT COMPUTABLE":"-","RATE OUT OF RANGE":"-"] graphics: [String: String] = ["Flat": "→", "DoubleUp": "↑↑", "SingleUp": "↑", "FortyFiveUp": "↗", "FortyFiveDown": "↘︎", "SingleDown": "↓", "DoubleDown": "↓↓", "None": "-", "NONE": "-", "NOT COMPUTABLE": "-", "RATE OUT OF RANGE": "-", "": "-"] @@ -1648,15 +1213,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele present(navController, animated: true) } - private func hideGraphs() { - BGChart.isHidden = true - BGChartFull.isHidden = true - } - - private func showGraphs() { - updateGraphVisibility() - } - private func makeCloseBarButtonItem() -> UIBarButtonItem { let button = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(dismissModal)) button.tintColor = .systemBlue @@ -1664,61 +1220,18 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } private func hideAllDataUI() { - // Hide graphs - BGChart.isHidden = true - BGChartFull.isHidden = true - - // Hide BG display elements - BGText.isHidden = true - DeltaText.isHidden = true - DirectionText.isHidden = true - MinAgoText.isHidden = true - serverText.isHidden = true - - // Hide info table and stats - infoTable.isHidden = true - statsView.isHidden = true - - // Hide loop status and prediction - LoopStatusLabel.isHidden = true - PredictionLabel.isHidden = true + mainContentView?.isHidden = true } private func showAllDataUI() { - // Show BG display elements - BGText.isHidden = false - DeltaText.isHidden = false - DirectionText.isHidden = false - MinAgoText.isHidden = false - serverText.isHidden = false - - // Show graphs based on settings - updateGraphVisibility() - - // Show/hide info table and stats based on user settings - let isNightscoutEnabled = IsNightscoutEnabled() - if isNightscoutEnabled { - infoTable.isHidden = Storage.shared.hideInfoTable.value - LoopStatusLabel.isHidden = false - PredictionLabel.isHidden = IsNotLooping - } else { - infoTable.isHidden = true - LoopStatusLabel.isHidden = true - PredictionLabel.isHidden = true - } - - statsView.isHidden = !Storage.shared.showStats.value + mainContentView?.isHidden = false } private func updateGraphVisibility() { - let isFirstTimeSetup = !isDataSourceConfigured() - - if isFirstTimeSetup { - BGChart.isHidden = true - BGChartFull.isHidden = true - } else { - BGChart.isHidden = false - BGChartFull.isHidden = !Storage.shared.showSmallGraph.value + // Graph and component visibility is handled reactively by MainHomeView. + // This method now only manages the overall content visibility for first-time setup. + if !isDataSourceConfigured() { + mainContentView?.isHidden = true } } diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift new file mode 100644 index 000000000..ac389a122 --- /dev/null +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -0,0 +1,275 @@ +// LoopFollow +// MoreMenuView.swift + +import SwiftUI +import UIKit + +struct MoreMenuView: View { + @State private var pendingRoute: MenuRoute? + @State private var latestVersion: String? + @State private var versionTint: Color = .secondary + @State private var alertTitle = "" + @State private var alertMessage = "" + @State private var showAlert = false + @State private var currentVersion: String = AppVersionManager().version() + + var body: some View { + List { + // Settings + Section { + NavigationLink(value: SettingsRoute.settings) { + Label("Settings", systemImage: "gearshape") + } + } + + // Features + Section("Features") { + ForEach(TabItem.featureOrder) { item in + FullRowButton(showsChevron: true) { + let tabs = Storage.shared.orderedTabBarItems() + if let tabIndex = tabs.firstIndex(of: item) { + Observable.shared.selectedTabIndex.value = tabIndex + } else { + pendingRoute = MenuRoute(item) + } + } label: { + Label(item.displayName, systemImage: item.icon) + } + } + } + + // Logging + Section("Logging") { + FullRowButton(showsChevron: true) { pendingRoute = .log } label: { + Label("View Log", systemImage: "doc.text.magnifyingglass") + } + + FullRowButton { shareLogs() } label: { + Label("Share Logs", systemImage: "square.and.arrow.up") + } + } + + // Support & Community + Section("Support & Community") { + Link(destination: URL(string: "https://loopfollowdocs.org/")!) { + HStack { + Label("LoopFollow Docs", systemImage: "book") + Spacer() + Image(systemName: "arrow.up.right.square") + .foregroundStyle(.tertiary) + } + } + + Link(destination: URL(string: "https://discord.gg/KQgk3gzuYU")!) { + HStack { + Label("Loop and Learn Discord", systemImage: "bubble.left.and.bubble.right") + Spacer() + Image(systemName: "arrow.up.right.square") + .foregroundStyle(.tertiary) + } + } + + Link(destination: URL(string: "https://www.facebook.com/groups/loopfollowlnl")!) { + HStack { + Label("LoopFollow Facebook Group", systemImage: "person.2.fill") + Spacer() + Image(systemName: "arrow.up.right.square") + .foregroundStyle(.tertiary) + } + } + } + + // Build Information + Section("Build Information") { + buildInfoRow(title: "Version", value: currentVersion, color: versionTint) + buildInfoRow(title: "Latest version", value: latestVersion ?? "Fetching…", color: .secondary) + + let build = BuildDetails.default + if !(build.isMacApp() || build.isSimulatorBuild()) { + buildInfoRow( + title: build.expirationHeaderString, + value: dateTimeUtils.formattedDate(from: build.calculateExpirationDate()), + color: .secondary + ) + } + + buildInfoRow(title: "Built", value: dateTimeUtils.formattedDate(from: build.buildDate()), color: .secondary) + buildInfoRow(title: "Branch", value: build.branchAndSha, color: .secondary) + } + } + .listStyle(.insetGrouped) + .navigationTitle("Menu") + .navigationBarTitleDisplayMode(.large) + .task { + await fetchVersionInfo() + } + .alert(alertTitle, isPresented: $showAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(alertMessage) + } + .navigationDestination(for: SettingsRoute.self) { $0.destination } + .navigationDestination( + isPresented: Binding( + get: { pendingRoute != nil }, + set: { if !$0 { pendingRoute = nil } } + ) + ) { + if let route = pendingRoute { + route.destination + } + } + } + + // MARK: - Helpers + + private func buildInfoRow(title: String, value: String, color: Color) -> some View { + HStack { + Text(title) + Spacer() + Text(value) + .foregroundStyle(color) + } + } + + private func shareLogs() { + let files = LogManager.shared.logFilesForTodayAndYesterday() + guard !files.isEmpty else { + alertTitle = "No Logs Available" + alertMessage = "There are no logs to share." + showAlert = true + return + } + + let noticeView = ShareLogNoticeView( + onCancel: { + UIApplication.shared.topMost?.dismiss(animated: true) + }, + onShare: { noticeText in + let presenter = UIApplication.shared.topMost + presenter?.dismiss(animated: true) { + presentLogShareSheet(noticeText: noticeText, logFiles: files) + } + } + ) + let host = UIHostingController(rootView: noticeView) + host.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle + host.modalPresentationStyle = .formSheet + UIApplication.shared.topMost?.present(host, animated: true) + } + + private func presentLogShareSheet(noticeText: String, logFiles: [URL]) { + var items: [Any] = logFiles + if let noticeURL = writeShareNoticeFile(text: noticeText) { + items.insert(noticeURL, at: 0) + } + let avc = UIActivityViewController(activityItems: items, applicationActivities: nil) + UIApplication.shared.topMost?.present(avc, animated: true) + } + + private func writeShareNoticeFile(text: String) -> URL? { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HHmm" + let timestamp = formatter.string(from: Date()) + + let version = AppVersionManager().version() + let branchAndSha = BuildDetails.default.branchAndSha + + let body = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "(no description provided)" + : text + + let contents = """ + LoopFollow Log Share Notice + Date: \(ISO8601DateFormatter().string(from: Date())) + App version: \(version) (\(branchAndSha)) + + User description: + \(body) + """ + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("ShareNotice_\(timestamp).txt") + do { + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } catch { + LogManager.shared.log(category: .general, message: "Failed to write share notice file: \(error)") + return nil + } + } + + private func fetchVersionInfo() async { + let mgr = AppVersionManager() + let (latest, newer, blacklisted) = await mgr.checkForNewVersionAsync() + latestVersion = latest ?? "Unknown" + + versionTint = blacklisted ? .red + : newer ? .orange + : latest == currentVersion ? .green + : .secondary + } +} + +// MARK: – Full-row tappable button + +private struct FullRowButton: View { + var showsChevron: Bool = false + let action: () -> Void + @ViewBuilder let label: () -> Label + + var body: some View { + Button(action: action) { + HStack { + label() + Spacer(minLength: 0) + if showsChevron { + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +// MARK: – Menu routing + +enum MenuRoute: Hashable { + case home + case alarms + case remote + case nightscout + case snoozer + case treatments + case stats + case log + + init(_ item: TabItem) { + switch item { + case .home: self = .home + case .alarms: self = .alarms + case .remote: self = .remote + case .nightscout: self = .nightscout + case .snoozer: self = .snoozer + case .treatments: self = .treatments + case .stats: self = .stats + } + } + + @ViewBuilder + var destination: some View { + switch self { + case .home: HomeContentView(isModal: true) + case .alarms: AlarmsContainerView(embedsInNavigationStack: false) + case .remote: RemoteContentView() + case .nightscout: NightscoutContentView() + case .snoozer: SnoozerView() + case .treatments: TreatmentsView() + case .stats: AggregatedStatsContentView(mainViewController: MainViewController.shared) + case .log: LogView() + } + } +} diff --git a/LoopFollow/ViewControllers/MoreMenuViewController.swift b/LoopFollow/ViewControllers/MoreMenuViewController.swift deleted file mode 100644 index 2693cde33..000000000 --- a/LoopFollow/ViewControllers/MoreMenuViewController.swift +++ /dev/null @@ -1,466 +0,0 @@ -// LoopFollow -// MoreMenuViewController.swift - -import Combine -import SwiftUI -import UIKit - -class MoreMenuViewController: UIViewController { - private var tableView: UITableView! - private var cancellables = Set() - private var fallbackMainViewController: MainViewController? - var needsTabRebuild = false - - // Build Information state - private var latestVersion: String? - private var versionTint: UIColor = .secondaryLabel - - // MARK: - Menu models - - enum MenuItemStyle { - case navigation - case action - case detail(String, UIColor) - case externalLink - } - - struct MenuItem { - let title: String - let icon: String - let style: MenuItemStyle - let action: () -> Void - - init(title: String, icon: String, style: MenuItemStyle = .navigation, action: @escaping () -> Void = {}) { - self.title = title - self.icon = icon - self.style = style - self.action = action - } - } - - struct MenuSection { - let title: String? - let items: [MenuItem] - } - - private var menuSections: [MenuSection] = [] - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .systemBackground - navigationItem.title = "Menu" - navigationItem.largeTitleDisplayMode = .always - navigationItem.backButtonDisplayMode = .minimal - - // Apply appearance mode - overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - // Listen for appearance setting changes - Storage.shared.appearanceMode.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] mode in - self?.overrideUserInterfaceStyle = mode.userInterfaceStyle - } - .store(in: &cancellables) - - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - .store(in: &cancellables) - - setupTableView() - updateMenuItems() - - Task { [weak self] in - await self?.fetchVersionInfo() - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationController?.setNavigationBarHidden(false, animated: animated) - navigationController?.navigationBar.prefersLargeTitles = true - updateMenuItems() - tableView.reloadData() - Observable.shared.settingsPath.set(NavigationPath()) - - if needsTabRebuild { - needsTabRebuild = false - MainViewController.rebuildTabsIfNeeded() - } - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - } - - // MARK: - Setup - - private func setupTableView() { - tableView = UITableView(frame: view.bounds, style: .insetGrouped) - tableView.translatesAutoresizingMaskIntoConstraints = false - tableView.delegate = self - tableView.dataSource = self - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - tableView.contentInsetAdjustmentBehavior = .automatic - - view.addSubview(tableView) - - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - } - - // MARK: - Menu construction - - private func updateMenuItems() { - let build = BuildDetails.default - let ver = AppVersionManager().version() - - var sections: [MenuSection] = [ - MenuSection(title: nil, items: [ - MenuItem(title: "Settings", icon: "gearshape") { [weak self] in - self?.openSettings() - }, - ]), - ] - - sections.append( - MenuSection(title: "Features", items: TabItem.featureOrder.map { item in - MenuItem(title: item.displayName, icon: item.icon) { [weak self] in - self?.openItem(item) - } - }) - ) - - sections.append(contentsOf: [ - MenuSection(title: "Logging", items: [ - MenuItem(title: "View Log", icon: "doc.text.magnifyingglass") { [weak self] in - self?.openViewLog() - }, - MenuItem(title: "Share Logs", icon: "square.and.arrow.up", style: .action) { [weak self] in - self?.shareLogs() - }, - ]), - - // Section 3: Support & Community - MenuSection(title: "Support & Community", items: [ - MenuItem(title: "LoopFollow Docs", icon: "book", style: .externalLink) { [weak self] in - self?.openURL("https://loopfollowdocs.org/") - }, - MenuItem(title: "Loop and Learn Discord", icon: "bubble.left.and.bubble.right", style: .externalLink) { [weak self] in - self?.openURL("https://discord.gg/KQgk3gzuYU") - }, - MenuItem(title: "LoopFollow Facebook Group", icon: "person.2.fill", style: .externalLink) { [weak self] in - self?.openURL("https://www.facebook.com/groups/loopfollowlnl") - }, - ]), - - // Section 4: Build Information - MenuSection(title: "Build Information", items: { - var items: [MenuItem] = [ - MenuItem(title: "Version", icon: "", style: .detail(ver, versionTint)), - MenuItem(title: "Latest version", icon: "", style: .detail(latestVersion ?? "Fetching…", .secondaryLabel)), - ] - - if !(build.isMacApp() || build.isSimulatorBuild()) { - items.append(MenuItem( - title: build.expirationHeaderString, - icon: "", - style: .detail(dateTimeUtils.formattedDate(from: build.calculateExpirationDate()), .secondaryLabel) - )) - } - - items.append(MenuItem( - title: "Built", - icon: "", - style: .detail(dateTimeUtils.formattedDate(from: build.buildDate()), .secondaryLabel) - )) - items.append(MenuItem( - title: "Branch", - icon: "", - style: .detail(build.branchAndSha, .secondaryLabel) - )) - - return items - }()), - ]) - - menuSections = sections - } - - // MARK: - Version fetching - - private func fetchVersionInfo() async { - let mgr = AppVersionManager() - let (latest, newer, blacklisted) = await mgr.checkForNewVersionAsync() - latestVersion = latest ?? "Unknown" - - let current = mgr.version() - versionTint = blacklisted ? .systemRed - : newer ? .systemOrange - : latest == current ? .systemGreen - : .secondaryLabel - - await MainActor.run { - updateMenuItems() - tableView.reloadData() - } - } - - // MARK: - Navigation - - private func openItem(_ item: TabItem) { - // If the item is in the tab bar, switch to it - if let tabVC = tabBarController, - let index = (tabVC.viewControllers ?? []).firstIndex(where: { $0.tabBarItem.title == item.displayName }) - { - tabVC.selectedIndex = index - return - } - // Otherwise push onto navigation stack - pushItem(item) - } - - private func pushItem(_ item: TabItem) { - switch item { - case .home: - openHome() - case .alarms: - openAlarmsConfig() - case .remote: - openRemote() - case .nightscout: - openNightscout() - case .snoozer: - openSnoozer() - case .treatments: - openTreatments() - case .stats: - openAggregatedStats() - } - } - - private func openSettings() { - needsTabRebuild = true - let settingsView = SettingsMenuView(onBack: { [weak self] in - self?.navigationController?.popViewController(animated: true) - }) - let settingsVC = NavBarHidingHostingController(rootView: settingsView) - settingsVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(settingsVC, animated: true) - } - - private func openAlarmsConfig() { - let alarmsView = AlarmsContainerView(onBack: { [weak self] in - self?.navigationController?.popViewController(animated: true) - }) - let alarmsVC = NavBarHidingHostingController(rootView: alarmsView) - alarmsVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(alarmsVC, animated: true) - } - - private func openRemote() { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let remoteVC = storyboard.instantiateViewController(withIdentifier: "RemoteViewController") - remoteVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(remoteVC, animated: true) - remoteVC.navigationItem.largeTitleDisplayMode = .never - } - - private func openNightscout() { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let nightscoutVC = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController") - nightscoutVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(nightscoutVC, animated: true) - nightscoutVC.navigationItem.largeTitleDisplayMode = .never - } - - private func openSnoozer() { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let snoozerVC = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController") - snoozerVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(snoozerVC, animated: true) - snoozerVC.navigationItem.largeTitleDisplayMode = .never - } - - private func openTreatments() { - let treatmentsView = TreatmentsView(onBack: { [weak self] in - self?.navigationController?.popViewController(animated: true) - }) - let treatmentsVC = NavBarHidingHostingController(rootView: treatmentsView) - treatmentsVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(treatmentsVC, animated: true) - } - - private func openAggregatedStats() { - guard let mainVC = getMainViewController() else { - presentSimpleAlert(title: "Error", message: "Unable to access data") - return - } - - let statsView = AggregatedStatsContentView(mainViewController: mainVC) - let statsVC = UIHostingController(rootView: statsView) - statsVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(statsVC, animated: true) - } - - private func openHome() { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else { return } - mainVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - mainVC.navigationItem.largeTitleDisplayMode = .never - navigationController?.pushViewController(mainVC, animated: true) - } - - private func openViewLog() { - let logView = LogView(onBack: { [weak self] in - self?.navigationController?.popViewController(animated: true) - }) - let logVC = NavBarHidingHostingController(rootView: logView) - logVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(logVC, animated: true) - } - - private func shareLogs() { - let files = LogManager.shared.logFilesForTodayAndYesterday() - guard !files.isEmpty else { - presentSimpleAlert(title: "No Logs Available", message: "There are no logs to share.") - return - } - let avc = UIActivityViewController(activityItems: files, applicationActivities: nil) - present(avc, animated: true) - } - - private func openURL(_ urlString: String) { - if let url = URL(string: urlString) { - UIApplication.shared.open(url) - } - } - - // MARK: - Helpers - - private func getMainViewController() -> MainViewController? { - guard let tabBarController = tabBarController else { return nil } - - for vc in tabBarController.viewControllers ?? [] { - if let mainVC = vc as? MainViewController { - return mainVC - } - if let navVC = vc as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - } - - if let fallbackMainViewController { - return fallbackMainViewController - } - - let storyboard = UIStoryboard(name: "Main", bundle: nil) - guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else { - return nil - } - - mainVC.isPresentedAsModal = true - fallbackMainViewController = mainVC - return mainVC - } -} - -// MARK: - NavBarHidingHostingController - -/// A UIHostingController subclass that hides the UIKit navigation bar. -/// Used for SwiftUI views that have their own NavigationStack/NavigationView -/// to prevent double navigation bars when pushed onto a UINavigationController. -private class NavBarHidingHostingController: UIHostingController { - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationController?.setNavigationBarHidden(true, animated: animated) - } -} - -// MARK: - UITableViewDataSource & UITableViewDelegate - -extension MoreMenuViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in _: UITableView) -> Int { - return menuSections.count - } - - func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { - return menuSections[section].items.count - } - - func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? { - return menuSections[section].title - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - let item = menuSections[indexPath.section].items[indexPath.row] - - switch item.style { - case let .detail(value, color): - var config = UIListContentConfiguration.valueCell() - config.text = item.title - config.secondaryText = value - config.secondaryTextProperties.color = color - cell.contentConfiguration = config - cell.accessoryType = .none - cell.selectionStyle = .none - - case .externalLink: - var config = cell.defaultContentConfiguration() - config.text = item.title - config.image = UIImage(systemName: item.icon) - cell.contentConfiguration = config - let linkImage = UIImageView(image: UIImage(systemName: "arrow.up.right.square")) - linkImage.tintColor = .tertiaryLabel - cell.accessoryView = linkImage - cell.selectionStyle = .default - - case .navigation: - var config = cell.defaultContentConfiguration() - config.text = item.title - config.image = UIImage(systemName: item.icon) - cell.contentConfiguration = config - cell.accessoryView = nil - cell.accessoryType = .disclosureIndicator - cell.selectionStyle = .default - - case .action: - var config = cell.defaultContentConfiguration() - config.text = item.title - config.image = UIImage(systemName: item.icon) - cell.contentConfiguration = config - cell.accessoryView = nil - cell.accessoryType = .none - cell.selectionStyle = .default - } - - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - let item = menuSections[indexPath.section].items[indexPath.row] - if case .detail = item.style { return } - item.action() - } -} diff --git a/LoopFollow/ViewControllers/NightScoutViewController.swift b/LoopFollow/ViewControllers/NightScoutViewController.swift index 96a4b7f47..8dd1fc4b2 100644 --- a/LoopFollow/ViewControllers/NightScoutViewController.swift +++ b/LoopFollow/ViewControllers/NightScoutViewController.swift @@ -6,13 +6,29 @@ import UIKit import WebKit class NightscoutViewController: UIViewController { - @IBOutlet var webView: WKWebView! + var webView: WKWebView! private var cancellables = Set() override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = .systemBackground overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle + // Create WKWebView programmatically + let webConfiguration = WKWebViewConfiguration() + webConfiguration.mediaTypesRequiringUserActionForPlayback = [] + webView = WKWebView(frame: .zero, configuration: webConfiguration) + webView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(webView) + + let safeArea = view.safeAreaLayoutGuide + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: safeArea.topAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), + ]) + // Listen for appearance setting changes Storage.shared.appearanceMode.$value .receive(on: DispatchQueue.main) @@ -21,14 +37,6 @@ class NightscoutViewController: UIViewController { } .store(in: &cancellables) - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - .store(in: &cancellables) - var url = Storage.shared.url.value let token = Storage.shared.token.value @@ -57,16 +65,6 @@ class NightscoutViewController: UIViewController { sender.endRefreshing() } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - } - func clearWebCache() { let dataStore = WKWebsiteDataStore.default() let cacheTypes = Set([WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache]) diff --git a/LoopFollow/ViewControllers/NightscoutContentView.swift b/LoopFollow/ViewControllers/NightscoutContentView.swift new file mode 100644 index 000000000..fd724bd19 --- /dev/null +++ b/LoopFollow/ViewControllers/NightscoutContentView.swift @@ -0,0 +1,38 @@ +// LoopFollow +// NightscoutContentView.swift + +import SwiftUI + +struct NightscoutContentView: View { + @ObservedObject private var url = Storage.shared.url + @ObservedObject private var token = Storage.shared.token + + var body: some View { + if url.value.isEmpty { + VStack(spacing: 16) { + Image(systemName: "network") + .font(.system(size: 60)) + .foregroundStyle(.secondary) + Text("Please enter your Nightscout URL in Settings.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } else { + NightscoutWebView() + // NightscoutViewController loads the page once in viewDidLoad, + // so recreate it when the URL or token changes. + .id(url.value + "|" + token.value) + } + } +} + +private struct NightscoutWebView: UIViewControllerRepresentable { + func makeUIViewController(context _: Context) -> NightscoutViewController { + NightscoutViewController() + } + + func updateUIViewController(_ uiViewController: NightscoutViewController, context _: Context) { + uiViewController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle + } +} diff --git a/LoopFollow/ViewControllers/SettingsViewController.swift b/LoopFollow/ViewControllers/SettingsViewController.swift deleted file mode 100644 index b27e32171..000000000 --- a/LoopFollow/ViewControllers/SettingsViewController.swift +++ /dev/null @@ -1,73 +0,0 @@ -// LoopFollow -// SettingsViewController.swift - -import Combine -import SwiftUI -import UIKit - -final class SettingsViewController: UIViewController { - // MARK: Stored properties - - private var host: UIHostingController! - private var cancellables = Set() - - // MARK: Life-cycle - - override func viewDidLoad() { - super.viewDidLoad() - - // Build SwiftUI menu - host = UIHostingController(rootView: SettingsMenuView()) - - // Appearance mode override - host.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - // Listen for appearance setting changes - Storage.shared.appearanceMode.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] mode in - self?.updateAppearance(mode) - } - .store(in: &cancellables) - - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateAppearance(Storage.shared.appearanceMode.value) - } - .store(in: &cancellables) - - // Embed - addChild(host) - host.view.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(host.view) - NSLayoutConstraint.activate([ - host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - host.view.topAnchor.constraint(equalTo: view.topAnchor), - host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - host.didMove(toParent: self) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - Observable.shared.settingsPath.set(NavigationPath()) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - updateAppearance(.system) - } - } - - private func updateAppearance(_ mode: AppearanceMode) { - host.overrideUserInterfaceStyle = mode.userInterfaceStyle - } -} diff --git a/Podfile b/Podfile index f8c2df3b2..1229b37d6 100644 --- a/Podfile +++ b/Podfile @@ -33,4 +33,36 @@ post_install do |installer| File.write(transformer, code.sub(original, patched)) end end + + # Inject a privacy manifest into the Charts framework (ITMS-91061). + # Charts 4.1.0 ships no PrivacyInfo.xcprivacy; it collects no data, performs + # no tracking, and uses no required-reason APIs, so this is a negative + # declaration. Re-applied here because `pod install` regenerates the project. + charts_manifest = <<~XML + + + + + \tNSPrivacyTracking + \t + \tNSPrivacyTrackingDomains + \t + \tNSPrivacyCollectedDataTypes + \t + \tNSPrivacyAccessedAPITypes + \t + + + XML + + manifest_path = installer.sandbox.root + 'Charts/PrivacyInfo.xcprivacy' + File.write(manifest_path, charts_manifest) + + charts_target = installer.pods_project.targets.find { |t| t.name == 'Charts' } + if charts_target + file_ref = installer.pods_project.new_file(manifest_path.to_s) + already_added = charts_target.resources_build_phase.files_references.include?(file_ref) + charts_target.resources_build_phase.add_file_reference(file_ref) unless already_added + installer.pods_project.save + end end diff --git a/Pods/Charts/PrivacyInfo.xcprivacy b/Pods/Charts/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..e08a130bc --- /dev/null +++ b/Pods/Charts/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/Pods/Pods.xcodeproj/project.pbxproj b/Pods/Pods.xcodeproj/project.pbxproj index 6a2b71889..489d852f5 100644 --- a/Pods/Pods.xcodeproj/project.pbxproj +++ b/Pods/Pods.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + BEEF11110000000000000002 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = BEEF11110000000000000001 /* PrivacyInfo.xcprivacy */; }; 0031CEAD857A1BA158C0D2BE1870A438 /* Animator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB10D8BF2F0C2E9B18C2130751D75984 /* Animator.swift */; }; 00351B69906B07C1950B97C40F65FABE /* RadarChartData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51F17D0D2858BA593E783811A2CF7E6 /* RadarChartData.swift */; }; 00E171A24B8B214FDC447BED6BB62C31 /* BarLineScatterCandleBubbleChartDataSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90B940BBEE17272D0FA9602CB3F1E1 /* BarLineScatterCandleBubbleChartDataSet.swift */; }; @@ -220,6 +221,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + BEEF11110000000000000001 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 0047677618FCD329E548FDDC0454A8AC /* LineChartView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LineChartView.swift; path = Source/Charts/Charts/LineChartView.swift; sourceTree = ""; }; 0055C3AB0AE0067E0AF50DA569843398 /* BarChartView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BarChartView.swift; path = Source/Charts/Charts/BarChartView.swift; sourceTree = ""; }; 0187F74F0F2E1ADE4F205C203F88DE85 /* RadarChartView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RadarChartView.swift; path = Source/Charts/Charts/RadarChartView.swift; sourceTree = ""; }; @@ -759,6 +761,7 @@ E5EBEB4E3DD769CF77FBA0FB4DD5A204 /* Charts */ = { isa = PBXGroup; children = ( + BEEF11110000000000000001 /* PrivacyInfo.xcprivacy */, 64FE9452040001E25B5A3EB8D58108DB /* Core */, AEDEF3B82F3D135001FB2D1778DAD53F /* Support Files */, ); @@ -944,6 +947,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + BEEF11110000000000000002 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/release.sh b/release.sh index fdffd9eae..3ba67c2fb 100755 --- a/release.sh +++ b/release.sh @@ -24,11 +24,6 @@ echo_run() { echo "+ $*"; "$@"; } push_cmds=() queue_push() { push_cmds+=("git -C \"$(pwd)\" $*"); echo "+ [queued] (in $(pwd)) git $*"; } -queue_push_tag () { - local tag="$1" - queue_push push origin "refs/tags/$tag" -} - update_follower () { local DIR="$1" echo; echo "🔄 Updating $DIR …" @@ -97,32 +92,30 @@ esac echo "🔢 Bumping version: $old_ver → $new_ver" -# --- switch to dev branch ---- +# --- switch to dev so the release branch is cut from latest dev ---- echo_run git switch "$DEV_BRANCH" echo_run git fetch echo_run git pull -# --- update version number ---- +# --- create release branch from dev's tip ---- +RELEASE_BRANCH="release/v${new_ver}" +echo_run git switch -c "$RELEASE_BRANCH" + +# --- bump version on the release branch ---- sed -i '' "s/${MARKETING_KEY}[[:space:]]*=.*/${MARKETING_KEY} = ${new_ver}/" "$VERSION_FILE" echo_run git diff "$VERSION_FILE"; pause echo_run git commit -m "update version to ${new_ver} [skip ci]" "$VERSION_FILE" -echo "💻 Build & test dev branch now."; pause -queue_push push origin "$DEV_BRANCH" +echo "💻 Build & test release branch now."; pause +queue_push push origin "$RELEASE_BRANCH" -# --- create a patch --------------------------- +# --- create a patch from main..release branch (includes the bump) ----- mkdir -p "$PATCH_DIR" PATCH_FILE="${PATCH_DIR}/LF_diff_${old_ver}_to_${new_ver}.patch" -git diff -M --binary "$MAIN_BRANCH" "$DEV_BRANCH" \ +git diff -M --binary "$MAIN_BRANCH" "$RELEASE_BRANCH" \ > "$PATCH_FILE" -# --- merge dev into main for new release -echo_run git switch "$MAIN_BRANCH" -echo_run git merge "$DEV_BRANCH" -echo "💻 Build & test main branch now."; pause -queue_push push origin "$MAIN_BRANCH" - cd .. update_follower "$SECOND_DIR" update_follower "$THIRD_DIR" @@ -136,24 +129,39 @@ pause cd ${PRIMARY_ABS_PATH} # ---------- push queue ---------- -echo; echo "🚀 Ready to tag and push changes upstream." +echo; echo "🚀 Ready to push changes upstream and open the release PR." echo_run git log --oneline -2 -read -rp "▶▶ Ready to tag? (y/n): " confirm -if [[ $confirm =~ ^[Yy]$ ]]; then - git tag -a "v${new_ver}" -m "v${new_ver}" - queue_push_tag "v${new_ver}" - echo_run git log --oneline -2 -else - echo "🚫 tag skipped, can add later" -fi - read -rp "▶▶ Push everything now? (y/n): " confirm if [[ $confirm =~ ^[Yy]$ ]]; then for cmd in "${push_cmds[@]}"; do echo "+ $cmd"; bash -c "$cmd"; done echo "🎉 All pushes completed." - echo; echo "🎉 All repos updated to v${new_ver} (local)." - echo "👉 Remember to create a GitHub release for tag v${new_ver}." + + echo; echo "📝 Opening sync PR ${RELEASE_BRANCH} → ${DEV_BRANCH} …" + gh pr create \ + --base "$DEV_BRANCH" \ + --head "$RELEASE_BRANCH" \ + --title "Sync v${new_ver} version bump to dev" \ + --body "Syncs the v${new_ver} version bump from the release branch back to \`dev\` so subsequent auto-bumps on \`dev\` continue from the released minor. + +\`auto_version_dev\` detects that \`Config.xcconfig\` was changed in this push and skips re-bumping. + +⚠️ **Use rebase-merge** (not squash or merge-commit) so \`dev\` and \`main\` end up at the same commit SHA after the release." + + echo; echo "📝 Opening release PR ${RELEASE_BRANCH} → ${MAIN_BRANCH} …" + gh pr create \ + --base "$MAIN_BRANCH" \ + --head "$RELEASE_BRANCH" \ + --title "Release v${new_ver}" \ + --body "Release v${new_ver}. + +Merging this PR triggers the tagging workflow, which creates tag \`v${new_ver}\` from \`LOOP_FOLLOW_MARKETING_VERSION\` in \`Config.xcconfig\`. + +⚠️ **Use rebase-merge** (not squash or merge-commit) so \`dev\` and \`main\` end up at the same commit SHA after the release." + + echo; echo "🎉 All repos updated to v${new_ver} (local). Release PRs opened (sync → dev, release → main)." + echo "👉 Review and merge both PRs — the tag will be created automatically by .github/workflows/tag_on_main.yml." + echo "👉 Remember to create a GitHub release for tag v${new_ver} after the tag exists." else echo "🚫 Pushes skipped. Run manually if needed:"; printf ' %s\n' "${push_cmds[@]}" echo "🚫 Release not completed, pushes to GitHub were skipped"