diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..457360f8 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.44.0" +} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7e42569e..3ec84ab6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,12 @@ name: Flutter build and test -on: push +# CI validation runs only on pull requests into the integration branches. +# Feature-branch pushes don't trigger anything; nothing runs on push to a +# branch, so there are no duplicate runs. +on: + pull_request: + branches: [develop, test, master] env: FLUTTER_FOLDER: '$HOME/flutter' @@ -30,7 +35,10 @@ jobs: uses: flutter-actions/setup-flutter@v4 with: channel: stable - version: 3.32.1 + version: 3.44.0 + + - name: Clean + run: flutter clean - name: Install dependencies run: flutter pub get diff --git a/.github/workflows/create_release_on_tag.yml b/.github/workflows/create_release_on_tag.yml index 61407422..a530bae4 100644 --- a/.github/workflows/create_release_on_tag.yml +++ b/.github/workflows/create_release_on_tag.yml @@ -1,7 +1,7 @@ on: push: tags: - - 'v*' + - "v*" name: Create Release @@ -10,34 +10,28 @@ jobs: name: Create Release and Upload APK runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@master + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: "adopt" # See 'Supported distributions' for available options + java-version: "17" - - name: Set up JDK 1.8 - uses: actions/setup-java@v4 + - name: Set up Flutter + uses: subosito/flutter-action@v2 with: - distribution: 'adopt' # See 'Supported distributions' for available options - java-version: '17' + flutter-version: "3.44.0" + channel: stable + cache: true + pub-cache: true - - name: Cache Flutter - id: cache-flutter - uses: actions/cache@v3 + - name: Cache Gradle + id: cache-gradle + uses: actions/cache@v4 with: path: | - flutter - ~/.pub-cache - key: flutter-${{ hashFiles('**/pubspec.lock') }} - - - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} - name: Install Flutter - run: git clone https://github.com/flutter/flutter.git --depth 1 -b stable $FOLDER - - - name: Add Flutter to PATH - run: echo "$GITHUB_WORKSPACE/flutter/bin" >> $GITHUB_PATH - - - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} - name: Install dependencies - run: flutter pub get + ~/.gradle/caches + ~/.gradle/wrapper/ + key: gradle-ubuntu-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Configure Keystore run: | @@ -59,20 +53,35 @@ jobs: run: | flutter --version pwd - + - name: Build APK run: | flutter clean flutter pub get - flutter pub upgrade flutter build apk --release --no-tree-shake-icons - name: Create Release and upload apk uses: underwindfall/create-release-with-debugapk@v2.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: + with: tag_name: ${{ github.ref }} asset_path: build/app/outputs/flutter-apk/app-release.apk asset_name: carp-studies-app-${{ github.ref_name }}.apk asset_content_type: application/vnd.android.package-archive + + # Setup Ruby/Bundler so fastlane is available + - name: Setup Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: false + working-directory: android + + # Production release to Google Play runs only on tags (like the iOS + # App Store workflow on Xcode Cloud). + - if: startsWith(github.ref, 'refs/tags/') + run: fastlane release + env: + PLAY_STORE_JSON_KEY: PLAY_STORE_CONFIG.json + working-directory: android diff --git a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml index db9f3d43..48eeb2ce 100644 --- a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml +++ b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml @@ -2,112 +2,62 @@ name: Upload to Google Store run-name: ${{github.actor}} is pushing to branch and deploying to Google Store on: - push: - branches: - - master - - test + push: + branches: + - test jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: - distribution: 'adopt' # See 'Supported distributions' for available options - java-version: '17' - - - name: Cache Flutter - id: cache-flutter - uses: actions/cache@v3 - with: - path: | - flutter - ~/.pub-cache - key: flutter-${{ hashFiles('**/pubspec.lock') }} - - - name: Cache Gradle - id: cache-gradle - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper/ - key: gradle-ubuntu-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - - - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} - name: Install Flutter - run: git clone https://github.com/flutter/flutter.git --depth 1 -b stable $FOLDER - - - name: Add Flutter to PATH - run: echo "$GITHUB_WORKSPACE/flutter/bin" >> $GITHUB_PATH - - - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} - name: Install dependencies - run: flutter pub get - - # Setup Ruby, Bundler, and Gemfile dependencies - - name: Setup Fastlane - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.3' - bundler-cache: false - working-directory: android - - - name: Extract last commit message - id: extract-commit-message - run: | - COMMIT_MESSAGE=$(git log -1 --pretty=%B | tr -d '\n') - COMMIT_MESSAGE="${COMMIT_MESSAGE}" - echo "Commit Message: $COMMIT_MESSAGE" - echo "commit_message=$COMMIT_MESSAGE" >> $GITHUB_ENV - - - name: Extract version code and version name from pubspec.yaml - id: extract-version - run: | - # Navigate to the root directory (if not already there) - pwd - cd $GITHUB_WORKSPACE - - # Extract version name and version code from pubspec.yaml - VERSION_NAME=$(grep 'version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1) - VERSION_CODE=$(grep 'version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f2) - - echo "Version Name: $VERSION_NAME" - echo "Version Code: $VERSION_CODE" - - echo "version_name=$VERSION_NAME" >> $GITHUB_ENV - echo "version_code=$VERSION_CODE" >> $GITHUB_ENV - - - name: Create or update changelog file - run: | - pwd - mkdir -p ./android/fastlane/metadata/android/en-GB/changelogs - echo "${{ env.commit_message }}" > "./android/fastlane/metadata/android/en-GB/changelogs/${{ env.version_code }}.txt" - - - name: Configure Keystore - run: | - echo "$PLAY_STORE_UPLOAD_KEY" | base64 --decode > app/upload-keystore.jks - echo "$PLAY_STORE_CONFIG_JSON" > PLAY_STORE_CONFIG.json - echo "keyPassword=$KEYSTORE_KEY_PASSWORD" >> key.properties - echo "storePassword=$KEYSTORE_STORE_PASSWORD" >> key.properties - echo "keyAlias=$KEYSTORE_KEY_ALIAS" >> key.properties - echo "storeFile=upload-keystore.jks" >> key.properties - env: - PLAY_STORE_UPLOAD_KEY: ${{ secrets.PLAY_STORE_UPLOAD_KEY }} - KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} - KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} - KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} - PLAY_STORE_CONFIG_JSON: ${{secrets.PLAY_STORE_CONFIG_JSON}} - working-directory: android - - - if: github.ref == 'refs/heads/master' - run: fastlane release - env: - PLAY_STORE_JSON_KEY: PLAY_STORE_CONFIG.json - working-directory: android - - - if: github.ref == 'refs/heads/test' - run: fastlane test - env: - PLAY_STORE_JSON_KEY: PLAY_STORE_CONFIG.json - working-directory: android \ No newline at end of file + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: "adopt" # See 'Supported distributions' for available options + java-version: "17" + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.44.0" + channel: stable + cache: true + pub-cache: true + + - name: Cache Gradle + id: cache-gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper/ + key: gradle-ubuntu-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + + # Setup Ruby, Bundler, and Gemfile dependencies + - name: Setup Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: false + working-directory: android + + - name: Configure Keystore + run: | + echo "$PLAY_STORE_UPLOAD_KEY" | base64 --decode > app/upload-keystore.jks + echo "$PLAY_STORE_CONFIG_JSON" > PLAY_STORE_CONFIG.json + echo "keyPassword=$KEYSTORE_KEY_PASSWORD" >> key.properties + echo "storePassword=$KEYSTORE_STORE_PASSWORD" >> key.properties + echo "keyAlias=$KEYSTORE_KEY_ALIAS" >> key.properties + echo "storeFile=upload-keystore.jks" >> key.properties + env: + PLAY_STORE_UPLOAD_KEY: ${{ secrets.PLAY_STORE_UPLOAD_KEY }} + KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} + KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} + KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} + PLAY_STORE_CONFIG_JSON: ${{secrets.PLAY_STORE_CONFIG_JSON}} + working-directory: android + + - if: github.ref == 'refs/heads/test' + run: fastlane test + env: + PLAY_STORE_JSON_KEY: PLAY_STORE_CONFIG.json + working-directory: android diff --git a/.gitignore b/.gitignore index 02b68057..701e4dce 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,15 @@ android/fastlane/report.xml # CARP related carp/carpspec.yaml -assets/carp/ -assets/carp/lang/_da.json -assets/carp/lang/_en.json +# Ignore local study config but keep the asset dirs (via .gitkeep) so release +# builds don't fail on missing/empty asset directories. +assets/carp/* +!assets/carp/lang/ +assets/carp/lang/* +!assets/carp/lang/.gitkeep +!assets/carp/resources/ +assets/carp/resources/* +!assets/carp/resources/.gitkeep + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 570ef544..a3b7a4a7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,18 +27,44 @@ ], "flutterMode": "debug" }, + { + // profile mode using the DEV CAWS instance - for measuring jank/perf + "name": "DEV (profile) - carp-studies-app", + "request": "launch", + "type": "dart", + "toolArgs": [ + "--dart-define", + "deployment-mode=dev", + "--dart-define", + "debug-level=info" + ], + "flutterMode": "profile" + }, + { + // release mode using the DEV CAWS instance - production-speed sanity check + "name": "DEV (release) - carp-studies-app", + "request": "launch", + "type": "dart", + "toolArgs": [ + "--dart-define", + "deployment-mode=dev", + "--dart-define", + "debug-level=info" + ], + "flutterMode": "release" + }, { // debug mode using the TEST CAWS instance - "name": "TEST - carp-studies-app", + "name": "TEST - carp-studies-app (release)", "request": "launch", "type": "dart", "toolArgs": [ "--dart-define", "deployment-mode=test", "--dart-define", - "debug-level=debug" + "debug-level=none" ], - "flutterMode": "debug" + "flutterMode": "release" }, { // debug mode using COMPUTEROME CAWS instance @@ -52,6 +78,22 @@ "debug-level=debug" ], "flutterMode": "debug" + }, + { + // end-to-end join-study flow on a device/simulator (local deployment). + // The test also forces local + debug internally, so it runs correctly + // even without these defines. + "name": "Integration - join study flow (LOCAL)", + "request": "launch", + "type": "dart", + "program": "integration_test/join_study_flow_test.dart", + "toolArgs": [ + "--dart-define", + "deployment-mode=local", + "--dart-define", + "debug-level=debug" + ], + "flutterMode": "debug" } ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1760de8a..80549beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 4.2.0 + +* moving to carp_themes_package instead of research package themes +* partially transitioning from pods to spm +* health connect flow update +* informed consent accepted is now checked via API instead of local settings +* fixing hasSeenBluetoothInstructions bool logic +* small UI changes + ## 4.1.1 - small visual fixes diff --git a/analysis_options.yaml b/analysis_options.yaml index b449163e..6a7acd6c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -3,6 +3,9 @@ include: package:lints/recommended.yaml +formatter: + page_width: 120 + analyzer: exclude: [build/**] language: diff --git a/android/Gemfile.lock b/android/Gemfile.lock index 3d9a5e6f..0e43bde2 100644 --- a/android/Gemfile.lock +++ b/android/Gemfile.lock @@ -1,43 +1,49 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.6) - rexml - addressable (2.8.4) - public_suffix (>= 2.0.2, < 6.0) - artifactory (3.0.15) + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.2.0) - aws-partitions (1.779.0) - aws-sdk-core (3.174.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-eventstream (1.4.0) + aws-partitions (1.1255.0) + aws-sdk-core (3.250.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.66.0) - aws-sdk-core (~> 3, >= 3.174.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.124.0) - aws-sdk-core (~> 3, >= 3.174.0) + logger + aws-sdk-kms (1.128.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.224.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.5.2) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + csv (3.3.5) declarative (0.0.20) - digest-crc (0.6.4) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.100.0) - faraday (1.10.3) + excon (0.112.0) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -49,32 +55,36 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) - faraday_middleware (1.2.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.2.7) - fastlane (2.213.0) - CFPropertyList (>= 2.3, < 4.0.0) + 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.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) - colored + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) @@ -82,134 +92,147 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) 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.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) naturally (~> 2.2) - optparse (~> 0.1.1) + nkf (~> 0.2) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.3) + security (= 0.1.5) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.0.0) + terminal-table (~> 3) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) - xcpretty-travis-formatter (>= 0.0.3) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.43.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.0) + 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 - webrick - 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.19.0) - google-apis-core (>= 0.9.0, < 2.a) - google-cloud-core (1.6.0) - google-cloud-env (~> 1.0) + 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.63.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.3.1) - google-cloud-storage (1.44.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.19.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.5.2) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) - memoist (~> 0.16) + 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) highline (2.0.3) - http-cookie (1.0.5) + http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m jmespath (1.6.2) - json (2.6.3) - jwt (2.7.1) - memoist (0.16.2) - mini_magick (4.12.0) - mini_mime (1.1.2) - multi_json (1.15.0) - multipart-post (2.3.0) - nanaimo (0.3.0) - naturally (2.2.1) - optparse (0.1.1) + 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.21.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.2.0) + optparse (0.8.1) os (1.1.4) - plist (3.7.0) - public_suffix (5.0.1) - rake (13.0.6) + ostruct (0.6.3) + plist (3.7.2) + 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) - rexml (3.2.5) - rouge (2.0.7) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) ruby2_keywords (0.0.5) - rubyzip (2.3.2) - security (0.1.3) - signet (0.17.0) + rubyzip (2.4.1) + security (0.1.5) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) trailblazer-option (0.1.2) tty-cursor (0.7.1) - tty-screen (0.8.1) + tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (1.8.0) - webrick (1.8.1) + unicode-display_width (2.6.0) word_wrap (1.0.0) - xcodeproj (1.22.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) - xcpretty (0.3.0) - rouge (~> 2.0.7) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) PLATFORMS arm64-darwin-22 + arm64-darwin-25 x86_64-linux DEPENDENCIES diff --git a/android/app/build.gradle b/android/app/build.gradle index e70b25af..bc2192c5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -5,7 +5,7 @@ plugins { } def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') +def localPropertiesFile = project.rootProject.file('local.properties') if (localPropertiesFile.exists()) { localPropertiesFile.withReader('UTF-8') { reader -> localProperties.load(reader) @@ -13,7 +13,7 @@ if (localPropertiesFile.exists()) { } def keyProperties = new Properties() -def keyPropertiesFile = rootProject.file('key.properties') +def keyPropertiesFile = project.rootProject.file('key.properties') def signingConfigExists = false if (keyPropertiesFile.exists()) { @@ -23,15 +23,8 @@ if (keyPropertiesFile.exists()) { } } -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') ?: '1' +def flutterVersionName = localProperties.getProperty('flutter.versionName') ?: '1.0' android { namespace "dk.carp.studies_app" @@ -88,10 +81,10 @@ android { debugSymbolLevel 'SYMBOL_TABLE' } if (signingConfigExists) { - logger.error('storeFile found, signing with release build.') + logger.info('storeFile found, signing with release build.') signingConfig signingConfigs.release } else { - logger.error('No storeFile found or null. Skipping signing of release build.') + logger.info('No storeFile found or null. Skipping signing of release build.') signingConfig signingConfigs.debug } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 303b83a4..ea000c7f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -56,8 +56,6 @@ - - @@ -68,29 +66,37 @@ - - - - - + + + + - - - - + + + - + + + - - - - - + - + + + + + + + + + + + + + - - - - - - - - - - - current_production_version_code && version_number > current_internal_version_code - sh "flutter clean" - sh "flutter pub get" - sh "flutter pub upgrade" - sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons" + # Auto-increment: one above the highest code on either track. This mirrors + # the iOS Xcode Cloud CI_BUILD_NUMBER flow, so the build number never needs + # a manual bump in pubspec.yaml. + codes = google_play_track_version_codes(track: 'production') + + google_play_track_version_codes(track: 'internal') + build_number = (codes.map(&:to_i).max || 0) + 1 + build_name = YAML.load_file('../../pubspec.yaml')['version'].split('+').first + UI.message("Building production versionCode #{build_number} (#{build_name})") + + sh "flutter clean" + sh "flutter pub get" + sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons --build-name=#{build_name} --build-number=#{build_number}" + + write_play_changelog(build_number) - upload_to_play_store( - track: 'production', - aab: '../build/app/outputs/bundle/release/app-release.aab', - ) - else - UI.user_error!("Version code must be greater than or equal to the current version code: #{current_production_version_code} and #{current_internal_version_code}") - end - end + upload_to_play_store( + track: 'production', + aab: '../build/app/outputs/bundle/release/app-release.aab', + ) end desc "Upload to internal testing track" lane :test do - current_production_version_code = google_play_track_version_codes(track: 'production').first.to_i - current_internal_version_code = google_play_track_version_codes(track: 'internal').first.to_i - pubspec_path = '../../pubspec.yaml' - pubspec_content = YAML.load_file(pubspec_path) - version_string = pubspec_content['version'] - version_parts = version_string.split('+') + # Auto-increment from the live Play state (see :release for rationale). + codes = google_play_track_version_codes(track: 'production') + + google_play_track_version_codes(track: 'internal') + build_number = (codes.map(&:to_i).max || 0) + 1 + base_version = YAML.load_file('../../pubspec.yaml')['version'].split('+').first + build_name = "#{base_version}-test" + UI.message("Building internal versionCode #{build_number} (#{build_name})") - if version_parts.size == 2 - version_number = version_parts[1].to_i - base_version = version_parts[0] + sh "flutter clean" + sh "flutter pub get" + sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons --dart-define='deployment-mode=test' --build-name=#{build_name} --build-number=#{build_number}" - if version_number > current_production_version_code && version_number > current_internal_version_code - test_version = "#{base_version}-test+#{version_number}" - sh "sed -i 's/^version: .*/version: #{test_version}/' #{pubspec_path}" - sh "flutter clean" - sh "flutter pub get" - sh "flutter pub upgrade" - sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons --dart-define='deployment-mode=test'" + write_play_changelog(build_number) - upload_to_play_store( - track: 'internal', - aab: '../build/app/outputs/bundle/release/app-release.aab', - ) - else - UI.user_error!("Version code must be greater than or equal to the current version code: #{current_production_version_code} and #{current_internal_version_code}") - end - end + upload_to_play_store( + track: 'internal', + aab: '../build/app/outputs/bundle/release/app-release.aab' + ) end -end \ No newline at end of file +end diff --git a/android/gradle.properties b/android/gradle.properties index 7d40b1c3..e7b01d3c 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -3,4 +3,8 @@ org.gradle.caching=true org.gradle.parallel=true org.gradle.daemon=true android.enableJetifier=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 2733ed5d..3ae1e2f1 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/settings.gradle b/android/settings.gradle index 52bd901a..5b46a604 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,9 +18,9 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.2" - id "com.android.application" version "8.9.0" apply false // this is AGP? - id 'com.android.library' version '7.4.2' apply false - id 'org.jetbrains.kotlin.android' version '2.1.0' apply false + id "com.android.application" version "8.11.1" apply false + id 'com.android.library' version '8.11.1' apply false + id 'org.jetbrains.kotlin.android' version '2.2.20' apply false } include ":app", ":mdsflutter", ":mdslib" diff --git a/assets/carp/lang/.gitkeep b/assets/carp/lang/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/assets/carp/resources/.gitkeep b/assets/carp/resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/assets/instructions/apple_health_preview.png b/assets/instructions/apple_health_preview.png new file mode 100644 index 00000000..89113a45 Binary files /dev/null and b/assets/instructions/apple_health_preview.png differ diff --git a/assets/instructions/google_health_connect_preview.png b/assets/instructions/google_health_connect_preview.png new file mode 100644 index 00000000..b0cb0528 Binary files /dev/null and b/assets/instructions/google_health_connect_preview.png differ diff --git a/assets/instructions/health_permission_allow_all.png b/assets/instructions/health_permission_allow_all.png new file mode 100644 index 00000000..7990000a Binary files /dev/null and b/assets/instructions/health_permission_allow_all.png differ diff --git a/assets/lang/da.json b/assets/lang/da.json index e08855bb..61fcabba 100644 --- a/assets/lang/da.json +++ b/assets/lang/da.json @@ -97,9 +97,9 @@ "pages.profile.device_role": "Engedsrolle", "pages.profile.contact": "Kontakt forsker", "pages.profile.privacy": "Fortrolighedspolitik", - "pages.profile.study_website": "Studiw Hjemmeside", + "pages.profile.study_website": "Hjemmeside for studiet", "pages.profile.leave_study": "Forlad studie", - "pages.profile.log_out": "Log ud", + "pages.profile.log_out": "Forlad studie & Log ud", "pages.profile.log_out.confirmation": "Du er ved at forlade dette studie og logge af. Operativsystemet vil åbne en browser for at logge dig ud. Er du sikker?", "pages.profile.leave_study.confirmation": "Du er ved at forlade studiet. Du vil ikke længere deltage i dette studie. Er du sikker?", "announcements": "Annonceringer", @@ -124,6 +124,12 @@ "pages.about.study.privacy": "Fortrolighedspolitik", "pages.about.install_health_connect.title": "Health Connect påkrævet", "pages.about.install_health_connect.description": "Denne app kræver, at Google Health Connect er installeret. Vil du installere det?", + "pages.devices.type.health.instructions.data.title": "Dette studie indsamler sundhedsrelaterede data fra din enhed. For at deltage skal du tillade alle de anmodede tilladelser på næste skærm.", + "pages.devices.type.health.access_denied.title": "Adgang ikke givet", + "pages.devices.type.health.access_denied.message": "Der blev ikke givet adgang. For at tillade det skal du gå til: Indstillinger → Apps → CARP Studies → Tilladelser → Sundhed, fitness og wellness → Tillad alle. Træk derefter ned på enhedsskærmen for at opdatere.", + "pages.devices.type.health.instructions.data.steps": "Skridttælling", + "pages.devices.type.health.instructions.data.heart_rate": "Puls", + "pages.devices.type.health.instructions.data.exercise": "Træningssessioner", "dialog.location.permission": "Lokaliseringstjeneste", "dialog.location.info": "Denne app indsamler lokaliseringsdata.\n\nVi beder dig om at aktivere lokaliseringstjenesten på telefonen ved at tillade sporing af placering 'Altid'. Lokaliseringstjenesten holder appen kørende i baggrunden, hvilket hjælper os med at indsamle data.\n\nVi bruger placeringsdata i vores efterfølgende dataanalyse for at udregne mobilitetsmønstre, såsom hvor meget tid du er hjemme.", "dialog.location.continue": "Fortsæt", @@ -216,5 +222,6 @@ "tasks.participant_data.phone_number.title": "Tilføj telefonnummer", "tasks.participant_data.phone_number.country": "Landekode", "tasks.participant_data.phone_number.phone_number": "Telefonnummer", - "tasks.participant_data.review.title": "Gennemse" + "tasks.participant_data.review.title": "Gennemse", + "pages.home.setup_failed": "Studiet kunne ikke konfigureres. Tjek din internetforbindelse og prøv igen." } \ No newline at end of file diff --git a/assets/lang/en.json b/assets/lang/en.json index aae2fb09..c7355678 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -110,9 +110,9 @@ "pages.profile.device_id": "Device ID", "pages.profile.contact": "Contact researcher", "pages.profile.privacy": "Privacy policy", - "pages.profile.study_website": "Study Website", + "pages.profile.study_website": "Study website", "pages.profile.leave_study": "Leave study", - "pages.profile.log_out": "Log out", + "pages.profile.log_out": "Leave study & Log out", "pages.profile.log_out.confirmation": "You are about to leave this study and log out. The operating system will open a browser to log you out. Are you sure?", "pages.profile.leave_study.confirmation": "You are about to leave the study. You will no longer participate in this study. Are you sure?", "announcements": "Announcements", @@ -211,6 +211,12 @@ "pages.devices.type.health.instructions.page2.android.allow_all": "Allow all", "pages.devices.type.health.instructions.page2.ios.turn_on_all": "Turn On All", "pages.devices.type.health.instructions.page2.allow": "Allow", + "pages.devices.type.health.instructions.data.title": "This study collects health-related data from your device. To take part, please allow all the requested permissions on the next screen.", + "pages.devices.type.health.access_denied.title": "Access not granted", + "pages.devices.type.health.access_denied.message": "Access was not granted. To allow it, go to: Settings → Apps → CARP Studies → Permissions → Health, fitness and wellness → Allow all. Then pull down on the devices screen to refresh.", + "pages.devices.type.health.instructions.data.steps": "Step count", + "pages.devices.type.health.instructions.data.heart_rate": "Heart rate", + "pages.devices.type.health.instructions.data.exercise": "Exercise sessions", "widgets.logoutmessage.title": "Log out", "widgets.logoutmessage.message": "The operating system will open a browser to log you out. Is this okay?", "pages.login.login": "Log in", @@ -243,5 +249,6 @@ "tasks.participant_data.phone_number.title": "Add phone number", "tasks.participant_data.phone_number.country": "Country code", "tasks.participant_data.phone_number.phone_number": "Phone No.", - "tasks.participant_data.review.title": "Review" -} + "tasks.participant_data.review.title": "Review", + "pages.home.setup_failed": "Could not set up the study. Please check your internet connection and try again." +} \ No newline at end of file diff --git a/assets/lang/es.json b/assets/lang/es.json index b4f811b1..da6cfe57 100644 --- a/assets/lang/es.json +++ b/assets/lang/es.json @@ -1,4 +1,11 @@ { + "pages.devices.type.health.instructions.data.title": "Este estudio recopila datos relacionados con la salud de tu dispositivo. Para participar, permite todos los permisos solicitados en la siguiente pantalla.", + "pages.devices.type.health.access_denied.title": "Acceso no concedido", + "pages.devices.type.health.access_denied.message": "No se concedió acceso. Para permitirlo, ve a: Ajustes → Aplicaciones → CARP Studies → Permisos → Salud, actividad física y bienestar → Permitir todo. Luego desliza hacia abajo en la pantalla de dispositivos para actualizar.", + "settings": "Ajustes", + "pages.devices.type.health.instructions.data.steps": "Recuento de pasos", + "pages.devices.type.health.instructions.data.heart_rate": "Frecuencia cardíaca", + "pages.devices.type.health.instructions.data.exercise": "Sesiones de ejercicio", "app_home.nav_bar_item.tasks": "Tareas", "app_home.nav_bar_item.about": "Acerca de", "app_home.nav_bar_item.data": "Datos", @@ -58,8 +65,9 @@ "pages.profile.account_id": "Id Usuario", "pages.profile.contact": "Contactar investigador", "pages.profile.privacy": "Política de privacidad", + "pages.profile.study_website": "Página web del estudio", "pages.profile.leave_study": "Abandonar el estudio", - "pages.profile.log_out": "Cerrar sesión", + "pages.profile.log_out": "Cerrar estudio & sesión", "pages.profile.log_out.confirmation": "Estás a punto de cerrar sesión et abandonar el estudio. ¿Estás seguro?", "pages.profile.leave_study.confirmation": "Estás a punto de abandonar el estudio. Dejarás de participar en el es estudio. ¿Estás seguro?", "announcement": "Anuncio", @@ -120,5 +128,6 @@ "pages.devices.connection.step.start.3": "Si el dispositivo no está en la lista, asegúrese de que esté cargado y encendido. Leer más en las 'Instrucciones'.", "pages.devices.connection.step.confirm.title": "está conectado!", "pages.devices.connection.step.confirm.1": "El", - "pages.devices.connection.step.confirm.2": " ha sido connectado satisfactoriamente al estudio y está listo para empezar a detectar." -} + "pages.devices.connection.step.confirm.2": " ha sido connectado satisfactoriamente al estudio y está listo para empezar a detectar.", + "pages.home.setup_failed": "No se pudo configurar el estudio. Comprueba tu conexión a internet e inténtalo de nuevo." +} \ No newline at end of file diff --git a/carp_study_app.code-workspace b/carp_study_app.code-workspace index 29fcf7e1..a2f866b8 100644 --- a/carp_study_app.code-workspace +++ b/carp_study_app.code-workspace @@ -21,7 +21,7 @@ "dart.debugExternalPackageLibraries": false, "dart.debugSdkLibraries": false, "dart.openDevTools": "never", - "java.configuration.updateBuildConfiguration": "automatic", + "dart.flutterSdkPath": ".fvm/flutter_sdk", }, "extensions": { "recommendations": [ diff --git a/integration_test/join_study_flow_test.dart b/integration_test/join_study_flow_test.dart new file mode 100644 index 00000000..4ffe33c2 --- /dev/null +++ b/integration_test/join_study_flow_test.dart @@ -0,0 +1,206 @@ +// End-to-end test of the join-study flow in LOCAL deployment mode: +// deploy a protocol locally, gate on informed consent, configure the study +// (Sensing initialize -> addStudy -> tryDeployment -> verify deployed), and +// start sensing. +// +// Run on a simulator or device with: +// +// flutter test integration_test --dart-define=deployment-mode=local +import 'package:carp_core/carp_core.dart' hide Smartphone; +import 'package:carp_mobile_sensing/carp_mobile_sensing.dart'; +import 'package:carp_backend/carp_backend.dart'; +import 'package:cognition_package/cognition_package.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:carp_study_app/main.dart'; + +/// Mock the platform channels a simulator cannot serve: OS permission +/// dialogs (which a test cannot answer) and battery hardware (which a +/// simulator does not have). Everything else - deployment, sensing runtime, +/// routing, consent - runs for real. +void mockSimulatorPlatformChannels() { + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + messenger.setMockMethodCallHandler(const MethodChannel('flutter.baseflow.com/permissions/methods'), (call) async { + const granted = 1; + switch (call.method) { + case 'requestPermissions': + return {for (final permission in (call.arguments as List).cast()) permission: granted}; + case 'checkPermissionStatus': + case 'checkServiceStatus': + return granted; + default: + return null; + } + }); + + messenger.setMockMethodCallHandler(const MethodChannel('dexterous.com/flutter/local_notifications'), (call) async { + switch (call.method) { + case 'initialize': + case 'requestPermissions': + return true; + case 'pendingNotificationRequests': + return >[]; + default: + return null; + } + }); + + messenger.setMockMethodCallHandler(const MethodChannel('dev.fluttercommunity.plus/battery'), (call) async { + switch (call.method) { + case 'getBatteryLevel': + return 100; + case 'getBatteryState': + return 'full'; + case 'isInBatterySaveMode': + return false; + default: + return null; + } + }); + + messenger.setMockStreamHandler( + const EventChannel('dev.fluttercommunity.plus/charging'), + MockStreamHandler.inline(onListen: (arguments, events) => events.success('full')), + ); +} + +/// Pump frames until [condition] is true. Used instead of [WidgetTester.pumpAndSettle] +/// since loaders with endless animations never settle. +Future pumpUntil( + WidgetTester tester, + bool Function() condition, { + Duration timeout = const Duration(minutes: 2), + String? reason, +}) async { + final end = DateTime.now().add(timeout); + var lastDump = DateTime.now(); + while (!condition()) { + if (DateTime.now().isAfter(end)) { + fail('pumpUntil timed out${reason != null ? ' waiting for: $reason' : ''} - ${_diagnostics()}'); + } + if (DateTime.now().difference(lastDump) > const Duration(seconds: 5)) { + lastDump = DateTime.now(); + debugPrint('pumpUntil${reason != null ? ' [$reason]' : ''} - ${_diagnostics()}'); + } + await tester.pump(const Duration(milliseconds: 250)); + } +} + +String _diagnostics() => + 'bloc.state=${bloc.state}, hasStudy=${bloc.study.hasStudy}, isDeployed=${bloc.study.isDeployed}, ' + 'isRunning=${bloc.study.isRunning}, ' + 'consentPage=${find.byType(InformedConsentPage).evaluate().isNotEmpty}, ' + 'studyPage=${find.byType(StudyPage).evaluate().isNotEmpty}'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Stop the study after the test so the bloc's periodic timers (message + // polling, view-model persistence) and the user-task subscription are + // cancelled - otherwise the isolate never goes idle and the run hangs + // after "All tests passed!". Also clears the persisted study so the next + // run starts clean. + tearDown(() async { + try { + await bloc.leaveStudy(); + } catch (_) { + // best-effort cleanup - never mask the test result + } + }); + + testWidgets( + 'join study flow: deploy, consent gate, configure, start sensing', + timeout: const Timeout(Duration(minutes: 5)), + (tester) async { + // This exercises the local-mode join flow, so force local deployment and + // debug logging regardless of how the suite is launched - otherwise a run + // without --dart-define=deployment-mode=local defaults to production and + // the test would silently skip. Set before bloc.initialize() reads them. + AppConfig.deploymentMode = DeploymentMode.local; + AppConfig.debugLevel = DebugLevel.debug; + + mockSimulatorPlatformChannels(); + + // Same package initialization as main(). + CarpMobileSensing.ensureInitialized(); + CognitionPackage.ensureInitialized(); + CarpDataManager.ensureInitialized(); + + // Deploy a minimal study protocol on the local (on-phone) deployment + // service - the in-test equivalent of placing a protocol.json in + // assets/carp/resources/ for local mode. + await Settings().init(); + final phone = Smartphone(); + final protocol = SmartphoneStudyProtocol(name: 'Join flow integration test', ownerId: 'test') + ..addPrimaryDevice(phone) + ..addTaskControl( + ImmediateTrigger(), + BackgroundTask(measures: [Measure(type: DeviceSamplingPackage.BATTERY_STATE)]), + phone, + ); + final status = await SmartphoneDeploymentService().createStudyDeployment(protocol); + final primaryDeviceRoleName = status.deviceStatusList + .firstWhere((deviceStatus) => deviceStatus.device is PrimaryDeviceConfiguration) + .device + .roleName; + LocalSettings().participant = Participant( + studyDeploymentId: status.studyDeploymentId, + deviceRoleName: primaryDeviceRoleName, + ); + bloc.study.study = SmartphoneStudy( + studyDeploymentId: status.studyDeploymentId, + deviceRoleName: primaryDeviceRoleName, + ); + + await bloc.initialize(); + + // The study descriptor exists, but it is not deployed in the sensing + // runtime and consent has not been given yet. + expect(bloc.study.hasStudy, isTrue); + expect(bloc.study.isDeployed, isFalse); + expect(await bloc.consent.hasBeenAccepted(bloc.study.study), isFalse); + + await tester.pumpWidget(const CarpStudyApp()); + + // The home page must gate on consent before configuring the study. With + // no local consent document, the consent page auto-accepts, which lets + // setup proceed: configure -> deploy -> verify -> start. + await pumpUntil( + tester, + () => LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false, + reason: 'consent gate to accept', + ); + await pumpUntil(tester, () => bloc.isConfigured, reason: 'study configuration to complete'); + + expect(await bloc.consent.hasBeenAccepted(bloc.study.study), isTrue); + expect(bloc.study.isDeployed, isTrue); + expect(bloc.study.deployment?.studyDeploymentId, status.studyDeploymentId); + + // Sensing must actually have been started - the executor is resumed. + await pumpUntil(tester, () => bloc.study.isRunning, reason: 'sensing to start'); + + // And the user lands on the study page. + await pumpUntil(tester, () => find.byType(StudyPage).evaluate().isNotEmpty, reason: 'study page to be shown'); + + // Signing out tears down sensing, clears auth, erases the study, and + // returns to the initial state - the router sends the user back to the + // invitation flow. + await bloc.signOutAndLeaveStudy(); + + expect(bloc.state, AppState.initialized); + expect(bloc.study.hasStudy, isFalse); + expect(bloc.study.isRunning, isFalse); + expect(bloc.auth.isAuthenticated, isFalse); + + await pumpUntil( + tester, + () => find.byType(InvitationListPage).evaluate().isNotEmpty, + reason: 'invitation list after signing out', + ); + }, + ); +} diff --git a/ios/Podfile b/ios/Podfile index 6e0ec481..9012b13d 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -35,7 +35,7 @@ target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) pod 'Movesense', :git => 'https://bitbucket.org/movesense/movesense-mobile-lib/' - pod 'PhoneNumberKit', '~> 3.7' + # pod 'PhoneNumberKit', '~> 3.7' end post_install do |installer| diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a1758e56..07628593 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -9,8 +9,11 @@ PODS: - Flutter - audio_session (0.0.1): - Flutter - - audio_streamer (0.0.1): + - audio_streamer (4.3.0): - Flutter + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS - battery_plus (1.0.0): - Flutter - camera_avfoundation (0.0.1): @@ -34,19 +37,23 @@ PODS: - Flutter - flutter_secure_storage (6.0.0): - Flutter - - flutter_sound (9.28.0): + - flutter_sound (9.30.0): - Flutter - - flutter_sound_core (= 9.28.0) - - flutter_sound_core (9.28.0) + - flutter_sound_core (= 9.30.0) + - flutter_sound_core (9.30.0) - flutter_timezone (0.0.1): - Flutter - flutter_web_auth_2 (3.0.0): - Flutter - - health (12.2.1): + - health (13.3.1): + - Flutter + - integration_test (0.0.1): - Flutter - just_audio (0.0.1): - Flutter - FlutterMacOS + - light (4.1.1): + - Flutter - location (0.0.1): - Flutter - mdsflutter (0.0.1): @@ -54,7 +61,6 @@ PODS: - Movesense - SwiftProtobuf - Movesense (3.33.1) - - MTBBarcodeScanner (5.0.11) - network_info_plus (0.0.1): - Flutter - oidc_ios (0.0.1): @@ -63,31 +69,21 @@ PODS: - Flutter - package_info_plus (0.4.5): - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - pedometer (0.0.1): + - pedometer (4.2.0): - Flutter - - permission_handler_apple (9.3.0): + - permission_handler_apple (9.4.8): - Flutter - - PhoneNumberKit (3.8.0): - - PhoneNumberKit/PhoneNumberKitCore (= 3.8.0) - - PhoneNumberKit/UIKit (= 3.8.0) - - PhoneNumberKit/PhoneNumberKitCore (3.8.0) - - PhoneNumberKit/UIKit (3.8.0): - - PhoneNumberKit/PhoneNumberKitCore - polar (0.0.1): - Flutter - - PolarBleSdk (~> 6.1.0) - - PolarBleSdk (6.1.0): + - PolarBleSdk (= 6.14.0) + - PolarBleSdk (6.14.0): - RxSwift (~> 6.8.0) - SwiftProtobuf (~> 1.0) - Zip (~> 2.1.2) - qr_code_scanner_plus (0.2.6): - Flutter - - MTBBarcodeScanner - RxSwift (6.8.0) - - screen_state (1.0.0): + - screen_state (5.0.1): - Flutter - sensors_plus (0.0.1): - Flutter @@ -97,7 +93,7 @@ PODS: - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - - SwiftProtobuf (1.31.1) + - SwiftProtobuf (1.33.3) - url_launcher_ios (0.0.1): - Flutter - video_player_avfoundation (0.0.1): @@ -109,6 +105,7 @@ DEPENDENCIES: - appcheck (from `.symlinks/plugins/appcheck/ios`) - audio_session (from `.symlinks/plugins/audio_session/ios`) - audio_streamer (from `.symlinks/plugins/audio_streamer/ios`) + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - battery_plus (from `.symlinks/plugins/battery_plus/ios`) - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) @@ -124,7 +121,9 @@ DEPENDENCIES: - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - health (from `.symlinks/plugins/health/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - just_audio (from `.symlinks/plugins/just_audio/darwin`) + - light (from `.symlinks/plugins/light/ios`) - location (from `.symlinks/plugins/location/ios`) - mdsflutter (from `.symlinks/plugins/mdsflutter/ios`) - Movesense (from `https://bitbucket.org/movesense/movesense-mobile-lib/`) @@ -132,10 +131,8 @@ DEPENDENCIES: - oidc_ios (from `.symlinks/plugins/oidc_ios/ios`) - open_settings_plus (from `.symlinks/plugins/open_settings_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - pedometer (from `.symlinks/plugins/pedometer/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - PhoneNumberKit (~> 3.7) - polar (from `.symlinks/plugins/polar/ios`) - qr_code_scanner_plus (from `.symlinks/plugins/qr_code_scanner_plus/ios`) - screen_state (from `.symlinks/plugins/screen_state/ios`) @@ -149,8 +146,6 @@ SPEC REPOS: trunk: - AppAuth - flutter_sound_core - - MTBBarcodeScanner - - PhoneNumberKit - PolarBleSdk - RxSwift - SwiftProtobuf @@ -163,6 +158,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/audio_session/ios" audio_streamer: :path: ".symlinks/plugins/audio_streamer/ios" + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/darwin" battery_plus: :path: ".symlinks/plugins/battery_plus/ios" camera_avfoundation: @@ -193,8 +190,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_web_auth_2/ios" health: :path: ".symlinks/plugins/health/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" just_audio: :path: ".symlinks/plugins/just_audio/darwin" + light: + :path: ".symlinks/plugins/light/ios" location: :path: ".symlinks/plugins/location/ios" mdsflutter: @@ -209,8 +210,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/open_settings_plus/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" pedometer: :path: ".symlinks/plugins/pedometer/ios" permission_handler_apple: @@ -241,7 +240,8 @@ SPEC CHECKSUMS: AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 appcheck: 3c94d0ffc94bd639938cac7427d5b13df2795404 audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 - audio_streamer: 2e472b9f81cec5e381c4cf7667afa3055dcb45a4 + audio_streamer: 85e5b27e443525fcfe375052132327539c89d19a + audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 battery_plus: b42253f6d2dde71712f8c36fef456d99121c5977 camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd @@ -251,39 +251,38 @@ SPEC CHECKSUMS: flutter_activity_recognition: 52dfad4c1e9cec99f0841b3ed0efcc9d424b3deb flutter_appauth: d4abcf54856e5d8ba82ed7646ffc83245d4aa448 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb + flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - flutter_sound: b9236a5875299aaa4cef1690afd2f01d52a3f890 - flutter_sound_core: 427465f72d07ab8c3edbe8ffdde709ddacd3763c + flutter_sound: d95194f6476c9ad211d22b3a414d852c12c7ca44 + flutter_sound_core: 7f2626d249d3a57bfa6da892ef7e22d234482c1a flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 - health: fe206e65f13a9b88623605fbd8af8f029e23dc35 + health: 4decf5d74e8f54c58a8c07a385fc72f629a831d9 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed + light: 6490592116da81837a414ef49387bbea7a5ed375 location: 155caecf9da4f280ab5fe4a55f94ceccfab838f8 mdsflutter: bd3c0b3335d50947d1a192cf70f930e0fb04a766 Movesense: dc64047c1feb856b7f7ac6ea2c67685350d99dd5 - MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc oidc_ios: 16966cad509ce6850ca4ca1216c5138bef2a8726 open_settings_plus: d19f91e8a04649358a51c19b484ce2e637149d70 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 - pedometer: 1c5eaab0c6bce8eb7651f7095553b5081c9d06ed - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - PhoneNumberKit: ec00ab8cef5342c1dc49fadb99d23fa7e66cf0ef - polar: 0374f6063ade7e2dd85d626f87cbe2393d3eda7c - PolarBleSdk: bfb57cf8350f0c53d441b8632ba6df363ce7c79f - qr_code_scanner_plus: 7e087021bc69873140e0754750eb87d867bed755 + pedometer: 6806036696085739f36b4e4e5374135e86220a59 + permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e + polar: e6c15a5007d85ab02ce12973c152545bcba861cd + PolarBleSdk: 1d56bb7a3ca0a6df04cb4f652fdccb26064df011 + qr_code_scanner_plus: 1fb59fd4576edb53ec7560c3746f454dee54300c RxSwift: 4e28be97cbcfeee614af26d83415febbf2bf6f45 - screen_state: 52d6e997d31bddba6417c60d9cdd22effd0320a7 + screen_state: e8bc94a9be30050982ffe1bbacc9821bb5287606 sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - SwiftProtobuf: e02f51c8c2df5845657aee2d4de9d61bf50ef788 + SwiftProtobuf: e1b437c8e31a4c5577b643249a0bb62ed4f02153 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a + video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 -PODFILE CHECKSUM: 0f233b2493d660073cf18073d2b24e7b319ab4a8 +PODFILE CHECKSUM: 45842edf150243fea66883d4dfad1e5ddb428147 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 516f6216..38eddf7c 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,13 +8,16 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1DF7245C2EC76C6800F24410 /* PhoneNumberKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1DF7245B2EC76C6800F24410 /* PhoneNumberKit */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; D93ECDADCF301D48CCE6DCE8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A0826C16315D483B5D5065B1 /* Pods_Runner.framework */; }; + F4422F0F2FD1A31D00EE32CC /* AppIcon26.icon in Resources */ = {isa = PBXBuildFile; fileRef = F4422F0E2FD1A31D00EE32CC /* AppIcon26.icon */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -53,6 +56,7 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -64,6 +68,7 @@ A0826C16315D483B5D5065B1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D653928A2B7565FF006884D6 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; E1E629EF268DFF1000DDDF95 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = ""; }; + F4422F0E2FD1A31D00EE32CC /* AppIcon26.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon26.icon; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -78,6 +83,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1DF7245C2EC76C6800F24410 /* PhoneNumberKit in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, D93ECDADCF301D48CCE6DCE8 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -106,6 +113,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -148,6 +156,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + F4422F0E2FD1A31D00EE32CC /* AppIcon26.icon */, ); path = Runner; sourceTree = ""; @@ -199,6 +208,10 @@ dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + 1DF7245B2EC76C6800F24410 /* PhoneNumberKit */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -233,6 +246,10 @@ da, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + 1DF7245A2EC76C6800F24410 /* XCRemoteSwiftPackageReference "PhoneNumberKit" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -257,6 +274,7 @@ files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + F4422F0F2FD1A31D00EE32CC /* AppIcon26.icon in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); @@ -443,7 +461,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon26; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -634,7 +653,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon26; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -668,7 +688,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon26; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; @@ -734,6 +755,36 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 1DF7245A2EC76C6800F24410 /* XCRemoteSwiftPackageReference "PhoneNumberKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/marmelroy/PhoneNumberKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.6.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 1DF7245B2EC76C6800F24410 /* PhoneNumberKit */ = { + isa = XCSwiftPackageProductDependency; + package = 1DF7245A2EC76C6800F24410 /* XCRemoteSwiftPackageReference "PhoneNumberKit" */; + productName = PhoneNumberKit; + }; + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..a71b474b --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "phonenumberkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marmelroy/PhoneNumberKit.git", + "state" : { + "revision" : "c107075aa8d394be7aced273a46e944afe8b321b", + "version" : "3.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index fa4cdb69..e596a992 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + () { -// if (!registry.hasPlugin("BackgroundLocatorPlugin")) { -// GeneratedPluginRegistrant.register(with: registry) -// } -// } - @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - - // from flutter_local_notifications + // flutter_local_notifications expects the notification center delegate set early. if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } - - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + // UIScene lifecycle: plugin registration moves here from didFinishLaunching. + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/ios/Runner/AppIcon26.icon/Assets/icon_foreground.svg b/ios/Runner/AppIcon26.icon/Assets/icon_foreground.svg new file mode 100644 index 00000000..337c8d8e --- /dev/null +++ b/ios/Runner/AppIcon26.icon/Assets/icon_foreground.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ios/Runner/AppIcon26.icon/icon.json b/ios/Runner/AppIcon26.icon/icon.json new file mode 100644 index 00000000..e26d337e --- /dev/null +++ b/ios/Runner/AppIcon26.icon/icon.json @@ -0,0 +1,54 @@ +{ + "fill" : { + "solid" : "extended-gray:1.00000,1.00000" + }, + "groups" : [ + { + "blend-mode" : "normal", + "blur-material" : null, + "hidden" : false, + "layers" : [ + { + "blend-mode" : "normal", + "fill" : "automatic", + "glass-specializations" : [ + { + "value" : true + }, + { + "appearance" : "light", + "value" : true + } + ], + "hidden" : false, + "image-name" : "icon_foreground.svg", + "name" : "icon_foreground", + "opacity" : 1, + "position" : { + "scale" : 1.2, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "lighting" : "individual", + "shadow" : { + "kind" : "layer-color", + "opacity" : 0.6 + }, + "specular" : false, + "translucency" : { + "enabled" : false, + "value" : 0.1 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d0d98aa1..00000000 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1 +0,0 @@ -{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index 41569738..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index f82eda9f..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 58e4e854..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 4b71b730..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 293b0de1..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index c80cb562..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 60e621e3..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 58e4e854..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 8270a2e8..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index d2e70892..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png deleted file mode 100644 index dbcc840c..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png deleted file mode 100644 index ac8b8e8b..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png deleted file mode 100644 index ead04e84..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png deleted file mode 100644 index 1b2af6ed..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index d2e70892..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 522e570f..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png deleted file mode 100644 index d2f79e25..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png deleted file mode 100644 index 5bf2e8aa..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 98b3d315..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 0fe120e9..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 9f73bc7b..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 301e663d..47fa25e9 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -93,6 +93,27 @@ CARP uses the motion system to detects activities such as walking and biking NSSpeechRecognitionUsageDescription CARP uses speech recognition + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents UIBackgroundModes @@ -115,7 +136,5 @@ UIViewControllerBasedStatusBarAppearance - UIStatusBarHidden - diff --git a/ios/ci_scripts/ci_post_clone.sh b/ios/ci_scripts/ci_post_clone.sh index 04698d63..b04288bd 100755 --- a/ios/ci_scripts/ci_post_clone.sh +++ b/ios/ci_scripts/ci_post_clone.sh @@ -7,7 +7,7 @@ set -ex set -euo pipefail # Install Flutter using git. -git clone https://github.com/flutter/flutter.git --depth 1 -b stable $HOME/flutter +git clone https://github.com/flutter/flutter.git --depth 1 -b 3.44.0 $HOME/flutter export PATH="$PATH:$HOME/flutter/bin" # Install Flutter artifacts for iOS (--ios), or macOS (--macos) platforms. @@ -31,6 +31,8 @@ elif [ "${CI_WORKFLOW:-}" = "Test - Internal Testing" ]; then MODE="test" fi -flutter build ios --config-only --release --dart-define="deployment-mode=$MODE" +# Build number is Xcode Cloud's auto-incrementing counter (CI_BUILD_NUMBER), +# so iOS builds don't rely on a manual bump in pubspec. +flutter build ios --config-only --release --build-number="$CI_BUILD_NUMBER" --dart-define="deployment-mode=$MODE" exit 0 \ No newline at end of file diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 18eca055..7d8b9c8d 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -1,7 +1,7 @@ part of carp_study_app; -/// The state of the [StudyAppBLoC]. -enum StudyAppState { +/// The state of the [AppBloc]. +enum AppState { /// The BLoC is created but not ready for use. created, @@ -15,461 +15,213 @@ enum StudyAppState { configured, } -/// How to deploy a study. -enum DeploymentMode { - /// Use a local study protocol & deployment and store data locally on the phone. - local, - - /// Use the CAWS production server to get the study deployment and store data. - production, - - /// Use the CAWS test server to get the study deployment and store data. - test, - - /// Use the CAWS development server to get the study deployment and store data. - dev, -} - -/// The main Business Logic Component (BLoC) for the entire app. +/// The coordinator for the entire app. /// /// Works as a singleton and can always be accessed via the global `bloc` /// variable. /// -/// Works as a [ChangeNotifier] and will notify its listeners on important -/// changes. Is also stateful and has a [state] and state changes are propagated -/// through the [stateStream]. -/// -/// The BLoC is configured using two environment variables: -/// -/// * `deployment-mode` set the [DeploymentMode]. -/// * `debug-level` set the [DebugLevel]. -/// -/// In Flutter these environment variables are set by specifying the `--dart-define` -/// option in `flutter run`. For example: +/// Holds the app state machine and the focused services doing the actual +/// work ([config], [auth], [study], [messages], [consent], [system], +/// [resources]). Orchestration that spans several services - like +/// [configureStudy] and [leaveStudy] - lives here. /// -/// `flutter run --dart-define=deployment-mode=local,debug-level=info` -class StudyAppBLoC extends ChangeNotifier { - StudyAppState _state = StudyAppState.created; - final CarpBackend _backend = CarpBackend(); - final CarpStudyAppViewModel _appViewModel = CarpStudyAppViewModel(); - List _messages = []; - final StreamController _messageStreamController = - StreamController.broadcast(); +/// Works as a [ChangeNotifier] and will notify its listeners (incl. the +/// router) on important changes. +class AppBloc extends ChangeNotifier { + AppState _state = AppState.created; + final AppViewModel _appViewModel = AppViewModel(); + StreamSubscription? _userTaskNotificationSubscription; - /// The state of this BloC. - StudyAppState get state => _state; + /// The resource managers matching the current deployment mode. + late final ResourceManagerFactory resources = ResourceManagerFactory(); + + /// Device- and platform-level checks. + late final SystemInfoService system = SystemInfoService(); - bool get isInitialized => _state.index >= StudyAppState.initialized.index; - bool get isConfiguring => _state.index >= StudyAppState.configuring.index; - bool get isConfigured => _state.index >= StudyAppState.configured.index; + /// User identity and authentication. + late final AuthService auth = AuthService(); - /// Debug level for the app and CAMS. - DebugLevel debugLevel = DebugLevel.info; + /// The study running on this phone and its deployment. + late final StudyService study = StudyService(resources: resources); - /// What kind of deployment are we running? - DeploymentMode deploymentMode = DeploymentMode.production; + /// The messages shown in the app, kept refreshed by polling. + late final MessageService messages = MessageService(resources.messageManager); - /// The localization (language)) of this app. - RPLocalizations? localization; + /// The informed consent flow. + late final ConsentService consent = ConsentService(resources.informedConsentManager); - /// The list of currently available messages. - List get messages => _messages; + /// The state of this BloC. + AppState get state => _state; - /// A stream of event when the list of [messages] is updated. - /// The data send on the stream is the number of available messages. - Stream get messageStream => _messageStreamController.stream; + bool get isInitialized => _state.index >= AppState.initialized.index; + bool get isConfiguring => _state.index >= AppState.configuring.index; + bool get isConfigured => _state.index >= AppState.configured.index; + + /// The overall data model for this app + AppViewModel get appViewModel => _appViewModel; // ScaffoldMessenger for showing snack bars final _scaffoldKey = GlobalKey(); GlobalKey get scaffoldKey => _scaffoldKey; - State? get scaffoldMessengerState => scaffoldKey.currentState; /// Create the BLoC for the app. - StudyAppBLoC() : super() { - const dep = - String.fromEnvironment('deployment-mode', defaultValue: 'production'); - deploymentMode = - DeploymentMode.values.where((element) => element.name == dep).first; - - const deb = String.fromEnvironment('debug-level', defaultValue: 'info'); - debugLevel = - DebugLevel.values.where((element) => element.name == deb).first; - - info('$runtimeType created. ' - 'DeploymentMode: ${deploymentMode.name}, ' - 'DebugLevel: ${debugLevel.name}'); - } - - LocalizationManager get localizationManager => - (deploymentMode == DeploymentMode.local - ? LocalResourceManager() - : CarpResourceManager()) as LocalizationManager; + AppBloc() : super() { + info( + '$runtimeType created. ' + 'DeploymentMode: ${AppConfig.deploymentMode.name}, ' + 'DebugLevel: ${AppConfig.debugLevel.name}', + ); - LocalizationLoader get localizationLoader { - debug('$runtimeType - using localizationManager: $localizationManager'); - return ResourceLocalizationLoader(localizationManager); + // The coordinator is the sole router-notifier - forward service changes. + // Once consent is given, the study can be configured and started. + consent.addListener(_onConsentChanged); } - MessageManager get messageManager => (deploymentMode == DeploymentMode.local - ? LocalResourceManager() - : CarpResourceManager()) as MessageManager; - - InformedConsentManager get informedConsentManager => - (bloc.deploymentMode == DeploymentMode.local - ? LocalResourceManager() - : CarpResourceManager()) as InformedConsentManager; - - ParticipationService get participationService => - (bloc.deploymentMode == DeploymentMode.local - ? LocalParticipationService() - : CarpParticipationService()); - - CarpBackend get backend => _backend; - - /// The study running on this phone. - /// Typical set based on an invitation. - /// `null` if no deployment have been specified. - SmartphoneStudy? get study => LocalSettings().study; - set study(SmartphoneStudy? study) => LocalSettings().study = study; - - /// Has a study been deployed on this phone? - bool get hasStudyBeenDeployed => study != null; - - /// The deployment running on this phone. - SmartphoneDeployment? get deployment => Sensing().controller?.deployment; - - Set get expectedParticipantData => - deployment?.expectedParticipantData ?? {}; - - /// Get the status for the current study deployment. - /// Returns null if the study is not yet deployed on this phone. - Future get studyDeploymentStatus async => - await Sensing().getStudyDeploymentStatus(); - - /// When was this study deployed on this phone. - DateTime? get studyStartTimestamp => deployment?.deployed; - - /// The overall data model for this app - CarpStudyAppViewModel get appViewModel => _appViewModel; - - final appCheck = AppCheck(); + void _onConsentChanged() { + logAppState('AppBloc._onConsentChanged() - consent.isAccepted=${consent.isAccepted}'); + notifyListeners(); + if (consent.isAccepted == true) unawaited(tryConfigureStudy()); + } - List? installedApps; + /// Run [configureStudy], surfacing a failure to the user instead of + /// throwing. Used by the setup flow and retry affordances, where no + /// caller can handle the error. + Future tryConfigureStudy() async { + logAppState('AppBloc.tryConfigureStudy() START'); + try { + await configureStudy(); + } catch (error) { + logAppState('AppBloc.tryConfigureStudy() FAILED - $error'); + final context = scaffoldKey.currentContext; + final locale = context != null ? RPLocalizations.of(context) : null; + scaffoldKey.currentState?.showSnackBar( + SnackBar(content: Text(locale?.translate('pages.home.setup_failed') ?? 'Could not set up the study.')), + ); + } + } /// Initialize this BLOC. Called before being used for anything. Future initialize() async { if (isInitialized) return; + logAppState('AppBloc.initialize() START'); - Settings().debugLevel = debugLevel; + Settings().debugLevel = AppConfig.debugLevel; await Settings().init(); - CarpResourceManager().initialize(); - Sensing(); + CarpResourceManager().initialize(); - // Initialize and use the CAWS backend if not in local deployment mode - if (deploymentMode != DeploymentMode.local) { - if (await checkConnectivity()) { - await backend.initialize(); + if (AppConfig.deploymentMode != DeploymentMode.local) { + // Initialize and use the CAWS backend if not in local deployment mode + if (await system.checkConnectivity()) { + await auth.initialize(); } + } else { + // Deploy the local protocol if running in local mode + await study.deployLocalProtocol(); } + await Sensing().initialize(study.deploymentService); - // Deploy the local protocol if running in local mode - if (deploymentMode == DeploymentMode.local) { - await deployLocalProtocol(); - } - - _state = StudyAppState.initialized; + _state = AppState.initialized; notifyListeners(); - debug('$runtimeType initialized - deployment mode: ${deploymentMode.name}'); - } - - /// Is the phone connected to the internet either via wifi or mobile network? - Future checkConnectivity() async { - final List results = - await (Connectivity().checkConnectivity()); + debug('$runtimeType initialized - deployment mode: ${AppConfig.deploymentMode.name}'); - return results.any((element) => - element == ConnectivityResult.mobile || - element == ConnectivityResult.wifi); - } - - /// Check if the Health database is installed on this phone. - /// - /// Always returns true on iOS, since Health is part of the OS and hence always installed. - /// On Android, returns true if Google Health Connect is installed, false otherwise. - Future isHealthInstalled() async { - if (Platform.isIOS) return true; + logAppState('AppBloc.initialize() DONE'); - try { - final apps = await appCheck.getInstalledApps() ?? []; - return apps.any( - (app) => app.packageName == LocalSettings.healthConnectPackageName); - } catch (e) { - debug("$runtimeType - Error checking Health Connect installation: $e"); - return false; - } - } - - Future getAppHasUpdate() async { - PackageInfo packageInfo = await PackageInfo.fromPlatform(); - AppVersionResult result = await AppVersionUpdate.checkForUpdates( - playStoreId: packageInfo.packageName, - appleId: '1569798025', - country: 'dk', - ); - return result.canUpdate; - } - - /// Deploy the local protocol if running in local mode. - /// - /// We can run the app in local mode to debug a local protocol stored in - /// assets/carp/resources/protocol.json - /// - /// This method will deploy the protocol in the local SmartphoneDeploymentService - /// which later will be used for deployment. See [Sensing.deploymentService]. - Future deployLocalProtocol() async { - if (deploymentMode != DeploymentMode.local) return; - - if (hasStudyBeenDeployed) { - info('Running in local deployment mode. Note that the local protocol has ' - 'already been deployed and the cached version will be loaded and used. ' - 'If you want to reload a modified protocol, delete the app with the ' - 'cached protocol from the phone before running it.'); - } else { - debug('$runtimeType - deploying local protocol'); - - // Get the protocol from the local study protocol manager. - // Note that the study id is not used since it always returns the same protocol. - var protocol = await LocalResourceManager().getStudyProtocol(''); - - // Deploy this protocol using the on-phone deployment service. - final status = - await SmartphoneDeploymentService().createStudyDeployment(protocol!); - - // Save the participant and study on the phone for use across app restart. - var participant = Participant( - studyDeploymentId: status.studyDeploymentId, - deviceRoleName: status.primaryDeviceStatus?.device.roleName, - ); - LocalSettings().participant = participant; - - bloc.study = SmartphoneStudy( - studyDeploymentId: status.studyDeploymentId, - deviceRoleName: status.primaryDeviceStatus!.device.roleName, - ); + // For a returning user, check consent - via [_onConsentChanged] this + // routes to the consent page or configures and starts the study. + if (study.hasStudy) { + logApp('AppBloc.initialize() - returning user has a persisted study, refreshing consent status'); + unawaited(consent.refreshStatus(study.study)); } } /// Set the active study in the app based on an [invitation]. /// - /// If a [context] is provided, the translation for this study is re-loaded - /// and applied in the app. - void setStudyInvitation( - ActiveParticipationInvitation invitation, [ - BuildContext? context, - ]) { - // create and save the participant info based on this invitation - var participant = Participant.fromParticipationInvitation(invitation); - LocalSettings().participant = participant; + /// The study translations are re-loaded by the app, which listens for the + /// study change. + void setStudyInvitation(ActiveParticipationInvitation invitation) { + logApp( + 'AppBloc.setStudyInvitation() - accepting invitation: ' + 'deploymentId=${invitation.studyDeploymentId}, participantId=${invitation.participation.participantId}', + ); - LocalSettings().study = SmartphoneStudy.fromInvitation(invitation); + // create and save the participant info based on this invitation + LocalSettings().participant = Participant.fromParticipationInvitation(invitation); - // make sure that the CAWS backend services are configured with the study + // save the study; this also seeds the CAWS backend services with it // in order to access the correct resources (like translations etc.). - backend.study = study!; + study.study = SmartphoneStudy.fromInvitation(invitation); - // And the re-initialize the resource manager. + // And then re-initialize the resource manager. CarpResourceManager().initialize(); notifyListeners(); - info('Invitation received - study: $study'); + info('Invitation received - study: ${study.study}'); + logAppState('AppBloc.setStudyInvitation() DONE - study set, refreshing consent status'); - if (context != null) CarpStudyApp.reloadLocale(context); + // Routes to the consent page - or, if this participant has already + // consented (e.g. on another phone), configures and starts the study. + unawaited(consent.refreshStatus(study.study)); } - /// This methods is used to configure the [study] deployment. + /// Configure the study deployment and start sensing. /// /// This includes: - /// * initialize sensing - /// * adding the CAMS study - /// * setting up messaging + /// * initialize sensing and deploy the study /// * initializing the data visualization pages - Future configureStudy() async { - // early out if already configured - if (isConfiguring) return; - - _state = StudyAppState.configuring; - - // set up and initialize sensing - await Sensing().initialize(); - - // make sure that the CAWS backend services are configured with the study - backend.study = study!; - - // add the study and configure sensing - await Sensing().addStudy(); - - // initialize the UI data models - appViewModel.init(Sensing().controller!); - - // set up the messaging part and get the initial list of messages - messageManager.initialize(); - refreshMessages(); - - // refresh the list of messages on a regular basis - Timer.periodic(const Duration(minutes: 30), (_) => refreshMessages()); - - info('Study configuration done.'); - notifyListeners(); - _state = StudyAppState.configured; - } - - Future> getParticipantDataListFromDeployment() async => - (deployment == null) - ? [] - : await participationService - .getParticipantDataList([deployment!.studyDeploymentId]); - - /// Set the participant data for this study. - void setParticipantData( - String studyDeploymentId, - Map data, [ - String? inputByParticipantRole, - ]) => - participationService.setParticipantData( - studyDeploymentId, - data, - inputByParticipantRole, - ); - - /// Does this app use location permissions? - bool get usingLocationPermissions => true; - - /// Get the informed consent for this study. - Future getInformedConsent({bool refresh = false}) => - informedConsentManager.getInformedConsent(refresh: refresh); - - /// Has the informed consent been accepted by the user? - bool get hasInformedConsentBeenAccepted => - LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false; - - set hasInformedConsentBeenAccepted(bool accepted) { - var participant = LocalSettings().participant; - participant?.hasInformedConsentBeenAccepted = true; - LocalSettings().participant = participant; - } - - /// Mark the informed consent as accepted by the user based on the - /// [informedConsentResult]. + /// * setting up messaging + /// * starting sensing (only if the deployment succeeded) /// - /// This entails that it has been shown to the user and accepted by the user. - /// Will upload it to CAWS (if not running in local deployment mode). - Future informedConsentHasBeenAccepted( - RPTaskResult informedConsentResult, - ) async { - info('Informed consent has been accepted by user.'); - hasInformedConsentBeenAccepted = true; - - if (deploymentMode != DeploymentMode.local) { - await backend.uploadInformedConsent(informedConsentResult); - } - } - - /// Refresh the list of messages (news, announcements, articles) to be shown in - /// the Study Page of the app. - Future refreshMessages() async { - try { - _messages = await messageManager.getMessages(); - _messages.sort((m1, m2) => m2.timestamp.compareTo(m1.timestamp)); - info('Message list refreshed - count: ${_messages.length}'); - } catch (error) { - warning('Error getting messages - $error'); + /// If configuration fails (e.g., no network), the state is reset so this + /// method can be called again, and the error is rethrown for the caller + /// to surface. + Future configureStudy() async { + // early out if already configuring or configured + if (_state == AppState.configuring || isConfigured) { + logApp('AppBloc.configureStudy() - skipped, already ${_state.name}'); + return; } - _messageStreamController.add(_messages.length); - } - - /// The signed in user. Returns null if no user is signed in. - CarpUser? get user => backend.user; - - /// The username of the user running this study. - /// Returns an empty string if no user logged in. - String? get username => user!.username; - - /// The name used for friendly greeting. - /// Returns an empty string if no user logged in. - String? get friendlyUsername => (user != null) ? user!.firstName : ''; + logAppState('AppBloc.configureStudy() START'); - /// Does this [deployment] have any measures? - bool hasMeasures() => (deployment == null) - ? false - : (deployment!.measures.any((measure) => - (measure.type != SurveyUserTask.VIDEO_TYPE && - measure.type != SurveyUserTask.IMAGE_TYPE && - measure.type != SurveyUserTask.AUDIO_TYPE && - measure.type != SurveyUserTask.SURVEY_TYPE))); - - /// Does this [deployment] have the measure of type [type]? - bool hasMeasure(String type) { - if (deployment == null) return false; + final previousState = _state; + _state = AppState.configuring; try { - deployment?.measures.firstWhere((measure) => measure.type == type); - } catch (_) { - return false; + await study.configure(); + } catch (error) { + _state = previousState; + warning('$runtimeType - Study configuration failed - $error'); + logAppState('AppBloc.configureStudy() FAILED - study.configure() threw - $error'); + rethrow; } - return true; - } - - /// Does this [deployment] have any user tasks? - bool hasUserTasks() => (deployment == null) - ? false - : deployment!.tasks.whereType().isNotEmpty; + appViewModel.init(study.controller!); - /// Does this [deployment] have any connected devices? - bool hasDevices() => - (deployment == null) ? false : deployment!.connectedDevices.isNotEmpty; + messages.start(); - /// Is sensing running, i.e. has the study executor been resumed? - bool get isRunning => Sensing().isRunning; + _listenToUserTaskNotifications(); - /// the list of running - i.e. used - probes in this study. - List get runningProbes => (Sensing().controller != null) - ? Sensing().controller!.executor.probes - : []; - - DeploymentService get deploymentService => Sensing().deploymentService; + info('Study configuration done.'); - /// The list of all devices in this deployment. - Iterable get deploymentDevices => - Sensing().deploymentDevices.map((device) => DeviceViewModel(device)); + _state = AppState.configured; + notifyListeners(); + logAppState('AppBloc.configureStudy() DONE - now starting sensing'); - /// Start sensing. - Future start() async { - assert(Sensing().controller != null, - 'No Study Controller - the study has not been deployed.'); - if (!Sensing().isRunning) Sensing().controller?.start(); + await study.start(); } - /// Stop sensing. - void stop() => Sensing().controller?.stop(); - - /// Dispose the entire sensing. - @override - void dispose() { - super.dispose(); - Sensing().controller?.dispose(); + /// Open the task page when the user taps a user-task notification from + /// the OS. The subscription is created once and cancelled in [leaveStudy]. + void _listenToUserTaskNotifications() { + _userTaskNotificationSubscription ??= AppTaskController().userTaskEvents.listen((userTask) { + if (userTask.state == UserTaskState.notified) { + userTask.onStart(); + if (userTask.hasWidget) _rootNavigatorKey.currentContext?.push('/task/${userTask.id}'); + } + }); } - /// Add [measurement] to the stream of collected measurements. - void addMeasurement(Measurement measurement) => - Sensing().controller?.executor.addMeasurement(measurement); - - /// Add [error] to the stream of measurements. - void addError(Object error, [StackTrace? stacktrace]) => - Sensing().controller?.executor.addError(error, stacktrace); - /// Leave the study deployed on this phone. /// /// This entails @@ -478,21 +230,23 @@ class StudyAppBLoC extends ChangeNotifier { /// * resetting the informed consent flow /// * returning the user to select an invitation for another study /// - /// Note that study deployment information and data is not removed from the - /// phone. This is stored for later access. Or if the same deployment is - /// re-deployed on the phone, data from the previous deployment will be - /// available. + /// Note that study deployment information and data is removed from the + /// phone. If the same deployment is re-deployed on the phone, data from the + /// previous deployment will NOT be available. Future leaveStudy() async { - debug('$runtimeType --------- LEAVING STUDY ------------'); + info('Leaving study ${study.study}'); - // save and clear the UI data models + // clear the UI data models, message polling, and notification handling appViewModel.clear(); + messages.stop(); + await _userTaskNotificationSubscription?.cancel(); + _userTaskNotificationSubscription = null; + consent.reset(); // stop sensing and remove all deployment info - await Sensing().removeStudy(); - await LocalSettings().eraseStudyDeployment(); + await study.remove(); - _state = StudyAppState.initialized; + _state = AppState.initialized; notifyListeners(); } @@ -502,7 +256,15 @@ class StudyAppBLoC extends ChangeNotifier { /// deleting all user authentication information from this phone, including /// the authentication and refresh tokens. Future signOutAndLeaveStudy() async { - await backend.signOut(); + await auth.signOut(); await leaveStudy(); } + + /// Dispose the entire sensing. + @override + void dispose() { + messages.dispose(); + study.controller?.dispose(); + super.dispose(); + } } diff --git a/lib/blocs/app_config.dart b/lib/blocs/app_config.dart new file mode 100644 index 00000000..ab5bfd94 --- /dev/null +++ b/lib/blocs/app_config.dart @@ -0,0 +1,49 @@ +part of carp_study_app; + +/// How to deploy a study. +enum DeploymentMode { + /// Use a local study protocol & deployment and store data locally on the phone. + local, + + /// Use the CAWS production server to get the study deployment and store data. + production, + + /// Use the CAWS test server to get the study deployment and store data. + test, + + /// Use the CAWS development server to get the study deployment and store data. + dev, +} + +/// App-wide configuration parsed once from compile-time environment variables. +/// +/// A static, dependency-free holder so that lower layers ([Sensing], +/// [CarpBackend]) can read configuration without depending on the global +/// [bloc] - and without anything needing to instantiate it. +/// +/// The configuration is set using two environment variables: +/// +/// * `deployment-mode` sets the [DeploymentMode]. +/// * `debug-level` sets the [DebugLevel]. +/// +/// In Flutter these environment variables are set by specifying the `--dart-define` +/// option in `flutter run`. For example: +/// +/// `flutter run --dart-define=deployment-mode=local,debug-level=info` +/// +/// Note: `String.fromEnvironment` only reads the `--dart-define` value in a +/// const context, hence the explicit `const` below. +abstract class AppConfig { + /// What kind of deployment are we running? + static DeploymentMode deploymentMode = DeploymentMode.values.firstWhere( + (mode) => mode.name == const String.fromEnvironment('deployment-mode', defaultValue: 'production'), + ); + + /// Debug level for the app and CAMS. + static DebugLevel debugLevel = DebugLevel.values.firstWhere( + (level) => level.name == const String.fromEnvironment('debug-level', defaultValue: 'info'), + ); + + /// The localization (language) of this app. + static RPLocalizations? localization; +} diff --git a/lib/blocs/app_log.dart b/lib/blocs/app_log.dart new file mode 100644 index 00000000..267cf81c --- /dev/null +++ b/lib/blocs/app_log.dart @@ -0,0 +1,67 @@ +part of carp_study_app; + +/// Tag prefixed to every app-specific log line. Filter device logs to just the +/// study app's own flow with `flutter logs | grep CARP_STUDY_APP` (these lines +/// are intentionally separate from the framework's `[CAMS ...]` logs). +const String appLogTag = 'CARP_STUDY_APP'; + +/// Log an app-specific [message] under the [appLogTag] tag. +/// +/// Use for tracing the app's own flow (e.g. login → study) without dumping the +/// full state snapshot - reach for [logAppState] when the state matters. +void logApp(String message) => debugPrint('[$appLogTag] $message'); + +/// Log [message] followed by a one-line-per-field snapshot of the current app +/// state: bloc state, auth, backend, the active study/participant ids, consent, +/// and what is persisted in [LocalSettings]. +/// +/// Call this at the key transitions in the login → study flow to answer +/// "where am I and what do I have" at that exact point. +void logAppState(String message) => debugPrint('[$appLogTag] $message\n${_appStateSnapshot()}'); + +/// Evaluate [getter], returning a placeholder instead of throwing - state +/// logging must never crash the flow it is meant to observe. +Object? _safe(Object? Function() getter) { + try { + return getter(); + } catch (error) { + return ''; + } +} + +/// A one-line-per-field dump of the current app state, used by [logAppState]. +String _appStateSnapshot() { + final buffer = StringBuffer(); + void line(String key, Object? value) => buffer.writeln(' $key = $value'); + + SmartphoneStudy? study; + try { + study = bloc.study.study; + } catch (_) {} + final settings = LocalSettings(); + + line('bloc.state', _safe(() => bloc.state.name)); + line( + 'isInitialized/isConfiguring/isConfigured', + _safe(() => '${bloc.isInitialized}/${bloc.isConfiguring}/${bloc.isConfigured}'), + ); + line('deploymentMode', _safe(() => AppConfig.deploymentMode.name)); + line('authenticated', _safe(() => bloc.auth.isAuthenticated)); + line('anonymous', _safe(() => bloc.auth.isAnonymous)); + line('user', _safe(() => bloc.auth.username)); + line('backend.study (seeded in CAWS)', _safe(() => CarpService().study?.studyDeploymentId)); + line('study.hasStudy', _safe(() => bloc.study.hasStudy)); + line('study.isDeployed', _safe(() => bloc.study.isDeployed)); + line('study.deploymentId', study?.studyDeploymentId); + line('study.participantId', study?.participantId); + line('study.participantRole', study?.participantRoleName); + line('study.deviceRole', study?.deviceRoleName); + line('consent.isAccepted', _safe(() => bloc.consent.isAccepted)); + line('LocalSettings.user', _safe(() => settings.user?.username)); + line('LocalSettings.study.deploymentId', _safe(() => settings.studyDeploymentId)); + line('LocalSettings.participant.id', _safe(() => settings.participant?.participantId)); + line('LocalSettings.participant.consentAccepted', _safe(() => settings.participant?.hasInformedConsentBeenAccepted)); + line('LocalSettings.isAnonymous', _safe(() => settings.isAnonymous)); + + return buffer.toString().trimRight(); +} diff --git a/lib/blocs/sensing.dart b/lib/blocs/sensing.dart index 4ce5d2ba..3dc69c9e 100644 --- a/lib/blocs/sensing.dart +++ b/lib/blocs/sensing.dart @@ -7,82 +7,19 @@ part of carp_study_app; -/// This class implements the sensing layer. +/// The sensing layer. /// -/// Call [initialize] to setup a deployment using a CARP deployment. -/// Once initialized, use the [addStudy] method to add the study to this runtime. -/// The runtime [controller] can be used to control the study execution -/// (i.e., start or stop). +/// Registers the sampling packages, data managers, and task factories used by +/// the app, and configures the CAMS client manager via [initialize]. +/// +/// This is infrastructure only - it owns no study. The study lifecycle +/// (deployment, status, translation, runtime) is owned by [StudyService]. /// /// Note that this class is a singleton and only one sensing layer is used. -/// The current assumption at the moment is that this Study App only -/// runs one study at a time, even though CAMS supports that several studies -/// added to the [client]. class Sensing { static final Sensing _instance = Sensing._(); - StudyDeploymentStatus? _status; - SmartphoneDeploymentController? _controller; - Study? _study; - - /// The deployment service used in this app. - DeploymentService get deploymentService => - bloc.deploymentMode == DeploymentMode.local - ? SmartphoneDeploymentService() - : CarpDeploymentService(); - - /// The study running on this phone. - /// Only available after [addStudy] is called. - Study? get study => _study; - - /// The deployment running on this phone. - /// Only available after [addStudy] is called. - SmartphoneDeployment? get deployment => _controller?.deployment; - - /// The latest status of the study deployment. - StudyDeploymentStatus? get status => _controller?.deploymentStatus; - - /// The role name of this device in the deployed study. - String? get deviceRoleName => _study?.deviceRoleName; - - /// The study deployment id of the deployment running on this phone. - String? get studyDeploymentId => _study?.studyDeploymentId; - - /// The study runtime for this deployment. - SmartphoneDeploymentController? get controller => _controller; - - /// Is sensing running, i.e. has the study executor been started? - bool get isRunning => - (controller != null) && - controller!.executor.state == ExecutorState.started; - /// The list of running - i.e. used - probes in this study. - List get runningProbes => - (_controller != null) ? _controller!.executor.probes : []; - - /// The list of all device managers used in the current deployment. - /// - /// Note that not all available devices on this phone may be used in the - /// current deployment. Hence, this method returns the list of device managers - /// used in the current deployment. - List get deploymentDevices => deployment != null - ? SmartPhoneClientManager() - .deviceController - .devices - .values - .where((manager) => deployment!.devices - .any((element) => element.type == manager.type)) - .toList() - : []; - - /// The smartphone (primary device) manager. - SmartphoneDeviceManager get smartphoneDeviceManager => - SmartPhoneClientManager().deviceController.smartphoneDeviceManager; - - /// The list of connected devices. - List? get connectedDevices => - SmartPhoneClientManager().deviceController.connectedDevices; - - /// The singleton sensing instance + /// The singleton sensing instance. factory Sensing() => _instance; Sensing._() { @@ -96,29 +33,27 @@ class Sensing { SamplingPackageRegistry().register(PolarSamplingPackage()); SamplingPackageRegistry().register(MovesenseSamplingPackage()); - // create and register external data managers + // Create and register external data managers. + // The CARP data manager is needed in both LOCAL and CARP deployments, + // since a local study protocol may still upload to CAWS. DataManagerRegistry().register(CarpDataManagerFactory()); // register the special-purpose audio user task factory AppTaskController().registerUserTaskFactory(AppUserTaskFactory()); } - /// Initialize and set up sensing. - Future initialize() async { - info('Initializing $runtimeType - mode: ${bloc.deploymentMode}'); + /// Register the available devices and configure the CAMS client manager + /// with the given [deploymentService]. + Future initialize(DeploymentService deploymentService) async { + info('Initializing $runtimeType'); // Set up the devices available on this phone DeviceController().registerAllAvailableDevices(); - // Register the CARP data manager for uploading data back to CAWS. - // This is needed in both LOCAL and CARP deployments, since a local study - // protocol may still upload to CAWS - DataManagerRegistry().register(CarpDataManagerFactory()); - // Create and configure a client manager for this phone await SmartPhoneClientManager().configure( deploymentService: deploymentService, - deviceController: DeviceController(), + dataCollectorFactory: DeviceController(), // Need to ask for permissions all at once on Android. askForPermissions: Platform.isAndroid ? true : false, @@ -126,91 +61,4 @@ class Sensing { info('$runtimeType initialized'); } - - /// Add and deploy the study, and configure the study runtime (sampling). - Future addStudy() async { - assert(SmartPhoneClientManager().isConfigured, - 'The client manager is not yet configured. Call SmartPhoneClientManager().configure() before adding a study.'); - assert(bloc.study != null, - 'No study is provided. Cannot start deployment w/o a study.'); - - // Add the study to the client. - _study = await SmartPhoneClientManager().addStudy(bloc.study!); - _controller = - SmartPhoneClientManager().getStudyRuntime(study!.studyDeploymentId); - - // Get the study controller and try to deploy the study. - return await tryDeployment(); - } - - ///7 Try to deploy the study. - /// - /// Note that if the study has already been deployed on this phone it has - /// been cached locally and the local version will be used pr. default. - /// If not deployed before (i.e., cached) the study deployment will be - /// fetched from the deployment service. - Future tryDeployment() async { - assert(controller != null, - 'No study or controller is provided. Cannot start deployment w/o a study.'); - - StudyStatus status = await controller!.tryDeployment(useCached: true); - - // Make sure to translate the user tasks in the study protocol before using - // them in the app's task list. - translateStudyProtocol(); - - // Configure the controller - await controller?.configure(); - - // Listening on the data stream and print them as json to the debug console - controller?.measurements - .listen((measurement) => debugPrint(toJsonString(measurement))); - - info('$runtimeType - Study added, deployment id: $studyDeploymentId'); - return status; - } - - Future removeStudy() async { - if (study != null) { - await SmartPhoneClientManager().removeStudy(study!.studyDeploymentId); - } - } - - /// Get the last known status for the current study deployment. - /// Use [getStudyDeploymentStatus] to refresh the status from CAWS. - /// Returns null if the study is not yet deployed on this phone. - StudyDeploymentStatus? get studyDeploymentStatus => _status; - - /// Get the status for the current study deployment. - /// Returns null if the study is not yet deployed on this phone. - Future getStudyDeploymentStatus() async => - studyDeploymentId != null - ? _status = await deploymentService - .getStudyDeploymentStatus(studyDeploymentId!) - : null; - - /// Translate the title and description of all AppTask in the study protocol - /// of the current master deployment. - void translateStudyProtocol([RPLocalizations? localization]) { - bloc.localization ??= localization; - - // Fast out is no localization - if (bloc.localization == null) return; - - // Fast out, if not configured or no protocol - if (controller?.status != StudyStatus.Deployed || - controller?.deployment == null) { - return; - } - - for (var task in controller!.deployment!.tasks) { - if (task is AppTask) { - task.title = bloc.localization!.translate(task.title); - task.description = bloc.localization!.translate(task.description); - } - } - - info( - "$runtimeType - Study protocol translated to locale '${bloc.localization!.locale}'"); - } } diff --git a/lib/blocs/util.dart b/lib/blocs/util.dart index f2c35de1..1713255c 100644 --- a/lib/blocs/util.dart +++ b/lib/blocs/util.dart @@ -1,8 +1,7 @@ part of carp_study_app; extension StringExtension on String { - String truncateTo(int maxLength) => - (length <= maxLength) ? this : '${substring(0, maxLength)}...'; + String truncateTo(int maxLength) => (length <= maxLength) ? this : '${substring(0, maxLength)}...'; } extension Humanize on Duration { diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 296c2360..4147cba6 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -8,68 +8,73 @@ class CarpStudyApp extends StatefulWidget { /// Reload language translations and re-build the entire app. static void reloadLocale(BuildContext context) async { - CarpStudyAppState? state = - context.findAncestorStateOfType(); + CarpAppState? state = context.findAncestorStateOfType(); state?.reloadLocale(); } @override - CarpStudyAppState createState() => CarpStudyAppState(); + CarpAppState createState() => CarpAppState(); } -class CarpStudyAppState extends State { +class CarpAppState extends State { /// The landing page once the onboarding process is done. - static const String firstRoute = StudyPage.route; static const String homeRoute = '/'; /// Reload language translations and re-build the entire app. void reloadLocale() => setState(() => rpLocalizationsDelegate.reload()); - // This create the routing in the entire app. - // Each page (like [LoginPage]) know the name of its own route. + // State-driven routing. Bloc state + // changes notify the router via refreshListenable, which re-evaluates the + // redirect at the current location and moves the user automatically. final GoRouter _router = GoRouter( initialLocation: homeRoute, navigatorKey: _rootNavigatorKey, + refreshListenable: bloc, errorBuilder: (context, state) => const ErrorPage(), + redirect: (context, state) async { + final loc = state.matchedLocation; + logAppState('GoRouter.redirect() - evaluating at location: $loc'); + + // 1) Not authenticated → login page. + if (AppConfig.deploymentMode != DeploymentMode.local && !bloc.auth.isAuthenticated) { + logApp('GoRouter.redirect() - [1] not authenticated → ${LoginPage.route}'); + return LoginPage.route; + } + + // 2) No study selected → user belongs on the invitation list (or its + // details page). Anywhere else gets bounced to the list. + if (!bloc.study.hasStudy) { + if (loc == InvitationListPage.route || loc.startsWith('${InvitationDetailsPage.route}/')) { + logApp('GoRouter.redirect() - [2] no study, already on invitation flow → stay'); + return null; + } + logApp('GoRouter.redirect() - [2] no study → ${InvitationListPage.route}'); + return InvitationListPage.route; + } + + // 3) Study selected but consent known to be pending → consent page. + // (null means the consent status is still being checked - stay put.) + if (!bloc.isConfiguring && bloc.consent.isAccepted == false) { + return loc == InformedConsentPage.route ? null : InformedConsentPage.route; + } + + // 4) Fully onboarded. + return null; + }, routes: [ ShellRoute( navigatorKey: _shellNavigatorKey, builder: (BuildContext context, GoRouterState state, Widget child) => - HomePage(child: child), + HomePage(model: bloc.appViewModel.homePageViewModel, child: child), routes: [ - // This is the root route, handling the onboarding. - // The flow of logic is: - // - do we run locally and need authentication - // - the user is authenticated, if not show login page - // - a study is deployed, if not show list of invitations for the user - // - the user has accepted the informed consent, if not show informed consent page - // - // Once the above is done, then show the "first route", which currently is - // the "study" information page. - GoRoute( - path: homeRoute, - parentNavigatorKey: _shellNavigatorKey, - redirect: (context, state) async { - if (bloc.deploymentMode != DeploymentMode.local) { - if (!bloc.backend.isAuthenticated) { - return LoginPage.route; - } else if (!bloc.hasStudyBeenDeployed) { - return InvitationListPage.route; - } - } - if (!bloc.hasInformedConsentBeenAccepted) { - return InformedConsentPage.route; - } - - return firstRoute; - }), + // Home is just a landing slot — the top-level redirect always moves + // the user to the right place based on bloc state. + GoRoute(path: homeRoute, parentNavigatorKey: _shellNavigatorKey, redirect: (_, _) => StudyPage.route), GoRoute( path: TaskListPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( - child: TaskListPage( - model: bloc.appViewModel.taskListPageViewModel, - ), + child: TaskListPage(model: bloc.appViewModel.taskListPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), @@ -77,26 +82,33 @@ class CarpStudyAppState extends State { path: StudyPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( - child: StudyPage( - model: bloc.appViewModel.studyPageViewModel, - ), + child: StudyPage(model: bloc.appViewModel.studyPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), + routes: [ + // /study/consent — nested so the parent StudyPage stays mounted + // underneath. parentNavigatorKey escapes the shell so the bottom + // nav doesn't bleed through during consent. + GoRoute( + path: 'consent', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => InformedConsentPage(model: bloc.appViewModel.informedConsentViewModel), + ), + ], ), GoRoute( path: DataVisualizationPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( - child: DataVisualizationPage( - bloc.appViewModel.dataVisualizationPageViewModel), + child: DataVisualizationPage(bloc.appViewModel.dataVisualizationPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), GoRoute( path: DeviceListPage.route, parentNavigatorKey: _shellNavigatorKey, - pageBuilder: (context, state) => const CustomTransitionPage( - child: DeviceListPage(), + pageBuilder: (context, state) => CustomTransitionPage( + child: DeviceListPage(model: bloc.appViewModel.devicesPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), @@ -113,15 +125,12 @@ class CarpStudyAppState extends State { GoRoute( path: StudyDetailsPage.route, parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => StudyDetailsPage( - model: bloc.appViewModel.studyPageViewModel, - ), + builder: (context, state) => StudyDetailsPage(model: bloc.appViewModel.studyPageViewModel), ), GoRoute( path: ParticipantDataPage.route, parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => ParticipantDataPage( - model: bloc.appViewModel.participantDataPageViewModel), + builder: (context, state) => ParticipantDataPage(model: bloc.appViewModel.participantDataPageViewModel), ), GoRoute( path: '/task/:taskId', @@ -132,23 +141,15 @@ class CarpStudyAppState extends State { return task?.widget ?? const ErrorPage(); }, ), - GoRoute( - path: InformedConsentPage.route, - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => InformedConsentPage( - model: bloc.appViewModel.informedConsentViewModel, - ), - ), GoRoute( path: LoginPage.route, parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => const LoginPage(), + builder: (context, state) => LoginPage(model: bloc.appViewModel.loginViewModel), ), GoRoute( path: '${MessageDetailsPage.route}/:messageId', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => MessageDetailsPage( - messageId: state.pathParameters['messageId'] ?? ''), + builder: (context, state) => MessageDetailsPage(messageId: state.pathParameters['messageId'] ?? ''), ), GoRoute( path: '${InvitationDetailsPage.route}/:invitationId', @@ -161,11 +162,7 @@ class CarpStudyAppState extends State { GoRoute( path: InvitationListPage.route, parentNavigatorKey: _rootNavigatorKey, - redirect: (context, state) => bloc.study != null - ? InformedConsentPage.route - : (bloc.user == null ? LoginPage.route : null), - builder: (context, state) => InvitationListPage( - model: bloc.appViewModel.invitationsListViewModel), + builder: (context, state) => InvitationListPage(model: bloc.appViewModel.invitationsListViewModel), ), ], debugLogDiagnostics: true, @@ -173,24 +170,51 @@ class CarpStudyAppState extends State { /// Research Package translations, incl. both local language assets plus /// translations of informed consent and surveys downloaded from CARP - final RPLocalizationsDelegate rpLocalizationsDelegate = - RPLocalizationsDelegate( - loaders: [ - const AssetLocalizationLoader(), - bloc.localizationLoader, - ], + final RPLocalizationsDelegate rpLocalizationsDelegate = _AppLocalizationsDelegate( + loaders: [const AssetLocalizationLoader(), bloc.resources.localizationLoader], ); + AppState _previousBlocState = bloc.state; + String? _previousDeploymentId = bloc.study.study?.studyDeploymentId; + + @override + void initState() { + super.initState(); + bloc.addListener(_onAppStateChanged); + } + + @override + void dispose() { + bloc.removeListener(_onAppStateChanged); + super.dispose(); + } + + /// Re-load translations when a (new) study is set or has been configured, + /// since both make new study-specific translations available. + void _onAppStateChanged() { + final configured = bloc.state == AppState.configured; + final deploymentId = bloc.study.study?.studyDeploymentId; + + if (mounted && + ((configured && _previousBlocState != AppState.configured) || deploymentId != _previousDeploymentId)) { + reloadLocale(); + } + + _previousBlocState = bloc.state; + _previousDeploymentId = deploymentId; + } + @override Widget build(BuildContext context) { - final carpColors = Theme.of(context).extension(); + final studyAppColors = Theme.of(context).extension(); + + // Apply system overlay style after frame so Theme.of(context) is ready + WidgetsBinding.instance.addPostFrameCallback((_) { + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(statusBarColor: Colors.transparent)); + }); return MaterialApp.router( scaffoldMessengerKey: bloc.scaffoldKey, - supportedLocales: const [ - Locale('en'), - Locale('da'), - Locale('es'), - ], + supportedLocales: const [Locale('en'), Locale('da'), Locale('es')], localizationsDelegates: [ // Research Package translations rpLocalizationsDelegate, @@ -210,28 +234,33 @@ class CarpStudyAppState extends State { } return supportedLocales.first; // default to EN }, - locale: bloc.localization?.locale, - theme: researchPackageTheme.copyWith( - extensions: [ - researchPackageTheme.extension()!.copyWith( - primary: carpColors?.primary, - ), - ], + locale: AppConfig.localization?.locale, + theme: carpTheme.copyWith( + extensions: [carpTheme.extension()!.copyWith(primary: studyAppColors?.primary)], ), - darkTheme: researchPackageDarkTheme, + darkTheme: carpDarkTheme, debugShowCheckedModeBanner: true, routerConfig: _router, ); } } +/// Loads the RP translations and captures the loaded localization in +/// [AppConfig], where non-UI layers (e.g. protocol translation) read it. +class _AppLocalizationsDelegate extends RPLocalizationsDelegate { + _AppLocalizationsDelegate({required super.loaders}); + + @override + Future load(Locale locale) async { + final localizations = await super.load(locale); + AppConfig.localization = localizations; + return localizations; + } +} + FadeTransition bottomNavigationBarAnimation( BuildContext context, Animation animation, Animation secondaryAnimation, Widget child, -) => - FadeTransition( - opacity: animation, - child: child, - ); +) => FadeTransition(opacity: animation, child: child); diff --git a/lib/data/carp_backend.dart b/lib/data/carp_backend.dart index 6f100fc3..752bf357 100644 --- a/lib/data/carp_backend.dart +++ b/lib/data/carp_backend.dart @@ -28,24 +28,14 @@ class CarpBackend { } /// The URI of the CAWS server - depending on deployment mode. - Uri get uri => Uri( - scheme: 'https', - host: uris[bloc.deploymentMode], - ); + Uri get uri => Uri(scheme: 'https', host: uris[AppConfig.deploymentMode]); /// The URI of the CAWS authentication service. /// /// Of the form: /// https://dev.carp.dk/auth/realms/Carp/ - Uri get authUri => Uri( - scheme: 'https', - host: uris[bloc.deploymentMode], - pathSegments: [ - 'auth', - 'realms', - 'Carp', - ], - ); + Uri get authUri => + Uri(scheme: 'https', host: uris[AppConfig.deploymentMode], pathSegments: ['auth', 'realms', 'Carp']); /// The CAWS app configuration. late final CarpApp _app = CarpApp(name: "CAWS @ DTU", uri: uri); @@ -54,17 +44,13 @@ class CarpBackend { /// The authentication configuration CarpAuthProperties get authProperties => CarpAuthProperties( - authURL: uri, - clientId: 'studies-app', - redirectURI: Uri.parse('carp-studies-auth://auth'), - anonymousRedirectURI: Uri.parse('carp-studies:/anonymous'), - // For authentication at CAWS the path is '/auth/realms/Carp' - discoveryURL: uri.replace(pathSegments: [ - 'auth', - 'realms', - 'Carp', - ]), - ); + authURL: uri, + clientId: 'studies-app', + redirectURI: Uri.parse('carp-studies-auth://auth'), + anonymousRedirectURI: Uri.parse('carp-studies:/anonymous'), + // For authentication at CAWS the path is '/auth/realms/Carp' + discoveryURL: uri.replace(pathSegments: ['auth', 'realms', 'Carp']), + ); /// Initialize this backend. Must be called before used. Future initialize() async { @@ -90,6 +76,12 @@ class CarpBackend { CarpParticipationService().configureFrom(CarpService()); CarpDeploymentService().configureFrom(CarpService()); + // CAWS services hold the active study as ambient state. If a study is + // cached locally (returning user), re-seed the services here — otherwise + // CarpResourceManager throws null-derefs on the first resource fetch. + final cachedStudy = LocalSettings().study; + if (cachedStudy != null) study = cachedStudy; + info('$runtimeType initialized - app: $app'); } @@ -155,19 +147,7 @@ class CarpBackend { Future> getInvitations() async { CarpParticipationService().configureFrom(CarpService()); - invitations = - await CarpParticipationService().getActiveParticipationInvitations(); - - // Filter the invitations to only include those that - // have a smartphone as a device in [ActiveParticipationInvitation.assignedDevices] list - // (i.e. the invitation is for a smartphone). - // This is done to avoid showing invitations for other devices (e.g. [WebBrowser]). - invitations.removeWhere((invitation) => - invitation.assignedDevices - ?.any((device) => device.device is! Smartphone) ?? - false); - - return invitations; + return invitations = await CarpParticipationService().getActiveParticipationInvitations(); } /// Set the [study] used on this phone. @@ -184,27 +164,22 @@ class CarpBackend { /// /// Looks for the first instance of a [RPConsentSignatureResult] in [consent] /// and uploads this. - Future uploadInformedConsent( - RPTaskResult consent, - ) async { + Future uploadInformedConsent(RPTaskResult consent) async { if (user == null) { warning('$runtimeType - No user authenticated.'); return null; } if (participant == null) { - warning( - '$runtimeType - No participant (no invitation has been accepted).'); + warning('$runtimeType - No participant (no invitation has been accepted).'); return null; } late RPConsentSignatureResult signedConsent; try { - signedConsent = consent.results.values.firstWhere( - (result) => result is RPConsentSignatureResult, - ) as RPConsentSignatureResult; + signedConsent = + consent.results.values.firstWhere((result) => result is RPConsentSignatureResult) as RPConsentSignatureResult; } catch (_) { - warning( - '$runtimeType - No signed informed consent found to be uploaded.'); + warning('$runtimeType - No signed informed consent found to be uploaded.'); return null; } @@ -219,17 +194,20 @@ class CarpBackend { ); try { - await CarpParticipationService() - .participation() - .setInformedConsent(uploadedConsent); + await CarpParticipationService().participation().setInformedConsent(uploadedConsent); - info('$runtimeType - Informed consent document uploaded successfully for ' - 'deployment id: ${bloc.study?.studyDeploymentId}'); + info( + '$runtimeType - Informed consent document uploaded successfully for ' + 'deployment id: ${LocalSettings().study?.studyDeploymentId}', + ); } on Exception { - warning( - '$runtimeType - Informed consent upload failed for username: $username'); + warning('$runtimeType - Informed consent upload failed for username: $username'); } return uploadedConsent; } + + Future? getInformedConsentByRole(String studyDeploymentId, String? role) async { + return await CarpParticipationService().participation(studyDeploymentId).getInformedConsentByRole(role); + } } diff --git a/lib/data/local_participation_service.dart b/lib/data/local_participation_service.dart index 610ac23b..1171e802 100644 --- a/lib/data/local_participation_service.dart +++ b/lib/data/local_participation_service.dart @@ -3,8 +3,7 @@ part of carp_study_app; /// A local [ParticipationService] that does not connect to any backend. /// This is used when running in [DeploymentMode.local]. class LocalParticipationService implements ParticipationService { - static final LocalParticipationService _instance = - LocalParticipationService._(); + static final LocalParticipationService _instance = LocalParticipationService._(); LocalParticipationService._(); @@ -12,25 +11,19 @@ class LocalParticipationService implements ParticipationService { factory LocalParticipationService() => _instance; @override - Future> getActiveParticipationInvitations( - [String? accountId]) async => - []; + Future> getActiveParticipationInvitations({String? accountId}) async => []; @override Future getParticipantData(String studyDeploymentId) async => ParticipantData(studyDeploymentId: studyDeploymentId); @override - Future> getParticipantDataList( - List studyDeploymentIds, - ) async => - []; + Future> getParticipantDataList(List studyDeploymentIds) async => []; @override Future setParticipantData( String studyDeploymentId, Map data, [ String? inputByParticipantRole, - ]) async => - ParticipantData(studyDeploymentId: studyDeploymentId); + ]) async => ParticipantData(studyDeploymentId: studyDeploymentId); } diff --git a/lib/data/local_resource_manager.dart b/lib/data/local_resource_manager.dart index fafdba4b..0d80bf02 100644 --- a/lib/data/local_resource_manager.dart +++ b/lib/data/local_resource_manager.dart @@ -17,11 +17,7 @@ part of carp_study_app; /// Note that the 'id' of the protocol in the [getStudyProtocol] method is ignored. /// The 'protocol.json' file is always loaded. class LocalResourceManager - implements - InformedConsentManager, - LocalizationManager, - MessageManager, - StudyProtocolManager { + implements InformedConsentManager, LocalizationManager, MessageManager, StudyProtocolManager { /// The path to the json files to be loaded using this resource manager. static final String basePath = 'assets/carp'; @@ -46,30 +42,30 @@ class LocalResourceManager RPOrderedTask? get informedConsent => _informedConsent; @override - Future getInformedConsent({bool refresh = false}) async { + Future getConsentDocument({bool refresh = false}) async { if (_informedConsent == null) { try { - var jsonString = - await rootBundle.loadString('$basePath/resources/consent.json'); - Map jsonMap = - json.decode(jsonString) as Map; + var jsonString = await rootBundle.loadString('$basePath/resources/consent.json'); + Map jsonMap = json.decode(jsonString) as Map; _informedConsent = RPOrderedTask.fromJson(jsonMap); } catch (error) { - warning("$runtimeType - Could not load a local informed consent. " - "It should be added as an asset resource in 'carp/resources/consent.json'. $error"); + warning( + "$runtimeType - Could not load a local informed consent. " + "It should be added as an asset resource in 'carp/resources/consent.json'. $error", + ); } } return _informedConsent; } @override - Future setInformedConsent(RPOrderedTask informedConsent) async { + Future setConsentDocument(RPOrderedTask informedConsent) async { _informedConsent = informedConsent; return true; } @override - Future deleteInformedConsent() async { + Future deleteConsentDocument() async { _informedConsent = null; return true; } @@ -77,27 +73,19 @@ class LocalResourceManager // LOCALIZATION @override - Future> getLocalizations( - Locale locale, { - bool refresh = false, - }) async { + Future> getLocalizations(Locale locale, {bool refresh = false}) async { if (_translations == null) { var path = '$basePath/lang/${locale.languageCode}.json'; var jsonString = await rootBundle.loadString(path); - Map jsonMap = - json.decode(jsonString) as Map; - _translations = - jsonMap.map((key, value) => MapEntry(key, value.toString())); + Map jsonMap = json.decode(jsonString) as Map; + _translations = jsonMap.map((key, value) => MapEntry(key, value.toString())); } return _translations!; } @override - Future setLocalizations( - Locale locale, - Map localizations, - ) { + Future setLocalizations(Locale locale, Map localizations) { throw UnimplementedError(); } @@ -112,43 +100,31 @@ class LocalResourceManager // MESSAGES @override - Future> getMessages({ - DateTime? start, - DateTime? end, - int? count = 20, - }) async { + Future> getMessages({DateTime? start, DateTime? end, int? count = 20}) async { if (_messages.isEmpty) { final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle); - final files = assetManifest - .listAssets() - .where((string) => string.startsWith("$basePath/messages/")) - .toList(); + final files = assetManifest.listAssets().where((string) => string.startsWith("$basePath/messages/")).toList(); for (var file in files) { var jsonString = await rootBundle.loadString(file); - Map jsonMap = - json.decode(jsonString) as Map; + Map jsonMap = json.decode(jsonString) as Map; var message = Message.fromJson(jsonMap); _messages[message.id] = message; } } - return _messages.values - .toList() - .sublist(0, (count! < _messages.length) ? count : _messages.length); + return _messages.values.toList().sublist(0, (count! < _messages.length) ? count : _messages.length); } @override Future getMessage(String messageId) async => _messages[messageId]; @override - Future setMessage(Message message) async => - _messages[message.id] = message; + Future setMessage(Message message) async => _messages[message.id] = message; @override - Future deleteMessage(String messageId) async => - _messages.remove(messageId); + Future deleteMessage(String messageId) async => _messages.remove(messageId); @override Future deleteAllMessages() async => _messages.clear(); @@ -159,28 +135,29 @@ class LocalResourceManager Future getStudyProtocol(String id) async { if (_protocol == null) { try { - var jsonString = - await rootBundle.loadString('$basePath/resources/protocol.json'); + var jsonString = await rootBundle.loadString('$basePath/resources/protocol.json'); - Map jsonMap = - json.decode(jsonString) as Map; + Map jsonMap = json.decode(jsonString) as Map; _protocol = SmartphoneStudyProtocol.fromJson(jsonMap); if (_protocol?.dataEndPoint?.type != null) { if (!(_protocol!.dataEndPoint!.type == DataEndPointTypes.FILE || _protocol!.dataEndPoint!.type == DataEndPointTypes.SQLITE)) { warning( - "$runtimeType - Local protocol is trying to use a non-local data endpoint of type: '${_protocol!.dataEndPoint!.type}'. " - "This will not work. Replacing this data endpoint to use a local SQLite backend instead. " - "You can also change this in the local protocol stored in the 'carp/resources/protocol.json' file."); + "$runtimeType - Local protocol is trying to use a non-local data endpoint of type: '${_protocol!.dataEndPoint!.type}'. " + "This will not work. Replacing this data endpoint to use a local SQLite backend instead. " + "You can also change this in the local protocol stored in the 'carp/resources/protocol.json' file.", + ); _protocol!.dataEndPoint = SQLiteDataEndPoint(); } } } catch (error) { - warning("$runtimeType - Could not load a local study protocol. " - "It should be added as an asset resource in 'carp/resources/protocol.json'.\n" - "Error: $error"); + warning( + "$runtimeType - Could not load a local study protocol. " + "It should be added as an asset resource in 'carp/resources/protocol.json'.\n" + "Error: $error", + ); } } return _protocol; diff --git a/lib/data/local_settings.dart b/lib/data/local_settings.dart index 086461d5..120013d7 100644 --- a/lib/data/local_settings.dart +++ b/lib/data/local_settings.dart @@ -14,9 +14,6 @@ class LocalSettings { /// See https://developer.android.com/health-and-fitness/guides/health-connect/develop/get-started#get-client static const healthConnectPackageName = 'com.google.android.apps.healthdata'; - bool isExpectedParticipantDataSet = false; - bool hasUserSeenDeviceConnectionInstructions = false; - // Keys for storing in shared preferences static const String userKey = 'user'; static const String participantKey = 'participant'; @@ -26,8 +23,6 @@ class LocalSettings { Participant? _participant; SmartphoneStudy? _study; - bool hasSeenBluetoothConnectionInstructions = false; - static final LocalSettings _instance = LocalSettings._(); factory LocalSettings() => _instance; LocalSettings._() : super(); @@ -40,9 +35,7 @@ class LocalSettings { if (_user == null) { String? userString = Settings().preferences!.getString(userKey); - _user = (userString != null) - ? CarpUser.fromJson(jsonDecode(userString) as Map) - : null; + _user = (userString != null) ? CarpUser.fromJson(jsonDecode(userString) as Map) : null; } return _user; } @@ -63,13 +56,11 @@ class LocalSettings { /// study by using a specific device. /// /// The [participant] is typically set based on an invitation set in the - /// [StudyAppBLoC.setStudyInvitation] method. + /// [AppBloc.setStudyInvitation] method. Participant? get participant { if (_participant == null) { String? userString = Settings().preferences!.getString(participantKey); - _participant = (userString != null) - ? Participant.fromJson(jsonDecode(userString) as Map) - : null; + _participant = (userString != null) ? Participant.fromJson(jsonDecode(userString) as Map) : null; } return _participant; } @@ -77,9 +68,7 @@ class LocalSettings { set participant(Participant? participant) { _participant = participant; if (participant != null) { - Settings() - .preferences! - .setString(participantKey, jsonEncode(participant.toJson())); + Settings().preferences!.setString(participantKey, jsonEncode(participant.toJson())); } else { Settings().preferences!.remove(participantKey); } @@ -93,37 +82,28 @@ class LocalSettings { var jsonString = Settings().preferences?.getString(studyKey); return _study = (jsonString == null) ? null - : _$SmartphoneStudyFromJson( - json.decode(jsonString) as Map); + : _$SmartphoneStudyFromJson(json.decode(jsonString) as Map); } set study(SmartphoneStudy? study) { assert( - study != null, - 'Cannot set the study to null in Settings. ' - "Use the 'eraseStudyDeployment()' method to erase study deployment information."); + study != null, + 'Cannot set the study to null in Settings. ' + "Use the 'eraseStudyDeployment()' method to erase study deployment information.", + ); _study = study; - Settings().preferences?.setString( - studyKey, - json.encode(_$SmartphoneStudyToJson(study!)), - ); + Settings().preferences?.setString(studyKey, json.encode(_$SmartphoneStudyToJson(study!))); } - bool get hasSeenConnectionInstructions => - hasSeenBluetoothConnectionInstructions; + bool get hasSeenBluetoothConnectionInstructions => + Settings().preferences?.getBool('hasSeenBluetoothConnectionInstructions') ?? false; - set hasSeenConnectionInstructions(bool seen) { - hasSeenBluetoothConnectionInstructions = seen; - Settings().preferences?.setBool( - 'hasSeenBluetoothConnectionInstructions', - seen, - ); + set hasSeenBluetoothConnectionInstructions(bool seen) { + Settings().preferences?.setBool('hasSeenBluetoothConnectionInstructions', seen); } - bool get isAnonymous => - Settings().preferences!.getBool('isAnonymous') ?? false; - set isAnonymous(bool value) => - Settings().preferences!.setBool('isAnonymous', value); + bool get isAnonymous => Settings().preferences!.getBool('isAnonymous') ?? false; + set isAnonymous(bool value) => Settings().preferences!.setBool('isAnonymous', value); /// The study deployment id for the currently running deployment. String? get studyDeploymentId => _study?.studyDeploymentId; @@ -145,30 +125,26 @@ class LocalSettings { await Settings().preferences!.remove(userKey); } - Future get deploymentBasePath async => (studyDeploymentId == null) - ? null - : await Settings().getDeploymentBasePath(studyDeploymentId!); + Future get deploymentBasePath async => + (studyDeploymentId == null) ? null : await Settings().getDeploymentBasePath(studyDeploymentId!); - Future get cacheBasePath async => (studyDeploymentId == null) - ? null - : await Settings().getCacheBasePath(studyDeploymentId!); + Future get cacheBasePath async => + (studyDeploymentId == null) ? null : await Settings().getCacheBasePath(studyDeploymentId!); } // Need to create our own JSON serializers here, since SmartphoneStudy is not made serializable -Map _$SmartphoneStudyToJson(SmartphoneStudy study) => - { - 'studyId': study.studyId, - 'studyDeploymentId': study.studyDeploymentId, - 'deviceRoleName': study.deviceRoleName, - 'participantId': study.participantId, - 'participantRoleName': study.participantRoleName, - }; - -SmartphoneStudy _$SmartphoneStudyFromJson(Map json) => - SmartphoneStudy( - studyId: json['studyId'] as String?, - studyDeploymentId: json['studyDeploymentId'] as String, - deviceRoleName: json['deviceRoleName'] as String, - participantId: json['participantId'] as String?, - participantRoleName: json['participantRoleName'] as String?, - ); +Map _$SmartphoneStudyToJson(SmartphoneStudy study) => { + 'studyId': study.studyId, + 'studyDeploymentId': study.studyDeploymentId, + 'deviceRoleName': study.deviceRoleName, + 'participantId': study.participantId, + 'participantRoleName': study.participantRoleName, +}; + +SmartphoneStudy _$SmartphoneStudyFromJson(Map json) => SmartphoneStudy( + studyId: json['studyId'] as String?, + studyDeploymentId: json['studyDeploymentId'] as String, + deviceRoleName: json['deviceRoleName'] as String, + participantId: json['participantId'] as String?, + participantRoleName: json['participantRoleName'] as String?, +); diff --git a/lib/data/localization_loader.dart b/lib/data/localization_loader.dart index 4f7c81e1..5ec67c9b 100644 --- a/lib/data/localization_loader.dart +++ b/lib/data/localization_loader.dart @@ -15,8 +15,7 @@ class ResourceLocalizationLoader implements LocalizationLoader { translations = await localizationManager.getLocalizations(locale) ?? {}; info("$runtimeType - translations for ´$locale' loaded."); } catch (error) { - warning( - "$runtimeType - could not load translations for '$locale' - $error"); + warning("$runtimeType - could not load translations for '$locale' - $error"); } return translations; diff --git a/lib/data/participant.dart b/lib/data/participant.dart index df71905e..63a0eec2 100644 --- a/lib/data/participant.dart +++ b/lib/data/participant.dart @@ -22,18 +22,15 @@ class Participant { this.hasInformedConsentBeenAccepted = false, }); - Participant.fromParticipationInvitation( - ActiveParticipationInvitation invitation, - ) : this( - studyId: invitation.studyId, - studyDeploymentId: invitation.studyDeploymentId, - deviceRoleName: invitation.assignedDevices?.first.device.roleName, - participantId: invitation.participation.participantId, - participantRoleName: - invitation.participation.assignedRoles.roleNames?.first, - ); + Participant.fromParticipationInvitation(ActiveParticipationInvitation invitation) + : this( + studyId: invitation.studyId, + studyDeploymentId: invitation.studyDeploymentId, + deviceRoleName: invitation.assignedDevices?.first.device.roleName, + participantId: invitation.participation.participantId, + participantRoleName: invitation.participation.assignedRoles.roleNames?.first, + ); - factory Participant.fromJson(Map json) => - _$ParticipantFromJson(json); + factory Participant.fromJson(Map json) => _$ParticipantFromJson(json); Map toJson() => _$ParticipantToJson(this); } diff --git a/lib/main.dart b/lib/main.dart index 904ffa4f..335a4d9d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'dart:io'; import 'package:app_version_update/data/models/app_version_result.dart'; import 'package:async/async.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -38,7 +39,8 @@ import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart' as qr; // the CARP packages import 'package:carp_serializable/carp_serializable.dart'; -import 'package:carp_core/carp_core.dart'; +// Both carp_core and carp_mobile_sensing export `Smartphone`; hide carp_core's. +import 'package:carp_core/carp_core.dart' hide Smartphone; import 'package:carp_mobile_sensing/carp_mobile_sensing.dart'; import 'package:carp_audio_package/media.dart'; //import 'package:carp_connectivity_package/connectivity.dart'; @@ -55,11 +57,21 @@ import 'package:cognition_package/cognition_package.dart'; import 'package:carp_health_package/health_package.dart'; // import 'package:health/health.dart'; import 'package:carp_movesense_package/carp_movesense_package.dart'; +import 'package:carp_themes_package/carp_themes_package.dart'; part 'blocs/app_bloc.dart'; +part 'blocs/app_config.dart'; +part 'blocs/app_log.dart'; part 'blocs/util.dart'; part 'blocs/sensing.dart'; +part 'services/resource_manager_factory.dart'; +part 'services/system_info_service.dart'; +part 'services/auth_service.dart'; +part 'services/study_service.dart'; +part 'services/message_service.dart'; +part 'services/consent_service.dart'; + part 'data/local_settings.dart'; part 'data/carp_backend.dart'; part 'data/localization_loader.dart'; @@ -68,6 +80,8 @@ part 'data/participant.dart'; part 'data/local_participation_service.dart'; part 'view_models/view_model.dart'; +part 'view_models/home_page_model.dart'; +part 'view_models/login_page_model.dart'; part 'view_models/tasklist_page_model.dart'; part 'view_models/study_page_model.dart'; part 'view_models/profile_page_model.dart'; @@ -91,6 +105,7 @@ part 'ui/pages/home_page.dart'; part 'ui/pages/home_page.install_health_connect_dialog.dart'; part 'ui/carp_study_style.dart'; part 'ui/colors.dart'; +part 'ui/helpers.dart'; part 'ui/pages/data_visualization_page.dart'; part 'ui/pages/study_page.dart'; part 'ui/pages/task_list_page.dart'; @@ -105,14 +120,13 @@ part 'ui/pages/devices_page.enable_bluetooth_dialog.dart'; part 'ui/pages/devices_page.bluetooth_connection_page.dart'; part 'ui/pages/devices_page.disconnection_dialog.dart'; part 'ui/pages/devices_page.list_title.dart'; -part 'ui/pages/devices_page.health_service_connect1.dart'; -part 'ui/pages/devices_page.health_service_connect2.dart'; +part 'ui/pages/devices_page.health_service_connect.dart'; part 'ui/tasks/audio_task_page.dart'; part 'ui/tasks/audio_page.dart'; part 'ui/pages/study_details_page.dart'; part 'ui/pages/message_details_page.dart'; -part 'ui/pages/invitation_page.dart'; +part 'ui/pages/invitation_details_page.dart'; part 'ui/pages/invitation_list_page.dart'; part 'ui/pages/process_message_page.dart'; part 'ui/tasks/camera_task_page.dart'; @@ -122,7 +136,6 @@ part 'ui/tasks/camera_page.dart'; part 'ui/widgets/carp_app_bar.dart'; part 'ui/widgets/horizontal_bar.dart'; -part 'ui/widgets/location_permission_page.dart'; part 'ui/widgets/charts_legend.dart'; part 'ui/widgets/details_banner.dart'; part 'ui/widgets/studies_material.dart'; @@ -146,16 +159,16 @@ part 'main.g.dart'; late CarpStudyApp app; void main() async { - // Initialize CAMS and related packages (loading json deserialization functions) - CarpMobileSensing.ensureInitialized(); - CognitionPackage.ensureInitialized(); - CarpDataManager.ensureInitialized(); - // Make sure to have an instance of the WidgetsBinding, which is required // to use platform channels to call native code. // See also >> https://stackoverflow.com/questions/63873338/what-does-widgetsflutterbinding-ensureinitialized-do/63873689 WidgetsFlutterBinding.ensureInitialized(); + // Initialize CAMS and related packages (loading json deserialization functions) + CarpMobileSensing.ensureInitialized(); + CognitionPackage.ensureInitialized(); + CarpDataManager.ensureInitialized(); + await bloc.initialize(); app = const CarpStudyApp(); @@ -163,4 +176,4 @@ void main() async { } /// The singleton BLoC. -final bloc = StudyAppBLoC(); +final bloc = AppBloc(); diff --git a/lib/main.g.dart b/lib/main.g.dart index cb297781..95750247 100644 --- a/lib/main.g.dart +++ b/lib/main.g.dart @@ -7,43 +7,37 @@ part of 'main.dart'; // ************************************************************************** Participant _$ParticipantFromJson(Map json) => Participant( - studyId: json['studyId'] as String?, - studyDeploymentId: json['studyDeploymentId'] as String?, - deviceRoleName: json['deviceRoleName'] as String?, - participantId: json['participantId'] as String?, - participantRoleName: json['participantRoleName'] as String?, - hasInformedConsentBeenAccepted: - json['hasInformedConsentBeenAccepted'] as bool? ?? false, - ); - -Map _$ParticipantToJson(Participant instance) => - { - if (instance.studyId case final value?) 'studyId': value, - if (instance.studyDeploymentId case final value?) - 'studyDeploymentId': value, - if (instance.deviceRoleName case final value?) 'deviceRoleName': value, - if (instance.participantId case final value?) 'participantId': value, - if (instance.participantRoleName case final value?) - 'participantRoleName': value, - 'hasInformedConsentBeenAccepted': instance.hasInformedConsentBeenAccepted, - }; + studyId: json['studyId'] as String?, + studyDeploymentId: json['studyDeploymentId'] as String?, + deviceRoleName: json['deviceRoleName'] as String?, + participantId: json['participantId'] as String?, + participantRoleName: json['participantRoleName'] as String?, + hasInformedConsentBeenAccepted: json['hasInformedConsentBeenAccepted'] as bool? ?? false, +); + +Map _$ParticipantToJson(Participant instance) => { + 'studyId': ?instance.studyId, + 'studyDeploymentId': ?instance.studyDeploymentId, + 'deviceRoleName': ?instance.deviceRoleName, + 'participantId': ?instance.participantId, + 'participantRoleName': ?instance.participantRoleName, + 'hasInformedConsentBeenAccepted': instance.hasInformedConsentBeenAccepted, +}; WeeklyActivities _$WeeklyActivitiesFromJson(Map json) => WeeklyActivities() ..activities = (json['activities'] as Map).map( (k, e) => MapEntry( - $enumDecode(_$ActivityTypeEnumMap, k), - (e as Map).map( - (k, e) => MapEntry(int.parse(k), (e as num).toInt()), - )), + $enumDecode(_$ActivityTypeEnumMap, k), + (e as Map).map((k, e) => MapEntry(int.parse(k), (e as num).toInt())), + ), ); -Map _$WeeklyActivitiesToJson(WeeklyActivities instance) => - { - 'activities': instance.activities.map((k, e) => MapEntry( - _$ActivityTypeEnumMap[k]!, - e.map((k, e) => MapEntry(k.toString(), e)))), - }; +Map _$WeeklyActivitiesToJson(WeeklyActivities instance) => { + 'activities': instance.activities.map( + (k, e) => MapEntry(_$ActivityTypeEnumMap[k]!, e.map((k, e) => MapEntry(k.toString(), e))), + ), +}; const _$ActivityTypeEnumMap = { ActivityType.IN_VEHICLE: 'IN_VEHICLE', @@ -54,75 +48,57 @@ const _$ActivityTypeEnumMap = { ActivityType.UNKNOWN: 'UNKNOWN', }; -WeeklyMobility _$WeeklyMobilityFromJson(Map json) => - WeeklyMobility() - ..weekMobility = (json['weekMobility'] as Map).map( - (k, e) => MapEntry( - int.parse(k), DailyMobility.fromJson(e as Map)), - ); +WeeklyMobility _$WeeklyMobilityFromJson(Map json) => WeeklyMobility() + ..weekMobility = (json['weekMobility'] as Map).map( + (k, e) => MapEntry(int.parse(k), DailyMobility.fromJson(e as Map)), + ); + +Map _$WeeklyMobilityToJson(WeeklyMobility instance) => { + 'weekMobility': instance.weekMobility.map((k, e) => MapEntry(k.toString(), e)), +}; -Map _$WeeklyMobilityToJson(WeeklyMobility instance) => - { - 'weekMobility': - instance.weekMobility.map((k, e) => MapEntry(k.toString(), e)), - }; - -DailyMobility _$DailyMobilityFromJson(Map json) => - DailyMobility( - (json['weekday'] as num).toInt(), - (json['places'] as num).toInt(), - (json['homeStay'] as num).toInt(), - (json['distance'] as num).toDouble(), - ); - -Map _$DailyMobilityToJson(DailyMobility instance) => - { - 'weekday': instance.weekday, - 'places': instance.places, - 'homeStay': instance.homeStay, - 'distance': instance.distance, - }; +DailyMobility _$DailyMobilityFromJson(Map json) => DailyMobility( + (json['weekday'] as num).toInt(), + (json['places'] as num).toInt(), + (json['homeStay'] as num).toInt(), + (json['distance'] as num).toDouble(), +); + +Map _$DailyMobilityToJson(DailyMobility instance) => { + 'weekday': instance.weekday, + 'places': instance.places, + 'homeStay': instance.homeStay, + 'distance': instance.distance, +}; WeeklySteps _$WeeklyStepsFromJson(Map json) => WeeklySteps() ..weeklySteps = (json['weeklySteps'] as Map).map( (k, e) => MapEntry(int.parse(k), (e as num).toInt()), ); -Map _$WeeklyStepsToJson(WeeklySteps instance) => - { - 'weeklySteps': - instance.weeklySteps.map((k, e) => MapEntry(k.toString(), e)), - }; - -HourlyHeartRate _$HourlyHeartRateFromJson(Map json) => - HourlyHeartRate() - ..hourlyHeartRate = (json['hourlyHeartRate'] as Map).map( - (k, e) => MapEntry(int.parse(k), - HeartRateMinMaxPrHour.fromJson(e as Map)), - ) - ..lastUpdated = DateTime.parse(json['lastUpdated'] as String) - ..maxHeartRate = (json['maxHeartRate'] as num?)?.toDouble() - ..minHeartRate = (json['minHeartRate'] as num?)?.toDouble(); - -Map _$HourlyHeartRateToJson(HourlyHeartRate instance) => - { - 'hourlyHeartRate': - instance.hourlyHeartRate.map((k, e) => MapEntry(k.toString(), e)), - 'lastUpdated': instance.lastUpdated.toIso8601String(), - if (instance.maxHeartRate case final value?) 'maxHeartRate': value, - if (instance.minHeartRate case final value?) 'minHeartRate': value, - }; - -HeartRateMinMaxPrHour _$HeartRateMinMaxPrHourFromJson( - Map json) => - HeartRateMinMaxPrHour( - (json['min'] as num?)?.toDouble(), - (json['max'] as num?)?.toDouble(), - ); - -Map _$HeartRateMinMaxPrHourToJson( - HeartRateMinMaxPrHour instance) => - { - if (instance.min case final value?) 'min': value, - if (instance.max case final value?) 'max': value, - }; +Map _$WeeklyStepsToJson(WeeklySteps instance) => { + 'weeklySteps': instance.weeklySteps.map((k, e) => MapEntry(k.toString(), e)), +}; + +HourlyHeartRate _$HourlyHeartRateFromJson(Map json) => HourlyHeartRate() + ..hourlyHeartRate = (json['hourlyHeartRate'] as Map).map( + (k, e) => MapEntry(int.parse(k), HeartRateMinMaxPrHour.fromJson(e as Map)), + ) + ..lastUpdated = DateTime.parse(json['lastUpdated'] as String) + ..maxHeartRate = (json['maxHeartRate'] as num?)?.toDouble() + ..minHeartRate = (json['minHeartRate'] as num?)?.toDouble(); + +Map _$HourlyHeartRateToJson(HourlyHeartRate instance) => { + 'hourlyHeartRate': instance.hourlyHeartRate.map((k, e) => MapEntry(k.toString(), e)), + 'lastUpdated': instance.lastUpdated.toIso8601String(), + 'maxHeartRate': ?instance.maxHeartRate, + 'minHeartRate': ?instance.minHeartRate, +}; + +HeartRateMinMaxPrHour _$HeartRateMinMaxPrHourFromJson(Map json) => + HeartRateMinMaxPrHour((json['min'] as num?)?.toDouble(), (json['max'] as num?)?.toDouble()); + +Map _$HeartRateMinMaxPrHourToJson(HeartRateMinMaxPrHour instance) => { + 'min': ?instance.min, + 'max': ?instance.max, +}; diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 00000000..49bb86ca --- /dev/null +++ b/lib/services/auth_service.dart @@ -0,0 +1,57 @@ +part of carp_study_app; + +/// User identity and authentication, wrapping the [CarpBackend] so the rest +/// of the app does not depend on the CAWS SDK types directly. +class AuthService { + AuthService({CarpBackend? backend}) : _backend = backend ?? CarpBackend(); + + final CarpBackend _backend; + + /// Initialize the CAWS backend. Must be called before authentication. + Future initialize() => _backend.initialize(); + + /// Has the user been authenticated? + bool get isAuthenticated => _backend.isAuthenticated; + + /// Did the user authenticate anonymously (via a magic link / QR code) + /// rather than a full CAWS login? + bool get isAnonymous => LocalSettings().isAnonymous; + + /// The signed in user. Returns null if no user is signed in. + CarpUser? get user => _backend.user; + + /// The username of the signed in user. + /// Returns an empty string if no user is signed in. + String get username => user?.username ?? ''; + + /// The name used for friendly greeting. + /// Returns an empty string if no user is signed in. + String get friendlyUsername => user?.firstName ?? ''; + + /// The URI of the CAWS server used in this deployment. + Uri get serverUri => _backend.uri; + + List _invitations = []; + + /// The list of invitations for this user, as last fetched by [getInvitations]. + List get invitations => _invitations; + + /// Get / refresh the list of active invitations for this user from CAWS, + /// keeping only invitations assigned to a smartphone (and not, e.g., a + /// web browser). + Future> getInvitations() async { + final all = await _backend.getInvitations(); + return _invitations = all + .where((invitation) => invitation.assignedDevices?.any((device) => device.device is! Smartphone) != true) + .toList(); + } + + /// Authenticate using a web view. + Future authenticate() => _backend.authenticate(); + + /// Authenticate anonymously using a magic link. + Future authenticateWithMagicLink(String uri) => _backend.authenticateWithMagicLink(uri); + + /// Sign out from CAWS and erase all local authentication information. + Future signOut() => _backend.signOut(); +} diff --git a/lib/services/consent_service.dart b/lib/services/consent_service.dart new file mode 100644 index 00000000..007fb60c --- /dev/null +++ b/lib/services/consent_service.dart @@ -0,0 +1,68 @@ +part of carp_study_app; + +/// Manages the informed consent flow: fetching the consent document, checking +/// whether consent has been given, and accepting consent. +/// +/// Notifies its listeners when consent is accepted. Listen via the global +/// [bloc], which chains service notifications to the router. +class ConsentService extends ChangeNotifier { + ConsentService(this._manager, {CarpBackend? backend}) : _backend = backend ?? CarpBackend(); + + final InformedConsentManager _manager; + final CarpBackend _backend; + bool? _accepted; + + /// Get the informed consent document for this study. + Future getDocument({bool refresh = false}) => _manager.getConsentDocument(refresh: refresh); + + /// The last known consent status, as cached by [refreshStatus] or [accept]. + /// Null until the status has been checked. Used by the router redirect, + /// which needs a synchronous answer. + bool? get isAccepted => _accepted; + + /// Check [hasBeenAccepted] for [study], cache the answer in [isAccepted], + /// and notify. The coordinator supplies the active study. + Future refreshStatus(SmartphoneStudy? study) async { + logApp('ConsentService.refreshStatus() START - deploymentId=${study?.studyDeploymentId}'); + _accepted = await hasBeenAccepted(study); + logApp('ConsentService.refreshStatus() DONE - isAccepted=$_accepted'); + notifyListeners(); + return _accepted!; + } + + /// Forget the cached consent status, e.g. when leaving a study. + void reset() => _accepted = null; + + /// Has the informed consent been accepted by the user for [study]? + /// + /// Consent is tied to the account, not the device, so the backend is the + /// single source of truth in non-local deployments. Local mode has no + /// backend and falls back to the locally stored flag. + Future hasBeenAccepted(SmartphoneStudy? study) async { + if (AppConfig.deploymentMode == DeploymentMode.local || study == null) { + return LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false; + } + try { + final consent = await _backend.getInformedConsentByRole(study.studyDeploymentId, study.participantRoleName); + return consent != null; + } catch (e) { + warning('Could not fetch informed consent status from backend: $e'); + return false; + } + } + + /// Mark the informed consent as accepted: persist locally and (when online) + /// upload the signed [result] to CAWS. Pass `null` when the study has no + /// consent document - only the local flag is set. + Future accept([RPTaskResult? result]) async { + info('Informed consent has been accepted by user.'); + var participant = LocalSettings().participant; + participant?.hasInformedConsentBeenAccepted = true; + LocalSettings().participant = participant; + if (result != null && AppConfig.deploymentMode != DeploymentMode.local) { + await _backend.uploadInformedConsent(result); + } + _accepted = true; + notifyListeners(); + } +} diff --git a/lib/services/message_service.dart b/lib/services/message_service.dart new file mode 100644 index 00000000..0ce3fb4e --- /dev/null +++ b/lib/services/message_service.dart @@ -0,0 +1,56 @@ +part of carp_study_app; + +/// Holds the list of messages (news, announcements, articles) shown in the +/// app and keeps it refreshed, owning the periodic polling timer. +class MessageService { + MessageService(this._manager, {Duration pollingInterval = const Duration(minutes: 30)}) + : _pollingInterval = pollingInterval; + + final MessageManager _manager; + final Duration _pollingInterval; + final StreamController _streamController = StreamController.broadcast(); + List _messages = []; + Timer? _pollingTimer; + + /// The list of currently available messages, newest first. + List get messages => _messages; + + /// A stream of events when the list of [messages] is updated. + /// The data sent on the stream is the number of available messages. + Stream get stream => _streamController.stream; + + /// The message with the given [id], or null if not found. + Message? byId(String id) => _messages.where((message) => message.id == id).firstOrNull; + + /// Initialize the message manager, fetch messages, and start polling for + /// new messages on a regular basis. Safe to call more than once. + void start() { + _manager.initialize(); + refresh(); + _pollingTimer?.cancel(); + _pollingTimer = Timer.periodic(_pollingInterval, (_) => refresh()); + } + + /// Stop polling for new messages. + void stop() { + _pollingTimer?.cancel(); + _pollingTimer = null; + } + + /// Refresh the list of messages. + Future refresh() async { + try { + _messages = await _manager.getMessages(); + _messages.sort((m1, m2) => m2.timestamp.compareTo(m1.timestamp)); + info('Message list refreshed - count: ${_messages.length}'); + } catch (error) { + warning('Error getting messages - $error'); + } + if (!_streamController.isClosed) _streamController.add(_messages.length); + } + + void dispose() { + stop(); + _streamController.close(); + } +} diff --git a/lib/services/resource_manager_factory.dart b/lib/services/resource_manager_factory.dart new file mode 100644 index 00000000..5c0b9705 --- /dev/null +++ b/lib/services/resource_manager_factory.dart @@ -0,0 +1,24 @@ +part of carp_study_app; + +/// Provides the resource managers matching the current [DeploymentMode]: +/// local resources in [DeploymentMode.local], CAWS-backed resources otherwise. +/// +/// Instances are created once and cached. +class ResourceManagerFactory { + bool get _local => AppConfig.deploymentMode == DeploymentMode.local; + + late final LocalizationManager localizationManager = + (_local ? LocalResourceManager() : CarpResourceManager()) as LocalizationManager; + + late final LocalizationLoader localizationLoader = ResourceLocalizationLoader(localizationManager); + + late final MessageManager messageManager = + (_local ? LocalResourceManager() : CarpResourceManager()) as MessageManager; + + late final InformedConsentManager informedConsentManager = + (_local ? LocalResourceManager() : CarpResourceManager()) as InformedConsentManager; + + late final ParticipationService participationService = _local + ? LocalParticipationService() + : CarpParticipationService(); +} diff --git a/lib/services/study_service.dart b/lib/services/study_service.dart new file mode 100644 index 00000000..5fcc2df6 --- /dev/null +++ b/lib/services/study_service.dart @@ -0,0 +1,250 @@ +part of carp_study_app; + +/// Manages the study running on this phone: the study descriptor, its +/// deployment, and the sensing runtime for it. +/// +/// This service is the single owner of the active study. The persisted copy +/// in [LocalSettings] and the CAWS service copies are seeded from the [study] +/// setter - do not set them directly. +class StudyService { + StudyService({ResourceManagerFactory? resources}) : _resources = resources ?? ResourceManagerFactory(); + + final ResourceManagerFactory _resources; + SmartphoneStudyController? _controller; + StudyDeploymentStatus? _status; + + /// The deployment service used in this app, selected by the deployment mode. + DeploymentService get deploymentService => + AppConfig.deploymentMode == DeploymentMode.local ? SmartphoneDeploymentService() : CarpDeploymentService(); + + /// The study running on this phone, typically set based on an invitation. + /// Returns null if no study has been selected (yet). + SmartphoneStudy? get study => LocalSettings().study; + + /// Set the active [study], persisting it locally and seeding the CAWS + /// services with it (in non-local deployments). + set study(SmartphoneStudy? study) { + if (study == null) return; + LocalSettings().study = study; + if (AppConfig.deploymentMode != DeploymentMode.local) CarpBackend().study = study; + } + + /// Has a study been selected on this phone (i.e., an invitation accepted)? + /// + /// Note that this does NOT imply that the study deployment has succeeded - + /// see [isDeployed] for that. + bool get hasStudy => study != null; + + /// Has the study deployment succeeded on this phone, i.e. has the device + /// deployment been received and validated? + bool get isDeployed => deployment != null; + + /// The study runtime controller for the current study. + /// Only available after the study has been added via [configure]. + SmartphoneStudyController? get controller => _controller; + + /// The deployment running on this phone. + /// Returns null if the study has not (yet) been deployed. + SmartphoneDeployment? get deployment => _controller?.deployment; + + /// When was this study deployed on this phone. + DateTime? get studyStartTimestamp => deployment?.deployed; + + Set get expectedParticipantData => deployment?.expectedParticipantData ?? {}; + + /// Refresh and return the status of the current study deployment from the + /// deployment service. Returns null if no study has been deployed. + Future refreshDeploymentStatus() async { + final id = study?.studyDeploymentId; + return id != null ? _status = await deploymentService.getStudyDeploymentStatus(id) : null; + } + + /// The last known status of the study deployment, without contacting the + /// deployment service. Use [refreshDeploymentStatus] to refresh it. + StudyDeploymentStatus? get cachedDeploymentStatus => _status; + + /// Initialize sensing and deploy the [study] on this phone. + /// + /// Throws if no study is set, or if deployment does not succeed - in which + /// case it is safe to call this method again (e.g., once back online). + Future configure() async { + if (study == null) throw StateError('No study set - cannot configure a study deployment.'); + logApp('StudyService.configure() - deploymentId=${study!.studyDeploymentId}, deviceRole=${study!.deviceRoleName}'); + + final status = await addStudy(study!); + + logApp('StudyService.configure() - addStudy returned status=$status, isDeployed=$isDeployed'); + if (!isDeployed) throw StateError('Study deployment did not succeed - status: $status.'); + } + + /// Add the [study] to the client manager and deploy it. + Future addStudy(SmartphoneStudy study) async { + assert( + SmartPhoneClientManager().isConfigured, + 'The client manager is not yet configured. Call Sensing().initialize() before adding a study.', + ); + + await SmartPhoneClientManager().addStudy(study); + return await tryDeployment(); + } + + /// Try to deploy the current study. + /// + /// Note that if the study has already been deployed on this phone it has + /// been cached locally and the local version will be used pr. default. + /// If not deployed before the study deployment will be fetched from the + /// deployment service. Returns null if no study has been selected yet. + Future tryDeployment() async { + if (study == null) return null; + + final status = await SmartPhoneClientManager().tryDeployment(study!.studyDeploymentId, study!.deviceRoleName); + _controller = SmartPhoneClientManager().getStudyController(study!); + translateProtocol(); + + info('$runtimeType - Study added, deployment id: ${study!.studyDeploymentId}'); + return status; + } + + /// Is sensing running, i.e. has the study executor been resumed? + bool get isRunning => (_controller != null) && _controller!.executor.state == ExecutorState.Resumed; + + /// Start sensing, if the study is deployed and not permanently stopped. + Future start() async { + final controller = _controller; + if (controller == null || !isDeployed || controller.study.status == StudyStatus.Stopped) { + warning( + '$runtimeType - Cannot start sensing - the study is not deployed ' + '(status: ${controller?.study.status}).', + ); + return; + } + + // The controller initializes its executor asynchronously after the device + // deployment is received, and resume() before that is silently ignored - + // so wait for the executor to be ready. + const retryDelay = Duration(milliseconds: 100); + var waited = Duration.zero; + while (controller.executor.state == ExecutorState.Created && waited < const Duration(seconds: 30)) { + await Future.delayed(retryDelay); + waited += retryDelay; + } + if (controller.executor.state == ExecutorState.Created) { + warning('$runtimeType - Cannot start sensing - the study executor was never initialized.'); + return; + } + + if (!isRunning) controller.resume(); + } + + /// Add [measurement] to the stream of collected measurements. + void addMeasurement(Measurement measurement) => _controller?.executor.addMeasurement(measurement); + + /// The list of all devices used in the current deployment. + /// + /// Note that not all available devices on this phone may be used in the + /// current deployment. + Iterable get deploymentDevices => deployment == null + ? [] + : SmartPhoneClientManager().deviceController.devices.values + .where((manager) => deployment!.devices.any((device) => device.type == manager.deviceType)) + .map((manager) => DeviceViewModel(manager)); + + /// Does this [deployment] have any measures (besides app tasks)? + bool hasMeasures() => (deployment == null) + ? false + : (deployment!.measures.any( + (measure) => + (measure.type != AppTask.VIDEO_TYPE && + measure.type != AppTask.IMAGE_TYPE && + measure.type != AppTask.AUDIO_TYPE && + measure.type != AppTask.SURVEY_TYPE), + )); + + /// Does this [deployment] have the measure of type [type]? + bool hasMeasure(String type) => deployment?.measures.any((measure) => measure.type == type) ?? false; + + /// Does this [deployment] have any user tasks? + bool hasUserTasks() => (deployment == null) ? false : deployment!.tasks.whereType().isNotEmpty; + + /// Translate the title and description of all [AppTask]s in the current + /// deployment using [AppConfig.localization]. + void translateProtocol([RPLocalizations? localization]) { + AppConfig.localization ??= localization; + + // Fast out if no localization + if (AppConfig.localization == null) return; + + // Fast out, if not deployed or no protocol. + if (!(study?.isDeployed ?? false) || deployment == null) return; + + for (var task in deployment!.tasks) { + if (task is AppTask) { + task.title = AppConfig.localization!.translate(task.title); + task.description = AppConfig.localization!.translate(task.description); + } + } + + info("$runtimeType - Study protocol translated to locale '${AppConfig.localization!.locale}'"); + } + + /// Get the participant data for the current deployment. + Future> getParticipantDataListFromDeployment() async => (deployment == null) + ? [] + : await _resources.participationService.getParticipantDataList([deployment!.studyDeploymentId]); + + /// Set the participant [data] for the current study. + void setParticipantData(Map data) => + _resources.participationService.setParticipantData(study!.studyDeploymentId, data, study!.participantRoleName); + + /// Deploy the local protocol if running in local mode. + /// + /// We can run the app in local mode to debug a local protocol stored in + /// assets/carp/resources/protocol.json + /// + /// This method will deploy the protocol in the local SmartphoneDeploymentService + /// which later will be used for deployment. See [deploymentService]. + Future deployLocalProtocol() async { + if (AppConfig.deploymentMode != DeploymentMode.local) return; + + if (hasStudy) { + info( + 'Running in local deployment mode. Note that the local protocol has ' + 'already been deployed and the cached version will be loaded and used. ' + 'If you want to reload a modified protocol, delete the app with the ' + 'cached protocol from the phone before running it.', + ); + } else { + debug('$runtimeType - deploying local protocol'); + + // Get the protocol from the local study protocol manager. + // Note that the study id is not used since it always returns the same protocol. + var protocol = await LocalResourceManager().getStudyProtocol(''); + + // Deploy this protocol using the on-phone deployment service. + final status = await SmartphoneDeploymentService().createStudyDeployment(protocol!); + + // The primary device (the smartphone) is found in the device status list. + final primaryDeviceRoleName = status.deviceStatusList + .firstWhere((deviceStatus) => deviceStatus.device is PrimaryDeviceConfiguration) + .device + .roleName; + + // Save the participant and study on the phone for use across app restart. + LocalSettings().participant = Participant( + studyDeploymentId: status.studyDeploymentId, + deviceRoleName: primaryDeviceRoleName, + ); + study = SmartphoneStudy(studyDeploymentId: status.studyDeploymentId, deviceRoleName: primaryDeviceRoleName); + } + } + + /// Stop sensing and remove all study deployment information from this phone. + Future remove() async { + if (study != null) { + await SmartPhoneClientManager().removeStudy(study!.studyDeploymentId, study!.deviceRoleName); + } + _controller = null; + _status = null; + await LocalSettings().eraseStudyDeployment(); + } +} diff --git a/lib/services/system_info_service.dart b/lib/services/system_info_service.dart new file mode 100644 index 00000000..5b8ed606 --- /dev/null +++ b/lib/services/system_info_service.dart @@ -0,0 +1,44 @@ +part of carp_study_app; + +/// Device- and platform-level checks: connectivity, installed apps, and +/// app-store updates. +class SystemInfoService { + SystemInfoService({AppCheck? appCheck, Connectivity? connectivity}) + : _appCheck = appCheck ?? AppCheck(), + _connectivity = connectivity ?? Connectivity(); + + final AppCheck _appCheck; + final Connectivity _connectivity; + + /// Is the phone connected to the internet either via wifi or mobile network? + Future checkConnectivity() async { + final results = await _connectivity.checkConnectivity(); + return results.any((result) => result == ConnectivityResult.mobile || result == ConnectivityResult.wifi); + } + + /// Check if the Health database is installed on this phone. + /// + /// Always returns true on iOS, since Health is part of the OS and hence always installed. + /// On Android, returns true if Google Health Connect is installed, false otherwise. + Future isHealthInstalled() async { + if (Platform.isIOS) return true; + + try { + return await _appCheck.isAppInstalled(LocalSettings.healthConnectPackageName); + } catch (e) { + debug("$runtimeType - Error checking Health Connect installation: $e"); + return false; + } + } + + /// Is a newer version of this app available in the app store? + Future getAppHasUpdate() async { + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + AppVersionResult result = await AppVersionUpdate.checkForUpdates( + playStoreId: packageInfo.packageName, + appleId: '1569798025', + country: 'dk', + ); + return result.canUpdate; + } +} diff --git a/lib/ui/cards/activity_card.dart b/lib/ui/cards/activity_card.dart index 51bb84c8..1c48af92 100644 --- a/lib/ui/cards/activity_card.dart +++ b/lib/ui/cards/activity_card.dart @@ -3,9 +3,7 @@ part of carp_study_app; class ActivityCard extends StatefulWidget { final ActivityCardViewModel model; final List colors; - const ActivityCard(this.model, - {super.key, - this.colors = const [CACHET.CAQUI, CACHET.OCEAN, CACHET.BLUE_2]}); + const ActivityCard(this.model, {super.key, this.colors = const [CACHET.CAQUI, CACHET.OCEAN, CACHET.BLUE_2]}); @override State createState() => ActivityCardState(); @@ -22,17 +20,16 @@ class ActivityCardState extends State { final betweenSpace = 2.4; List> activitiesList = List.generate( - 7, (_) => List.generate(4, (index) => index, growable: false), - growable: false); + 7, + (_) => List.generate(4, (index) => index, growable: false), + growable: false, + ); @override void initState() { - _walk = - widget.model.activities[ActivityType.WALKING]![DateTime.now().weekday]; - _run = - widget.model.activities[ActivityType.RUNNING]![DateTime.now().weekday]; - _cycle = widget - .model.activities[ActivityType.ON_BICYCLE]![DateTime.now().weekday]; + _walk = widget.model.activities[ActivityType.WALKING]![DateTime.now().weekday]; + _run = widget.model.activities[ActivityType.RUNNING]![DateTime.now().weekday]; + _cycle = widget.model.activities[ActivityType.ON_BICYCLE]![DateTime.now().weekday]; /// Doing some conversions to make the data readable by the chart /// The data is organized in a list of lists, where each list represents a day @@ -63,7 +60,7 @@ class ActivityCardState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -72,17 +69,13 @@ class ActivityCardState extends State { children: [ Text( '${_walk! + _run! + _cycle!}', - style: dataVizCardTitleNumber.copyWith( - color: Theme.of(context).extension()!.grey900!, - ), + style: fs28fw700.copyWith(color: Theme.of(context).extension()!.grey900!), ), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( '${locale.translate('cards.activity.total.min')} ${_getDayName(touchedIndex)}', - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), ), ], @@ -91,9 +84,7 @@ class ActivityCardState extends State { children: [ Text( "${widget.model.currentMonth} ${widget.model.startOfWeek} - ${int.parse(widget.model.endOfWeek) < int.parse(widget.model.startOfWeek) ? widget.model.nextMonth : widget.model.currentMonth} ${widget.model.endOfWeek}, ${widget.model.currentYear}", - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), Spacer(), ], @@ -115,20 +106,12 @@ class ActivityCardState extends State { Expanded( child: Row( children: [ - Text( - '$_walk', - style: dataVizCardBottomNumber.copyWith( - color: widget.colors[0], - ), - ), + Text('$_walk', style: fs22fw700.copyWith(color: widget.colors[0])), Padding( padding: const EdgeInsets.all(4.0), child: Text( locale.translate('cards.activity.walking'), - style: dataVizCardBottomText.copyWith( - color: Theme.of(context) - .extension()! - .grey800), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), ), ), ], @@ -139,44 +122,28 @@ class ActivityCardState extends State { children: [ Padding( padding: const EdgeInsets.only(left: 8.0), - child: Text( - '$_run', - style: dataVizCardBottomNumber.copyWith( - color: widget.colors[1], - ), - ), + child: Text('$_run', style: fs12fw700.copyWith(color: widget.colors[1])), ), Padding( padding: const EdgeInsets.all(4.0), child: Text( locale.translate('cards.activity.running'), - style: dataVizCardBottomText.copyWith( - color: Theme.of(context) - .extension()! - .grey800), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), ), ), ], ), - ) + ), ], ), Row( children: [ - Text( - '$_cycle', - style: dataVizCardBottomNumber.copyWith( - color: widget.colors[2], - ), - ), + Text('$_cycle', style: fs22fw700.copyWith(color: widget.colors[2])), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( locale.translate('cards.activity.cycling'), - style: dataVizCardBottomText.copyWith( - color: - Theme.of(context).extension()!.grey800, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), ), ), ], @@ -195,19 +162,11 @@ class ActivityCardState extends State { alignment: BarChartAlignment.spaceAround, titlesData: FlTitlesData( bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: bottomTitles, - reservedSize: 20, - ), + sideTitles: SideTitles(showTitles: true, getTitlesWidget: bottomTitles, reservedSize: 20), ), leftTitles: const AxisTitles(), rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: rightTitles, - reservedSize: 48, - ), + sideTitles: SideTitles(showTitles: true, getTitlesWidget: rightTitles, reservedSize: 48), ), topTitles: const AxisTitles(), ), @@ -215,45 +174,27 @@ class ActivityCardState extends State { enabled: false, touchCallback: (p0, p1) { setState(() { - touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? - DateTime.now().weekday - 1) + - 1; + touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; }); }, ), groupsSpace: 4, - barGroups: activitiesList - .map((e) => generateGroupData(e[0], e[1], e[2], e[3])) - .toList(), + barGroups: activitiesList.map((e) => generateGroupData(e[0], e[1], e[2], e[3])).toList(), maxY: (maxValue) * 1.2, gridData: FlGridData( show: true, drawVerticalLine: false, drawHorizontalLine: true, getDrawingHorizontalLine: (value) { - return FlLine( - color: Colors.grey.withValues(alpha: 0.3), - strokeWidth: 1, - ); + return FlLine(color: Colors.grey.withValues(alpha: 0.3), strokeWidth: 1); }, ), - borderData: FlBorderData( - show: true, - border: Border.all( - width: 1, - color: Colors.grey.withValues(alpha: 0.2), - ), - ), + borderData: FlBorderData(show: true, border: Border.all(width: 1, color: Colors.grey.withValues(alpha: 0.2))), ), ); } - BarChartGroupData generateGroupData( - int x, - num walking, - num running, - num cycling, - ) { + BarChartGroupData generateGroupData(int x, num walking, num running, num cycling) { double roundness = 2; bool isTouched = touchedIndex == x; maxValue = max(maxValue, walking + running + cycling); @@ -268,17 +209,19 @@ class ActivityCardState extends State { groupVertically: true, barRods: [ BarChartRodData( - fromY: 0, - toY: walking + 0, - color: widget.colors[0].withValues(alpha: isTouched ? 0.8 : 1), - width: 32, - borderRadius: BorderRadius.all(Radius.circular(roundness))), + fromY: 0, + toY: walking + 0, + color: widget.colors[0].withValues(alpha: isTouched ? 0.8 : 1), + width: 32, + borderRadius: BorderRadius.all(Radius.circular(roundness)), + ), BarChartRodData( - fromY: walking + betweenSpace, - toY: walking + betweenSpace + running, - color: widget.colors[1].withValues(alpha: isTouched ? 0.8 : 1), - width: 32, - borderRadius: BorderRadius.all(Radius.circular(roundness))), + fromY: walking + betweenSpace, + toY: walking + betweenSpace + running, + color: widget.colors[1].withValues(alpha: isTouched ? 0.8 : 1), + width: 32, + borderRadius: BorderRadius.all(Radius.circular(roundness)), + ), BarChartRodData( fromY: walking + betweenSpace + running + betweenSpace, toY: walking + betweenSpace + running + betweenSpace + cycling, @@ -300,12 +243,8 @@ class ActivityCardState extends State { meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), ), ); } diff --git a/lib/ui/cards/anonymous_card.dart b/lib/ui/cards/anonymous_card.dart index 1c935295..1d393778 100644 --- a/lib/ui/cards/anonymous_card.dart +++ b/lib/ui/cards/anonymous_card.dart @@ -8,36 +8,27 @@ class AnonymousCard extends StatelessWidget { RPLocalizations locale = RPLocalizations.of(context)!; return Card( - color: Theme.of(context).extension()!.grey50, + color: Theme.of(context).extension()!.grey50, elevation: 0, margin: const EdgeInsets.all(16.0), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.0), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(16.0)), child: Row( children: [ Expanded( child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, vertical: 22.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 22.0), child: Row( children: [ Column( children: [ Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6.0, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4), child: CircleAvatar( radius: 18, backgroundColor: CACHET.ANONYMOUS, - child: Icon( - Icons.info_outline, - color: Colors.white, - ), + child: Icon(Icons.info_outline, color: Colors.white), ), ), Padding( @@ -45,8 +36,7 @@ class AnonymousCard extends StatelessWidget { child: Text( locale.translate('pages.about.anonymous.anonymous'), maxLines: 2, - style: aboutCardSubtitleStyle.copyWith( - color: CACHET.ANONYMOUS), + style: fs16fw600.copyWith(color: CACHET.ANONYMOUS), ), ), ], @@ -56,10 +46,8 @@ class AnonymousCard extends StatelessWidget { padding: const EdgeInsets.only(left: 16.0), child: Text( locale.translate('pages.about.anonymous.message'), - style: aboutCardSubtitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + style: fs16fw600.copyWith( + color: Theme.of(context).extension()!.grey900, fontSize: 14, ), ), diff --git a/lib/ui/cards/distance_card.dart b/lib/ui/cards/distance_card.dart index 92e06851..143a0101 100644 --- a/lib/ui/cards/distance_card.dart +++ b/lib/ui/cards/distance_card.dart @@ -4,9 +4,7 @@ class DistanceCard extends StatefulWidget { final List colors; final MobilityCardViewModel model; - const DistanceCard(this.model, - {super.key, - this.colors = const [CACHET.BLUE_1, CACHET.BLUE_2, CACHET.BLUE_3]}); + const DistanceCard(this.model, {super.key, this.colors = const [CACHET.BLUE_1, CACHET.BLUE_2, CACHET.BLUE_3]}); @override State createState() => _DistanceCardState(); @@ -33,26 +31,19 @@ class _DistanceCardState extends State { @override Widget build(BuildContext context) { return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( children: [ Row( children: [ - Text( - _distance, - style: dataVizCardTitleNumber.copyWith( - color: Theme.of(context).extension()!.grey900!, - ), - ), + Text(_distance, style: fs28fw700.copyWith(color: Theme.of(context).extension()!.grey900!)), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( 'km ${_getDayName(touchedIndex)}', - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), ), ], @@ -61,9 +52,7 @@ class _DistanceCardState extends State { children: [ Text( "${widget.model.currentMonth} ${widget.model.startOfWeek} - ${int.parse(widget.model.endOfWeek) < int.parse(widget.model.startOfWeek) ? widget.model.nextMonth : widget.model.currentMonth} ${widget.model.endOfWeek}, ${widget.model.currentYear}", - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), Spacer(), ], @@ -71,10 +60,7 @@ class _DistanceCardState extends State { SizedBox( height: 160, width: MediaQuery.of(context).size.width * 0.9, - child: StreamBuilder( - stream: widget.model.mobilityEvents, - builder: (context, snapshot) => barCharts, - ), + child: StreamBuilder(stream: widget.model.mobilityEvents, builder: (context, snapshot) => barCharts), ), ], ), @@ -83,64 +69,45 @@ class _DistanceCardState extends State { } BarChart get barCharts { - return BarChart(BarChartData( - alignment: BarChartAlignment.spaceAround, - titlesData: FlTitlesData( - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: bottomTitles, - reservedSize: 20, + return BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: bottomTitles, reservedSize: 20), ), - ), - leftTitles: const AxisTitles(), - rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: rightTitles, - reservedSize: 48, + leftTitles: const AxisTitles(), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: rightTitles, reservedSize: 48), ), + topTitles: const AxisTitles(), ), - topTitles: const AxisTitles(), - ), - barTouchData: BarTouchData( - enabled: false, - touchCallback: (p0, p1) { - setState(() { - touchedIndex = - (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + - 1; - }); - }, - ), - groupsSpace: 4, - barGroups: barChartsGroups, - maxY: (maxValue) * 1.2, - gridData: FlGridData( - show: true, - drawVerticalLine: false, - drawHorizontalLine: true, - getDrawingHorizontalLine: (value) { - return FlLine( - color: Colors.grey.withValues(alpha: 0.3), - strokeWidth: 1, - ); - }, - ), - borderData: FlBorderData( - show: true, - border: Border.all( - width: 1, - color: Colors.grey.withValues(alpha: 0.2), + barTouchData: BarTouchData( + enabled: false, + touchCallback: (p0, p1) { + setState(() { + touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; + }); + }, + ), + groupsSpace: 4, + barGroups: barChartsGroups, + maxY: (maxValue) * 1.2, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + drawHorizontalLine: true, + getDrawingHorizontalLine: (value) { + return FlLine(color: Colors.grey.withValues(alpha: 0.3), strokeWidth: 1); + }, ), + borderData: FlBorderData(show: true, border: Border.all(width: 1, color: Colors.grey.withValues(alpha: 0.2))), ), - )); + ); } List get barChartsGroups { - return widget.model.weekData.entries - .map((e) => generateGroupData(e.key, e.value.distance)) - .toList(); + return widget.model.weekData.entries.map((e) => generateGroupData(e.key, e.value.distance)).toList(); } BarChartGroupData generateGroupData(int x, double step) { @@ -157,10 +124,7 @@ class _DistanceCardState extends State { toY: step.toDouble(), color: widget.colors[0].withValues(alpha: isTouched ? 0.8 : 1), width: 32, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)), ), ], ); @@ -171,12 +135,8 @@ class _DistanceCardState extends State { meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), ), ); } diff --git a/lib/ui/cards/heart_rate_card.dart b/lib/ui/cards/heart_rate_card.dart index abe6290a..0830352c 100644 --- a/lib/ui/cards/heart_rate_card.dart +++ b/lib/ui/cards/heart_rate_card.dart @@ -4,15 +4,13 @@ class HeartRateCardWidget extends StatefulWidget { final HeartRateCardViewModel model; const HeartRateCardWidget(this.model, {super.key}); - factory HeartRateCardWidget.withSampleData(HeartRateCardViewModel model) => - HeartRateCardWidget(model); + factory HeartRateCardWidget.withSampleData(HeartRateCardViewModel model) => HeartRateCardWidget(model); @override HeartRateCardWidgetState createState() => HeartRateCardWidgetState(); } -class HeartRateCardWidgetState extends State - with SingleTickerProviderStateMixin { +class HeartRateCardWidgetState extends State with SingleTickerProviderStateMixin { late AnimationController animationController; late Animation animation; @@ -24,13 +22,8 @@ class HeartRateCardWidgetState extends State duration: const Duration(seconds: 1), lowerBound: 0.9, upperBound: 1, - )..repeat( - reverse: true, - ); - animation = CurvedAnimation( - parent: animationController, - curve: Curves.easeOut, - ); + )..repeat(reverse: true); + animation = CurvedAnimation(parent: animationController, curve: Curves.easeOut); } @override @@ -42,7 +35,7 @@ class HeartRateCardWidgetState extends State @override Widget build(BuildContext context) { return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -53,14 +46,8 @@ class HeartRateCardWidgetState extends State return Column( children: [ getDailyRange, - SizedBox( - height: 240, - child: barCharts, - ), - SizedBox( - height: 50, - child: currentHeartRateWidget, - ) + SizedBox(height: 240, child: barCharts), + SizedBox(height: 50, child: currentHeartRateWidget), ], ); }, @@ -82,23 +69,13 @@ class HeartRateCardWidgetState extends State children: [ Container( margin: const EdgeInsets.only(left: 8, right: 4, bottom: 4), - child: Text( - min == null || max == null - ? '-' - : '${(min.toInt())} - ${(max.toInt())}', - style: heartRateNumberStyle, - ), + child: Text(min == null || max == null ? '-' : '${(min.toInt())} - ${(max.toInt())}', style: fs28fw700), ), Padding( padding: const EdgeInsets.only(bottom: 10), child: Text( - min == null || max == null - ? '' - : locale.translate('cards.heartrate.bpm'), - style: heartRateBPMTextStyle.copyWith( - fontSize: 12, - color: Theme.of(context).extension()!.grey600, - ), + min == null || max == null ? '' : locale.translate('cards.heartrate.bpm'), + style: fs10fw700.copyWith(fontSize: 12, color: Theme.of(context).extension()!.grey600), ), ), ], @@ -120,11 +97,8 @@ class HeartRateCardWidgetState extends State Container( margin: const EdgeInsets.only(left: 8, bottom: 8, right: 4), child: currentHeartRate != null - ? Text( - currentHeartRate.toStringAsFixed(0), - style: heartRateNumberStyle, - ) - : Text('-', style: heartRateNumberStyle), + ? Text(currentHeartRate.toStringAsFixed(0), style: fs28fw700) + : Text('-', style: fs28fw700), ), Padding( padding: const EdgeInsets.only(bottom: 14), @@ -134,20 +108,13 @@ class HeartRateCardWidgetState extends State children: [ RepaintBoundary( child: ScaleTransition( - scale: Tween(begin: 1, end: 1) - .animate(animationController), - child: Icon( - Icons.favorite, - color: CACHET.HEART_RATE_RED, - size: 10, - ), + scale: Tween(begin: 1, end: 1).animate(animationController), + child: Icon(Icons.favorite, color: CACHET.HEART_RATE_RED, size: 10), ), ), Text( locale.translate('cards.heartrate.bpm'), - style: heartRateBPMTextStyle.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs10fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), ], ), @@ -169,48 +136,41 @@ class HeartRateCardWidgetState extends State enabled: true, touchTooltipData: BarTouchTooltipData( fitInsideHorizontally: true, - // tooltipBgColor: Theme.of(context).primaryColorLight, + fitInsideVertically: true, getTooltipItem: (group, groupIndex, rod, rodIndex) { return BarTooltipItem( '', textAlign: TextAlign.start, children: [ TextSpan( - text: - locale.translate('cards.heartrate.range').toUpperCase(), + text: locale.translate('cards.heartrate.range').toUpperCase(), style: TextStyle( - color: - Theme.of(context).primaryTextTheme.bodySmall?.color, + color: Theme.of(context).primaryTextTheme.bodySmall?.color, fontWeight: FontWeight.bold, ), ), TextSpan( text: "\n${rod.fromY.toInt()}-${rod.toY.toInt()}", - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 30, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 30), ), TextSpan( text: "${locale.translate('cards.heartrate.bpm')}\n", style: TextStyle( fontWeight: FontWeight.bold, - color: - Theme.of(context).primaryTextTheme.bodySmall?.color, + color: Theme.of(context).primaryTextTheme.bodySmall?.color, fontSize: 20, ), ), TextSpan( text: "$groupIndex-${groupIndex + 1} ", style: TextStyle( - color: - Theme.of(context).primaryTextTheme.bodySmall?.color, + color: Theme.of(context).primaryTextTheme.bodySmall?.color, fontWeight: FontWeight.bold, fontSize: 20, ), ), ], - heartRateNumberStyle, + fs28fw700, ); }, ), @@ -218,53 +178,31 @@ class HeartRateCardWidgetState extends State titlesData: FlTitlesData( show: true, bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - interval: 20, - getTitlesWidget: bottomTitles, - ), + sideTitles: SideTitles(showTitles: true, interval: 20, getTitlesWidget: bottomTitles), ), rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 48, - getTitlesWidget: rightTitles, - ), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), + sideTitles: SideTitles(showTitles: true, reservedSize: 48, getTitlesWidget: rightTitles), ), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ), gridData: FlGridData( - show: true, - drawVerticalLine: true, - drawHorizontalLine: true, - getDrawingHorizontalLine: (value) => FlLine( - color: Colors.grey.withValues(alpha: 0.2), - strokeWidth: 1, - ), - checkToShowHorizontalLine: (value) => value % 100 == 0, - getDrawingVerticalLine: (value) => FlLine( - color: Colors.grey.withValues(alpha: 0.2), - strokeWidth: 1, - dashArray: [3, 2]), - verticalInterval: 1 / 24, - checkToShowVerticalLine: (value) { - if ((value * 24).round() == 6) return true; - if ((value * 24).round() == 12) return true; - if ((value * 24).round() == 18) return true; - return false; - }), - borderData: FlBorderData( show: true, - border: Border.all( - width: 1, - color: Colors.grey.withValues(alpha: 0.2), - ), + drawVerticalLine: true, + drawHorizontalLine: true, + getDrawingHorizontalLine: (value) => FlLine(color: Colors.grey.withValues(alpha: 0.2), strokeWidth: 1), + checkToShowHorizontalLine: (value) => value % 100 == 0, + getDrawingVerticalLine: (value) => + FlLine(color: Colors.grey.withValues(alpha: 0.2), strokeWidth: 1, dashArray: [3, 2]), + verticalInterval: 1 / 24, + checkToShowVerticalLine: (value) { + if ((value * 24).round() == 6) return true; + if ((value * 24).round() == 12) return true; + if ((value * 24).round() == 18) return true; + return false; + }, ), + borderData: FlBorderData(show: true, border: Border.all(width: 1, color: Colors.grey.withValues(alpha: 0.2))), groupsSpace: 4, barGroups: getHeartRateBars(), minY: 0, @@ -274,11 +212,7 @@ class HeartRateCardWidgetState extends State } Widget bottomTitles(double value, TitleMeta meta) { - final style = TextStyle( - color: Colors.grey.withValues(alpha: 0.6), - fontSize: 14, - fontWeight: FontWeight.bold, - ); + final style = TextStyle(color: Colors.grey.withValues(alpha: 0.6), fontSize: 14, fontWeight: FontWeight.bold); String text; if (value == 0) { text = '00'; @@ -295,10 +229,7 @@ class HeartRateCardWidgetState extends State return SideTitleWidget( meta: meta, space: 0, - child: Text( - text, - style: style, - ), + child: Text(text, style: style), ); } @@ -307,31 +238,23 @@ class HeartRateCardWidgetState extends State meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), maxLines: 1, ), ); } - List getHeartRateBars() => - widget.model.hourlyHeartRate.entries - .map((value) => BarChartGroupData( - x: value.key, - barRods: [ - BarChartRodData( - fromY: value.value.min, - toY: value.value.max ?? 0, - color: CACHET.HEART_RATE_RED, - width: 6, - ), - ], - )) - .toList(); + List getHeartRateBars() => widget.model.hourlyHeartRate.entries + .map( + (value) => BarChartGroupData( + x: value.key, + barRods: [ + BarChartRodData(fromY: value.value.min, toY: value.value.max ?? 0, color: CACHET.HEART_RATE_RED, width: 6), + ], + ), + ) + .toList(); } class HeartRateOuterStatefulWidget extends StatefulWidget { @@ -339,12 +262,10 @@ class HeartRateOuterStatefulWidget extends StatefulWidget { const HeartRateOuterStatefulWidget(this.model, {super.key}); @override - HeartRateOuterStatefulWidgetState createState() => - HeartRateOuterStatefulWidgetState(); + HeartRateOuterStatefulWidgetState createState() => HeartRateOuterStatefulWidgetState(); } -class HeartRateOuterStatefulWidgetState - extends State { +class HeartRateOuterStatefulWidgetState extends State { @override Widget build(BuildContext context) { return HeartRateCardWidget.withSampleData(widget.model); diff --git a/lib/ui/cards/media_card.dart b/lib/ui/cards/media_card.dart index 1fe94b1c..be777f8f 100644 --- a/lib/ui/cards/media_card.dart +++ b/lib/ui/cards/media_card.dart @@ -3,8 +3,7 @@ part of carp_study_app; class MediaCardWidget extends StatefulWidget { final List modelsList; final List colors; - const MediaCardWidget(this.modelsList, - {super.key, this.colors = CACHET.COLOR_LIST}); + const MediaCardWidget(this.modelsList, {super.key, this.colors = CACHET.COLOR_LIST}); @override MediaCardWidgetState createState() => MediaCardWidgetState(); } @@ -19,7 +18,7 @@ class MediaCardWidgetState extends State { } return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -34,37 +33,33 @@ class MediaCardWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 5), - Text('$total MEDIA', style: dataCardTitleStyle), + Text('$total MEDIA', style: fs16fw400ls1), Column( children: widget.modelsList .asMap() .entries .map( (entry) => Column( - crossAxisAlignment: - CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 15), Text( '${entry.value.tasksDone} ${locale.translate('cards.${entry.value.taskType}.title')}', - style: dataCardTitleStyle.copyWith( - fontSize: 14), + style: fs16fw400ls1.copyWith(fontSize: 14), ), - LayoutBuilder(builder: - (BuildContext context, - BoxConstraints constraints) { - return HorizontalBar( + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return HorizontalBar( parentWidth: constraints.maxWidth, names: entry.value.taskCount - .map((task) => locale - .translate(task.title)) - .toList(), - values: entry.value.taskCount - .map((task) => task.size) + .map((task) => locale.translate(task.title)) .toList(), + values: entry.value.taskCount.map((task) => task.size).toList(), colors: CACHET.COLOR_LIST, - height: 18); - }), + height: 18, + ); + }, + ), ], ), ) @@ -76,7 +71,7 @@ class MediaCardWidgetState extends State { ], ), ], - ) + ), ], ), ), diff --git a/lib/ui/cards/mobility_card.dart b/lib/ui/cards/mobility_card.dart index ee506136..9daff951 100644 --- a/lib/ui/cards/mobility_card.dart +++ b/lib/ui/cards/mobility_card.dart @@ -4,9 +4,7 @@ class MobilityCard extends StatefulWidget { final List colors; final MobilityCardViewModel model; - const MobilityCard(this.model, - {super.key, - this.colors = const [CACHET.CAQUI, CACHET.ORANGE, CACHET.BLUE_3]}); + const MobilityCard(this.model, {super.key, this.colors = const [CACHET.CAQUI, CACHET.ORANGE, CACHET.BLUE_3]}); @override State createState() => _MobilityCardState(); @@ -30,26 +28,19 @@ class _MobilityCardState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( children: [ Row( children: [ - Text( - '$_homestay%', - style: dataVizCardTitleNumber.copyWith( - color: widget.colors[0], - ), - ), + Text('$_homestay%', style: fs28fw700.copyWith(color: widget.colors[0])), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( "${locale.translate('cards.mobility.homestay')} ${_getDayName(touchedIndex)}", - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey900!, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey900!), ), ), ], @@ -58,9 +49,7 @@ class _MobilityCardState extends State { children: [ Text( "${widget.model.currentMonth} ${widget.model.startOfWeek} - ${int.parse(widget.model.endOfWeek) < int.parse(widget.model.startOfWeek) ? widget.model.nextMonth : widget.model.currentMonth} ${widget.model.endOfWeek}, ${widget.model.currentYear}", - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), Spacer(), ], @@ -79,20 +68,12 @@ class _MobilityCardState extends State { children: [ Row( children: [ - Text( - '$_places', - style: dataVizCardBottomNumber.copyWith( - color: widget.colors[0], - ), - ), + Text('$_places', style: fs22fw700.copyWith(color: widget.colors[0])), Padding( padding: const EdgeInsets.all(4.0), child: Text( locale.translate('cards.mobility.places'), - style: dataVizCardBottomText.copyWith( - color: Theme.of(context) - .extension()! - .grey800), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), ), ), ], @@ -106,58 +87,41 @@ class _MobilityCardState extends State { } BarChart get barCharts { - return BarChart(BarChartData( - alignment: BarChartAlignment.spaceAround, - titlesData: FlTitlesData( - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: bottomTitles, - reservedSize: 20, + return BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: bottomTitles, reservedSize: 20), ), - ), - leftTitles: const AxisTitles(), - rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: rightTitles, - reservedSize: 48, + leftTitles: const AxisTitles(), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: rightTitles, reservedSize: 48), ), + topTitles: const AxisTitles(), ), - topTitles: const AxisTitles(), - ), - barTouchData: BarTouchData( - enabled: false, - touchCallback: (p0, p1) { - setState(() { - touchedIndex = - (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + - 1; - }); - }, - ), - groupsSpace: 4, - barGroups: barChartsGroups, - maxY: 100, - gridData: FlGridData( - show: true, - drawVerticalLine: false, - drawHorizontalLine: true, - getDrawingHorizontalLine: (value) { - return FlLine( - color: Colors.grey.withValues(alpha: 0.3), - strokeWidth: 1, - ); - }, - ), - borderData: FlBorderData( - show: true, - border: Border.all( - width: 1, - color: Colors.grey.withValues(alpha: 0.2), + barTouchData: BarTouchData( + enabled: false, + touchCallback: (p0, p1) { + setState(() { + touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; + }); + }, ), + groupsSpace: 4, + barGroups: barChartsGroups, + maxY: 100, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + drawHorizontalLine: true, + getDrawingHorizontalLine: (value) { + return FlLine(color: Colors.grey.withValues(alpha: 0.3), strokeWidth: 1); + }, + ), + borderData: FlBorderData(show: true, border: Border.all(width: 1, color: Colors.grey.withValues(alpha: 0.2))), ), - )); + ); } List get barChartsGroups { @@ -180,19 +144,13 @@ class _MobilityCardState extends State { toY: places.toDouble(), color: widget.colors[1].withValues(alpha: isTouched ? 0.8 : 1), width: 16, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)), ), BarChartRodData( toY: homestay.toDouble(), color: widget.colors[0].withValues(alpha: isTouched ? 0.8 : 1), width: 16, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)), ), ], ); @@ -203,12 +161,8 @@ class _MobilityCardState extends State { meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), ), ); } @@ -218,12 +172,8 @@ class _MobilityCardState extends State { meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), ), ); } diff --git a/lib/ui/cards/scoreboard_card.dart b/lib/ui/cards/scoreboard_card.dart index 7ea6e62d..974d8897 100644 --- a/lib/ui/cards/scoreboard_card.dart +++ b/lib/ui/cards/scoreboard_card.dart @@ -14,12 +14,7 @@ class ScoreboardCardState extends State { return SliverPersistentHeader( pinned: false, - delegate: ScoreboardPersistentHeaderDelegate( - model: widget.model, - locale: locale, - minExtent: 40, - maxExtent: 110, - ), + delegate: ScoreboardPersistentHeaderDelegate(model: widget.model, locale: locale, minExtent: 40, maxExtent: 110), ); } } @@ -29,8 +24,7 @@ class ScoreboardCardState extends State { /// This is used in the [StudyPage] to make the header of the page. /// The delegate should retract from 110px to 40px when scrolling down. /// The animation should be simple and linear. A stretched header does not do anything. -class ScoreboardPersistentHeaderDelegate - extends SliverPersistentHeaderDelegate { +class ScoreboardPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { TaskListPageViewModel model; RPLocalizations locale; @override @@ -46,57 +40,64 @@ class ScoreboardPersistentHeaderDelegate }); @override - Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { double height = 110; double offsetForShrink = 50; List childrenDays = [ - Text(model.daysInStudy.toString(), - style: scoreNumberStyle.copyWith( - fontSize: calculateScrollAwareSizing(shrinkOffset, - scoreNumberStyleSmall.fontSize!, scoreNumberStyle.fontSize!), - color: Theme.of(context).extension()!.grey900)), + Text( + model.daysInStudy.toString(), + style: fs36fw800.copyWith( + fontSize: calculateScrollAwareSizing(shrinkOffset, fs20fw800.fontSize!, fs36fw800.fontSize!), + color: Theme.of(context).extension()!.grey900, + ), + ), if (shrinkOffset < offsetForShrink) - Text(locale.translate('cards.scoreboard.days'), - style: scoreTextStyle.copyWith( - color: Theme.of(context).extension()!.grey900)), + Text( + locale.translate('cards.scoreboard.days'), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey900), + ), if (shrinkOffset > offsetForShrink) Padding( padding: const EdgeInsets.only(left: 8.0), - child: Text(locale.translate('cards.scoreboard.days-short'), - style: scoreTextStyle.copyWith( - color: Theme.of(context).extension()!.grey900)), - ) + child: Text( + locale.translate('cards.scoreboard.days-short'), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey900), + ), + ), ]; List childrenTasks = [ - Text(model.taskCompleted.toString(), - style: scoreNumberStyle.copyWith( - fontSize: calculateScrollAwareSizing(shrinkOffset, - scoreNumberStyleSmall.fontSize!, scoreNumberStyle.fontSize!), - color: Theme.of(context).extension()!.primary)), + Text( + model.taskCompleted.toString(), + style: fs36fw800.copyWith( + fontSize: calculateScrollAwareSizing(shrinkOffset, fs20fw800.fontSize!, fs36fw800.fontSize!), + color: Theme.of(context).extension()!.primary, + ), + ), if (shrinkOffset < offsetForShrink) - Text(locale.translate('cards.scoreboard.tasks'), - style: scoreTextStyle.copyWith( - color: Theme.of(context).extension()!.primary)), + Text( + locale.translate('cards.scoreboard.tasks'), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.primary), + ), if (shrinkOffset > offsetForShrink) Expanded( flex: 0, child: Padding( padding: const EdgeInsets.only(left: 8.0), - child: Text(locale.translate('cards.scoreboard.tasks-short'), - style: scoreTextStyle.copyWith( - color: Theme.of(context).extension()!.primary)), + child: Text( + locale.translate('cards.scoreboard.tasks-short'), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.primary), + ), ), - ) + ), ]; return Container( height: height, decoration: BoxDecoration( - color: Theme.of(context).extension()!.white, + color: Theme.of(context).extension()!.white, borderRadius: BorderRadius.circular(8), // Rounded corners ), child: StreamBuilder( @@ -108,21 +109,14 @@ class ScoreboardPersistentHeaderDelegate children: [ Expanded( child: shrinkOffset < offsetForShrink - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: childrenDays, - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: childrenDays, - ), + ? Column(mainAxisAlignment: MainAxisAlignment.center, children: childrenDays) + : Row(mainAxisAlignment: MainAxisAlignment.center, children: childrenDays), ), // A vertical divider line with rounded corners that spans from 10% to 90% of the height Expanded( flex: 0, child: Container( - height: calculateScrollAwareSizing( - shrinkOffset, minExtent * 0.6, maxExtent * 0.6), + height: calculateScrollAwareSizing(shrinkOffset, minExtent * 0.6, maxExtent * 0.6), width: 2, decoration: BoxDecoration( color: Theme.of(context).dividerColor, @@ -132,15 +126,9 @@ class ScoreboardPersistentHeaderDelegate ), Expanded( child: shrinkOffset < offsetForShrink - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: childrenTasks, - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: childrenTasks, - ), - ) + ? Column(mainAxisAlignment: MainAxisAlignment.center, children: childrenTasks) + : Row(mainAxisAlignment: MainAxisAlignment.center, children: childrenTasks), + ), ], ); }, @@ -150,8 +138,7 @@ class ScoreboardPersistentHeaderDelegate // A simple function that returns the font size from the scoreNumberStyle, but increasingly smaller when scrolling down. // Also used for the size of the divider in the middle - double calculateScrollAwareSizing( - double shrinkOffset, double minSize, double maxSize) { + double calculateScrollAwareSizing(double shrinkOffset, double minSize, double maxSize) { // Calculate the normalized shrinkOffset value in the range [0, 1] double normalizedShrinkOffset = shrinkOffset / maxExtent; @@ -169,12 +156,8 @@ class ScoreboardPersistentHeaderDelegate @override FloatingHeaderSnapConfiguration get snapConfiguration => - FloatingHeaderSnapConfiguration( - curve: Curves.linear, - duration: const Duration(milliseconds: 100), - ); + FloatingHeaderSnapConfiguration(curve: Curves.linear, duration: const Duration(milliseconds: 100)); @override - OverScrollHeaderStretchConfiguration get stretchConfiguration => - OverScrollHeaderStretchConfiguration(); + OverScrollHeaderStretchConfiguration get stretchConfiguration => OverScrollHeaderStretchConfiguration(); } diff --git a/lib/ui/cards/steps_card.dart b/lib/ui/cards/steps_card.dart index f8caef84..577a0561 100644 --- a/lib/ui/cards/steps_card.dart +++ b/lib/ui/cards/steps_card.dart @@ -4,9 +4,7 @@ class StepsCardWidget extends StatefulWidget { final List colors; final StepsCardViewModel model; - const StepsCardWidget(this.model, - {super.key, - this.colors = const [CACHET.ORANGE, CACHET.BLUE_2, CACHET.BLUE_3]}); + const StepsCardWidget(this.model, {super.key, this.colors = const [CACHET.ORANGE, CACHET.BLUE_2, CACHET.BLUE_3]}); @override StepsCardWidgetState createState() => StepsCardWidgetState(); @@ -29,7 +27,7 @@ class StepsCardWidgetState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -37,18 +35,14 @@ class StepsCardWidgetState extends State { Row( children: [ Text( - '$_step', - style: dataVizCardTitleNumber.copyWith( - color: Theme.of(context).extension()!.grey900!, - ), + _step > 0 ? '$_step' : '0', + style: fs28fw700.copyWith(color: Theme.of(context).extension()!.grey900!), ), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( '${locale.translate('cards.steps.steps')} ${_getDayName(touchedIndex)}', - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), ), ], @@ -57,9 +51,7 @@ class StepsCardWidgetState extends State { children: [ Text( "${widget.model.currentMonth} ${widget.model.startOfWeek} - ${int.parse(widget.model.endOfWeek) < int.parse(widget.model.startOfWeek) ? widget.model.nextMonth : widget.model.currentMonth} ${widget.model.endOfWeek}, ${widget.model.currentYear}", - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), Spacer(), ], @@ -81,64 +73,45 @@ class StepsCardWidgetState extends State { } BarChart get barCharts { - return BarChart(BarChartData( - alignment: BarChartAlignment.spaceAround, - titlesData: FlTitlesData( - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: bottomTitles, - reservedSize: 20, + return BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: bottomTitles, reservedSize: 20), ), - ), - leftTitles: const AxisTitles(), - rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: rightTitles, - reservedSize: 48, + leftTitles: const AxisTitles(), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: rightTitles, reservedSize: 48), ), + topTitles: const AxisTitles(), ), - topTitles: const AxisTitles(), - ), - barTouchData: BarTouchData( - enabled: false, - touchCallback: (p0, p1) { - setState(() { - touchedIndex = - (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + - 1; - }); - }, - ), - groupsSpace: 4, - barGroups: barChartsGroups, - maxY: (maxValue) * 1.2, - gridData: FlGridData( - show: true, - drawVerticalLine: false, - drawHorizontalLine: true, - getDrawingHorizontalLine: (value) { - return FlLine( - color: Colors.grey.withValues(alpha: 0.3), - strokeWidth: 1, - ); - }, - ), - borderData: FlBorderData( - show: true, - border: Border.all( - width: 1, - color: Colors.grey.withValues(alpha: 0.2), + barTouchData: BarTouchData( + enabled: false, + touchCallback: (p0, p1) { + setState(() { + touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; + }); + }, ), + groupsSpace: 4, + barGroups: barChartsGroups, + maxY: (maxValue) * 1.2, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + drawHorizontalLine: true, + getDrawingHorizontalLine: (value) { + return FlLine(color: Colors.grey.withValues(alpha: 0.3), strokeWidth: 1); + }, + ), + borderData: FlBorderData(show: true, border: Border.all(width: 1, color: Colors.grey.withValues(alpha: 0.2))), ), - )); + ); } List get barChartsGroups { - return widget.model.weeklySteps.entries - .map((e) => generateGroupData(e.key, e.value)) - .toList(); + return widget.model.weeklySteps.entries.map((e) => generateGroupData(e.key, e.value)).toList(); } BarChartGroupData generateGroupData(int x, int step) { @@ -155,10 +128,7 @@ class StepsCardWidgetState extends State { toY: step.toDouble(), color: widget.colors[1].withValues(alpha: isTouched ? 0.8 : 1), width: 32, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)), ), ], ); @@ -169,12 +139,8 @@ class StepsCardWidgetState extends State { meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), ), ); } @@ -183,10 +149,7 @@ class StepsCardWidgetState extends State { const style = TextStyle(fontSize: 10); return SideTitleWidget( meta: meta, - child: Text( - _getDayName(value.toInt()), - style: style, - ), + child: Text(_getDayName(value.toInt()), style: style), ); } diff --git a/lib/ui/cards/study_progress_card.dart b/lib/ui/cards/study_progress_card.dart index c74e4cf6..b1cc044e 100644 --- a/lib/ui/cards/study_progress_card.dart +++ b/lib/ui/cards/study_progress_card.dart @@ -4,9 +4,11 @@ class StudyProgressCardWidget extends StatefulWidget { final StudyProgressCardViewModel model; final List colors; - const StudyProgressCardWidget(this.model, - {super.key, - this.colors = const [CACHET.BLUE_1, CACHET.RED_1, CACHET.GREY_6]}); + const StudyProgressCardWidget( + this.model, { + super.key, + this.colors = const [CACHET.BLUE_1, CACHET.RED_1, CACHET.GREY_6], + }); @override StudyProgressCardWidgetState createState() => StudyProgressCardWidgetState(); @@ -19,7 +21,7 @@ class StudyProgressCardWidgetState extends State { widget.model.updateProgress(); return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: StreamBuilder( @@ -31,35 +33,28 @@ class StudyProgressCardWidgetState extends State { padding: const EdgeInsets.only(left: 8), child: Row( mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text(locale.translate('cards.study_progress.title'), - style: dataCardTitleStyle), - ], + children: [Text(locale.translate('cards.study_progress.title'), style: fs16fw400ls1)], ), ), SizedBox( height: 130, child: LayoutBuilder( - builder: - (BuildContext context, BoxConstraints constraints) { + builder: (BuildContext context, BoxConstraints constraints) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: List.generate( widget.model.progress.length, (index) => Padding( - padding: - const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.symmetric(vertical: 4.0), child: Row( children: [ Text( - widget.model.progress[index].value - .toString(), + widget.model.progress[index].value.toString(), style: TextStyle( fontSize: 22, fontWeight: FontWeight.bold, @@ -68,8 +63,7 @@ class StudyProgressCardWidgetState extends State { ), const SizedBox(width: 4), Text( - locale.translate( - widget.model.progress[index].state), + locale.translate(widget.model.progress[index].state), style: const TextStyle(fontSize: 16), ), ], @@ -80,20 +74,15 @@ class StudyProgressCardWidgetState extends State { ), // Circular Progress Representation Padding( - padding: - const EdgeInsets.only(bottom: 18, right: 24.0), + padding: const EdgeInsets.only(bottom: 18, right: 24.0), child: SizedBox( width: 104, height: 104, child: CustomPaint( painter: TaskProgressPainter( - values: widget.model.progress - .map((p) => p.value) - .toList(), + values: widget.model.progress.map((p) => p.value).toList(), colors: widget.colors, - faintColors: widget.colors - .map((c) => c.withValues(alpha: 0.2)) - .toList(), + faintColors: widget.colors.map((c) => c.withValues(alpha: 0.2)).toList(), ), ), ), @@ -118,8 +107,7 @@ class TaskProgressPainter extends CustomPainter { final List faintColors; final double pi = 3.141592; - TaskProgressPainter( - {required this.values, required this.colors, required this.faintColors}); + TaskProgressPainter({required this.values, required this.colors, required this.faintColors}); @override void paint(Canvas canvas, Size size) { @@ -167,13 +155,7 @@ class TaskProgressPainter extends CustomPainter { ); // draw a full circle as a background with a faint color - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - 0, - 2 * pi, - false, - paintBackground, - ); + canvas.drawArc(Rect.fromCircle(center: center, radius: radius), 0, 2 * pi, false, paintBackground); } } diff --git a/lib/ui/cards/survey_card.dart b/lib/ui/cards/survey_card.dart index 92569b90..f2f4314a 100644 --- a/lib/ui/cards/survey_card.dart +++ b/lib/ui/cards/survey_card.dart @@ -21,7 +21,7 @@ class _SurveyCardState extends State { } return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -29,69 +29,54 @@ class _SurveyCardState extends State { children: [ Padding( padding: const EdgeInsets.only(left: 10.0), - child: Text(locale.translate('cards.survey.title').toUpperCase(), - style: dataCardTitleStyle), + child: Text(locale.translate('cards.survey.title').toUpperCase(), style: fs16fw400ls1), ), SizedBox( height: 160, width: MediaQuery.of(context).size.width * 0.9, - child: Row(children: [ - // List of text with the number of surveys done for each survey - Expanded( - flex: 2, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, vertical: 8), - child: Column( + child: Row( + children: [ + // List of text with the number of surveys done for each survey + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 8), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: widget.model.tasksTable.entries.map((entry) { Widget dot = Container( width: 10, height: 10, decoration: BoxDecoration( - color: widget.colors[widget.model.tasksTable.keys - .toList() - .indexOf(entry.key)], + color: widget.colors[widget.model.tasksTable.keys.toList().indexOf(entry.key)], shape: BoxShape.circle, ), ); Widget text = Text( '${entry.value} ${locale.translate(entry.key).truncateTo(12)}', - style: legendStyle, + style: fs12fw400, ); - return Row( - children: [ - dot, - const SizedBox(width: 8), - text, - ], - ); - }).toList()), - ), - ), - // The pie chart - Expanded( - flex: 3, - child: Stack( - alignment: Alignment.center, - children: [ - PieChart( - PieChartData( - sections: pieChartSections, - startDegreeOffset: 270, - ), + return Row(children: [dot, const SizedBox(width: 8), text]); + }).toList(), ), - Text( - '$totalSurveys', - style: surveysCardTotalTextStyle.copyWith( - color: - Theme.of(context).extension()!.grey800, + ), + ), + // The pie chart + Expanded( + flex: 3, + child: Stack( + alignment: Alignment.center, + children: [ + PieChart(PieChartData(sections: pieChartSections, startDegreeOffset: 270)), + Text( + '$totalSurveys', + style: fs24fw700.copyWith(color: Theme.of(context).extension()!.grey800), ), - ) - ], + ], + ), ), - ), - ]), + ], + ), ), ], ), @@ -100,17 +85,14 @@ class _SurveyCardState extends State { } List get pieChartSections { - return widget.model.tasksTable.entries.map( - (entry) { - return PieChartSectionData( - // Color should be the next color in the list - color: widget - .colors[widget.model.tasksTable.keys.toList().indexOf(entry.key)], - value: entry.value.toDouble(), - title: '${entry.value}', - showTitle: false, - ); - }, - ).toList(); + return widget.model.tasksTable.entries.map((entry) { + return PieChartSectionData( + // Color should be the next color in the list + color: widget.colors[widget.model.tasksTable.keys.toList().indexOf(entry.key)], + value: entry.value.toDouble(), + title: '${entry.value}', + showTitle: false, + ); + }).toList(); } } diff --git a/lib/ui/carp_study_style.dart b/lib/ui/carp_study_style.dart index 3d6f935f..6e2d162e 100644 --- a/lib/ui/carp_study_style.dart +++ b/lib/ui/carp_study_style.dart @@ -1,8 +1,8 @@ part of carp_study_app; @immutable -class CarpColors extends ThemeExtension { - const CarpColors({ +class StudyAppColors extends ThemeExtension { + const StudyAppColors({ this.primary, this.warningColor, this.backgroundGray, @@ -44,24 +44,25 @@ class CarpColors extends ThemeExtension { final Color? grey950; @override - CarpColors copyWith( - {Color? primary, - Color? warningColor, - Color? backgroundGray, - Color? tabBarBackground, - Color? white, - Color? grey50, - Color? grey100, - Color? grey200, - Color? grey300, - Color? grey400, - Color? grey500, - Color? grey600, - Color? grey700, - Color? grey800, - Color? grey900, - Color? grey950}) { - return CarpColors( + StudyAppColors copyWith({ + Color? primary, + Color? warningColor, + Color? backgroundGray, + Color? tabBarBackground, + Color? white, + Color? grey50, + Color? grey100, + Color? grey200, + Color? grey300, + Color? grey400, + Color? grey500, + Color? grey600, + Color? grey700, + Color? grey800, + Color? grey900, + Color? grey950, + }) { + return StudyAppColors( primary: primary ?? this.primary, warningColor: warningColor ?? this.warningColor, backgroundGray: backgroundGray ?? this.backgroundGray, @@ -82,11 +83,11 @@ class CarpColors extends ThemeExtension { } @override - CarpColors lerp(CarpColors? other, double t) { - if (other is! CarpColors) { + StudyAppColors lerp(StudyAppColors? other, double t) { + if (other is! StudyAppColors) { return this; } - return CarpColors( + return StudyAppColors( primary: Color.lerp(primary, other.primary, t), warningColor: Color.lerp(warningColor, other.warningColor, t), backgroundGray: Color.lerp(backgroundGray, other.backgroundGray, t), @@ -109,7 +110,7 @@ class CarpColors extends ThemeExtension { ThemeData carpStudyTheme = ThemeData.light().copyWith( extensions: >[ - CarpColors( + StudyAppColors( primary: const Color(0xff000000), warningColor: Colors.orange[500], backgroundGray: const Color(0xfff2f2f7), @@ -126,49 +127,39 @@ ThemeData carpStudyTheme = ThemeData.light().copyWith( grey800: const Color(0xff2C2C2E), grey900: const Color(0xff1C1C1E), grey950: const Color(0xff0E0E0E), - ) + ), ], primaryColor: const Color(0xff006398), colorScheme: const ColorScheme.light().copyWith( - secondary: const Color(0xFFFAFAFA), - primary: const Color(0xFF206FA2), - tertiary: const ui.Color.fromARGB(255, 230, 230, 230)), + secondary: const Color(0xFFFAFAFA), + primary: const Color(0xFF206FA2), + tertiary: const ui.Color.fromARGB(255, 230, 230, 230), + ), //accentColor: Color(0xFFFAFAFA), //Color(0xffcce8fa), hoverColor: const Color(0xFFF1F9FF), scaffoldBackgroundColor: const Color(0xFFFFFFFF), - textTheme: ThemeData.light() - .textTheme + textTheme: ThemeData.light().textTheme .copyWith( - bodySmall: ThemeData.light().textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 14.0, - ), - bodyLarge: ThemeData.light().textTheme.bodyLarge!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 18.0, - ), - bodyMedium: ThemeData.light().textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w400, - fontSize: 16.0, - ), + bodySmall: ThemeData.light().textTheme.bodySmall!.copyWith(fontWeight: FontWeight.w500, fontSize: 14.0), + bodyLarge: ThemeData.light().textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, fontSize: 18.0), + bodyMedium: ThemeData.light().textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w400, fontSize: 16.0), titleMedium: ThemeData.light().textTheme.titleMedium!.copyWith( - fontWeight: FontWeight.w600, - fontSize: 20.0, - color: const Color(0xFF206FA2)), - titleLarge: ThemeData.light().textTheme.titleLarge!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 20.0, - ), + fontWeight: FontWeight.w600, + fontSize: 20.0, + color: const Color(0xFF206FA2), + ), + titleLarge: ThemeData.light().textTheme.titleLarge!.copyWith(fontWeight: FontWeight.w500, fontSize: 20.0), headlineMedium: ThemeData.light().textTheme.headlineMedium!.copyWith( - fontWeight: FontWeight.w700, - fontSize: 30.0, - ), + fontWeight: FontWeight.w700, + fontSize: 30.0, + ), labelLarge: ThemeData.light().textTheme.labelLarge!.copyWith( - fontWeight: FontWeight.w500, fontSize: 16.0, color: Colors.white), + fontWeight: FontWeight.w500, + fontSize: 16.0, + color: Colors.white, + ), ) - .apply( - fontFamily: 'OpenSans', - ), + .apply(fontFamily: 'OpenSans'), pageTransitionsTheme: const PageTransitionsTheme( builders: { TargetPlatform.android: CupertinoPageTransitionsBuilder(), @@ -179,7 +170,7 @@ ThemeData carpStudyTheme = ThemeData.light().copyWith( ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( extensions: >[ - CarpColors( + StudyAppColors( primary: const Color(0xff24B2FF), warningColor: Colors.orange[700], backgroundGray: const Color(0xff0e0e0e), @@ -196,7 +187,7 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( grey800: const Color(0xffF2F2F7), grey900: const Color(0xffF2F2F7), grey950: const Color(0xff0E0E0E), - ) + ), ], primaryColor: const Color(0xff0379ff), colorScheme: const ColorScheme.dark().copyWith( @@ -206,38 +197,26 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( ), // accentColor: Color(0xff4C4C4C), disabledColor: const Color(0xffcce8fa), - textTheme: ThemeData.dark() - .textTheme + textTheme: ThemeData.dark().textTheme .copyWith( - bodySmall: ThemeData.dark().textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 14.0, - ), - bodyLarge: ThemeData.dark().textTheme.bodyLarge!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 18.0, - ), - bodyMedium: ThemeData.dark().textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w400, - fontSize: 16.0, - ), + bodySmall: ThemeData.dark().textTheme.bodySmall!.copyWith(fontWeight: FontWeight.w500, fontSize: 14.0), + bodyLarge: ThemeData.dark().textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, fontSize: 18.0), + bodyMedium: ThemeData.dark().textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w400, fontSize: 16.0), titleMedium: ThemeData.dark().textTheme.titleMedium!.copyWith( - fontWeight: FontWeight.w600, - fontSize: 20.0, - color: const Color(0xff81C7F3), - ), - titleLarge: ThemeData.dark().textTheme.titleLarge!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 20.0, - ), + fontWeight: FontWeight.w600, + fontSize: 20.0, + color: const Color(0xff81C7F3), + ), + titleLarge: ThemeData.dark().textTheme.titleLarge!.copyWith(fontWeight: FontWeight.w500, fontSize: 20.0), headlineMedium: ThemeData.dark().textTheme.headlineMedium!.copyWith( - fontWeight: FontWeight.w700, - fontSize: 30.0, - ), + fontWeight: FontWeight.w700, + fontSize: 30.0, + ), labelLarge: ThemeData.dark().textTheme.labelLarge!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 16.0, - color: Colors.grey.shade800), + fontWeight: FontWeight.w500, + fontSize: 16.0, + color: Colors.grey.shade800, + ), ) .apply( fontFamily: 'OpenSans', @@ -253,13 +232,13 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( // These TextStyles are now implemented in ResearchPackage -// TextStyle studyTitleStyle = +// TextStyle fs24fw600 = // const TextStyle(fontSize: 24, fontWeight: FontWeight.w600); -// TextStyle studyDetailsInfoTitle = +// TextStyle fs16fw700 = // const TextStyle(fontSize: 16, fontWeight: FontWeight.w700); -// TextStyle studyDetailsInfoMessage = +// TextStyle fs12fw700 = // const TextStyle(fontSize: 12, fontWeight: FontWeight.w700); // TextStyle readMoreStudyStyle = @@ -278,29 +257,29 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( // TextStyle scoreTextStyle = // const TextStyle(fontSize: 12, fontWeight: FontWeight.w700); -// TextStyle aboutStudyCardTitleStyle = +// TextStyle fs24fw700 = // const TextStyle(fontSize: 24, fontWeight: FontWeight.w700) // .apply(fontFamily: 'OpenSans'); -// TextStyle aboutCardTitleStyle = +// TextStyle fs20fw700 = // const TextStyle(fontSize: 20, fontWeight: FontWeight.w700) // .apply(fontFamily: 'OpenSans'); // TextStyle aboutCardInfoStyle = // const TextStyle(fontSize: 14, fontStyle: FontStyle.italic); -// TextStyle aboutCardSubtitleStyle = +// TextStyle fs16fw600 = // const TextStyle(fontSize: 16, fontWeight: FontWeight.w600); -// TextStyle aboutCardContentStyle = +// TextStyle fs16fw400 = // const TextStyle(fontSize: 16, fontWeight: FontWeight.w400) // .apply(fontFamily: 'OpenSans'); -// TextStyle aboutCardTimeAgoStyle = +// TextStyle fs10fw600 = // const TextStyle(fontSize: 10, fontWeight: FontWeight.w600) // .apply(fontFamily: 'OpenSans'); -// TextStyle sectionTitleStyle = +// TextStyle fs18fw700 = // const TextStyle(fontSize: 18, fontWeight: FontWeight.w700); // TextStyle inputFieldStyle = @@ -309,21 +288,18 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( // TextStyle welcomeMessageStyle = const TextStyle( // fontSize: 24, color: Color(0xff707070), fontWeight: FontWeight.bold); -// TextStyle studyDescriptionStyle = -// const TextStyle(fontSize: 12, fontWeight: FontWeight.w300); - -// TextStyle dataCardTitleStyle = const TextStyle( +// TextStyle fs16fw400ls1 = const TextStyle( // fontSize: 16, fontWeight: FontWeight.w400, letterSpacing: 1); // TextStyle dataCardRightTitleStyle = // const TextStyle(fontSize: 14, letterSpacing: 1); // TextStyle measuresStyle = // const TextStyle(fontSize: 18, fontWeight: FontWeight.w400); -// TextStyle legendStyle = +// TextStyle fs12fw400 = // const TextStyle(fontSize: 12, fontWeight: FontWeight.w400); -// TextStyle audioTitleStyle = +// TextStyle fs22fw700 = // const TextStyle(fontSize: 22, fontWeight: FontWeight.w700); -// TextStyle audioContentStyle = +// TextStyle fs16fw600 = // const TextStyle(fontSize: 16, fontWeight: FontWeight.w700); // TextStyle heartRateNumberStyle = @@ -343,27 +319,27 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( // TextStyle dataVizCardBottomText = // const TextStyle(fontSize: 12, fontWeight: FontWeight.w700); -// TextStyle deviceTitle = +// TextStyle fs16fw700 = // const TextStyle(fontSize: 16, fontWeight: FontWeight.w700); -// TextStyle deviceSubtitle = +// TextStyle fs12fw700 = // const TextStyle(fontSize: 12, fontWeight: FontWeight.w700); // TextStyle healthServiceConnectTitleStyle = // const TextStyle(fontSize: 24, fontWeight: FontWeight.w700); -// TextStyle healthServiceConnectMessageStyle = +// TextStyle fs22fw700 = // const TextStyle(fontSize: 22, fontWeight: FontWeight.w700); -// TextStyle profileSectionStyle = +// TextStyle fs12fw600 = // TextStyle(fontSize: 12, fontWeight: FontWeight.w600); -// TextStyle profileTitleStyle = +// TextStyle fs14fw600 = // TextStyle(fontSize: 14, fontWeight: FontWeight.w600); -// TextStyle profileActionStyle = +// TextStyle fs16fw600 = // TextStyle(fontSize: 16, fontWeight: FontWeight.w600); // TextStyle timerStyle = // const TextStyle(fontSize: 36, fontWeight: FontWeight.w600); -// TextStyle studyNameStyle = +// TextStyle fs30fw800 = // const TextStyle(fontSize: 30.0, fontWeight: FontWeight.w800); diff --git a/lib/ui/colors.dart b/lib/ui/colors.dart index 4e04adb8..877216aa 100644 --- a/lib/ui/colors.dart +++ b/lib/ui/colors.dart @@ -57,8 +57,7 @@ class CACHET { static const Color HEART_RATE_RED = Color.fromRGBO(235, 75, 98, 1.0); - static Color pie = - createMaterialColor(const Color.fromRGBO(225, 244, 250, 1)); + static Color pie = createMaterialColor(const Color.fromRGBO(225, 244, 250, 1)); static const List COLOR_LIST = [ Color(0xFF7FC9E3), diff --git a/lib/ui/helpers.dart b/lib/ui/helpers.dart new file mode 100644 index 00000000..d60de25e --- /dev/null +++ b/lib/ui/helpers.dart @@ -0,0 +1,14 @@ +part of carp_study_app; + +/// UI presentation helpers for [MessageType]. +/// +/// Single source of truth for how a message type is rendered. Returns +/// [IconData] (not a widget) so callers decide colour and size. +extension MessageTypeUI on MessageType { + /// The icon representing this message type. + IconData get icon => switch (this) { + MessageType.announcement => Icons.campaign, + MessageType.news => Icons.newspaper, + MessageType.article => Icons.article, + }; +} diff --git a/lib/ui/pages/data_visualization_page.dart b/lib/ui/pages/data_visualization_page.dart index 56db1a91..6bd7a5a4 100644 --- a/lib/ui/pages/data_visualization_page.dart +++ b/lib/ui/pages/data_visualization_page.dart @@ -15,16 +15,14 @@ class _DataVisualizationPageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, - body: SafeArea( - child: Column( + backgroundColor: Theme.of(context).extension()!.backgroundGray, + body: SafeArea( + child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar(hasProfileIcon: true), ), Container( @@ -37,13 +35,13 @@ class _DataVisualizationPageState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(locale.translate('pages.data_viz.title'), - style: aboutStudyCardTitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900, - fontWeight: FontWeight.bold, - )), + Text( + locale.translate('pages.data_viz.title'), + style: fs24fw700.copyWith( + color: Theme.of(context).extension()!.grey900, + fontWeight: FontWeight.bold, + ), + ), ], ), ), @@ -57,22 +55,21 @@ class _DataVisualizationPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( - padding: const EdgeInsets.symmetric( - horizontal: 15, vertical: 24.0), - child: Text(locale.translate('pages.data_viz.thanks'), - style: aboutCardSubtitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey600, - )), + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 24.0), + child: Text( + locale.translate('pages.data_viz.thanks'), + style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey600), + ), ), ..._dataVizCards, ], ), ), - ) + ), ], - ))); + ), + ), + ); } // The list of cards, depending on what measures are defined in the study. @@ -80,46 +77,43 @@ class _DataVisualizationPageState extends State { final List widgets = []; // Show user task progress, if study has any tasks. - if (bloc.hasUserTasks()) { - widgets.add( - StudyProgressCardWidget(widget.model.studyProgressCardDataModel)); + if (widget.model.hasUserTasks) { + widgets.add(StudyProgressCardWidget(widget.model.studyProgressCardDataModel)); } // Show HR if there is a POLAR or MOVESENSE device in the study - if (bloc.hasMeasure(PolarSamplingPackage.HR) || - bloc.hasMeasure(MovesenseSamplingPackage.HR)) { - widgets.add( - HeartRateOuterStatefulWidget(widget.model.heartRateCardDataModel)); + if (widget.model.hasHeartRateMeasure) { + widgets.add(HeartRateOuterStatefulWidget(widget.model.heartRateCardDataModel)); } // check to show surveys stats - if (bloc.hasUserTasks()) { + if (widget.model.hasUserTasks) { widgets.add(SurveyCard(widget.model.surveysCardDataModel)); } List mediaModelsList = []; // check what media types are in the study and add them to de media card - if (bloc.hasMeasure(MediaSamplingPackage.AUDIO)) { + if (widget.model.hasAudioMeasure) { mediaModelsList.add(widget.model.audioCardDataModel); } - if (bloc.hasMeasure(MediaSamplingPackage.VIDEO)) { + if (widget.model.hasVideoMeasure) { mediaModelsList.add(widget.model.videoCardDataModel); } - if (bloc.hasMeasure(MediaSamplingPackage.IMAGE)) { + if (widget.model.hasImageMeasure) { mediaModelsList.add(widget.model.imageCardDataModel); } if (mediaModelsList.isNotEmpty) { widgets.add(MediaCardWidget(mediaModelsList)); } - if (bloc.hasMeasure(SensorSamplingPackage.STEP_COUNT)) { + if (widget.model.hasStepsMeasure) { widgets.add(StepsCardWidget(widget.model.stepsCardDataModel)); } - if (bloc.hasMeasure(ContextSamplingPackage.ACTIVITY)) { + if (widget.model.hasActivityMeasure) { widgets.add(ActivityCard(widget.model.activityCardDataModel)); } - if (bloc.hasMeasure(ContextSamplingPackage.MOBILITY)) { + if (widget.model.hasMobilityMeasure) { widgets.add(MobilityCard(widget.model.mobilityCardDataModel)); widgets.add(DistanceCard(widget.model.mobilityCardDataModel)); } diff --git a/lib/ui/pages/device_list_page.dart b/lib/ui/pages/device_list_page.dart index d392f148..58d7d6da 100644 --- a/lib/ui/pages/device_list_page.dart +++ b/lib/ui/pages/device_list_page.dart @@ -6,7 +6,8 @@ part of carp_study_app; /// * Any online services (connected services) class DeviceListPage extends StatefulWidget { static const String route = '/devices'; - const DeviceListPage({super.key}); + final DeviceListPageViewModel model; + const DeviceListPage({required this.model, super.key}); @override DeviceListPageState createState() => DeviceListPageState(); @@ -16,19 +17,9 @@ class DeviceListPageState extends State { StreamSubscription? bluetoothStateStream; BluetoothAdapterState? bluetoothAdapterState; - final List _smartphoneDevice = bloc.deploymentDevices - .where((element) => element.deviceManager is SmartphoneDeviceManager) - .toList(); - - final List _hardwareDevices = bloc.deploymentDevices - .where((element) => - element.deviceManager is HardwareDeviceManager && - element.deviceManager is! SmartphoneDeviceManager) - .toList(); - - final List _onlineServices = bloc.deploymentDevices - .where((element) => element.deviceManager is OnlineServiceManager) - .toList(); + late final List _smartphoneDevice = widget.model.smartphoneDevice; + late final List _hardwareDevices = widget.model.hardwareDevices; + late final List _onlineServices = widget.model.onlineServices; @override void initState() { @@ -49,15 +40,14 @@ class DeviceListPageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar(hasProfileIcon: true), ), Container( @@ -72,9 +62,8 @@ class DeviceListPageState extends State { children: [ Text( locale.translate('pages.devices.title'), - style: aboutStudyCardTitleStyle.copyWith( - color: - Theme.of(context).extension()!.grey900, + style: fs24fw700.copyWith( + color: Theme.of(context).extension()!.grey900, fontWeight: FontWeight.bold, ), ), @@ -93,12 +82,10 @@ class DeviceListPageState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(locale.translate("pages.devices.message"), - style: aboutCardSubtitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey600, - )), + Text( + locale.translate("pages.devices.message"), + style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey600), + ), const SizedBox(height: 15), ], ), @@ -107,14 +94,16 @@ class DeviceListPageState extends State { ), Expanded( flex: 4, - child: CustomScrollView( - slivers: [ - ..._smartphoneDeviceList(locale), - if (_hardwareDevices.isNotEmpty) - ..._hardwareDevicesList(locale), - if (_onlineServices.isNotEmpty) - ..._onlineServicesList(locale), - ], + child: RefreshIndicator( + onRefresh: _refreshStatuses, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + ..._smartphoneDeviceList(locale), + if (_hardwareDevices.isNotEmpty) ..._hardwareDevicesList(locale), + if (_onlineServices.isNotEmpty) ..._onlineServicesList(locale), + ], + ), ), ), ], @@ -123,112 +112,109 @@ class DeviceListPageState extends State { ); } + /// Re-check the current permission/connection state of all services (e.g. + /// after the user grants access in system settings). Status changes flow to + /// the cards via their [statusEvents] streams. + Future _refreshStatuses() async { + for (final service in _onlineServices) { + await service.deviceManager.hasPermissions(); + } + if (mounted) setState(() {}); + } + /// The list of smartphones - which is a list with only one smartphone. List _smartphoneDeviceList(RPLocalizations locale) => [ - DevicesPageListTitle(locale: locale, type: DevicesPageTypes.phone), - SliverList( - delegate: SliverChildBuilderDelegate( - childCount: _smartphoneDevice.length, - (BuildContext context, int index) => ListenableBuilder( - listenable: _smartphoneDevice[index], - builder: (BuildContext context, Widget? widget) => Center( - child: StudiesMaterial( - backgroundColor: - Theme.of(context).extension()!.grey50!, - child: _cardListBuilder( - leading: _smartphoneDevice[index].icon!, - title: ( - "${_smartphoneDevice[index].phoneInfo["model"]!} " - "- ${_smartphoneDevice[index].phoneInfo["version"]!}", - _smartphoneDevice[index].batteryLevel ?? 0 - ), - subtitle: _smartphoneDevice[index].phoneInfo['name']!, - ), + DevicesPageListTitle(locale: locale, type: DevicesPageTypes.phone), + SliverList( + delegate: SliverChildBuilderDelegate( + childCount: _smartphoneDevice.length, + (BuildContext context, int index) => ListenableBuilder( + listenable: _smartphoneDevice[index], + builder: (BuildContext context, Widget? widget) => Center( + child: StudiesMaterial( + backgroundColor: Theme.of(context).extension()!.grey50!, + child: _cardListBuilder( + leading: _smartphoneDevice[index].icon!, + title: ( + "${_smartphoneDevice[index].phoneInfo["model"]!} " + "- ${_smartphoneDevice[index].phoneInfo["version"]!}", + _smartphoneDevice[index].batteryLevel ?? 0, ), + subtitle: _smartphoneDevice[index].phoneInfo['name']!, ), ), ), ), - ]; + ), + ), + ]; /// The list of connected hardware devices (like a Polar sensor) List _hardwareDevicesList(RPLocalizations locale) => [ - DevicesPageListTitle(locale: locale, type: DevicesPageTypes.devices), - SliverList( - delegate: SliverChildBuilderDelegate( - childCount: _hardwareDevices.length, - (BuildContext context, int index) { - DeviceViewModel device = _hardwareDevices[index]; - return _devicesPageCardStream( - device.statusEvents, - DeviceStatus.unknown, - () => _cardListBuilder( - enableFeedback: true, - leading: device.icon!, - title: ( - locale.translate(device.typeName), - device.batteryLevel ?? 0 + DevicesPageListTitle(locale: locale, type: DevicesPageTypes.devices), + SliverList( + delegate: SliverChildBuilderDelegate(childCount: _hardwareDevices.length, (BuildContext context, int index) { + DeviceViewModel device = _hardwareDevices[index]; + return _devicesPageCardStream( + device.statusEvents, + DeviceStatus.unknown, + () => _cardListBuilder( + enableFeedback: true, + leading: device.icon!, + title: (locale.translate(device.typeName), device.batteryLevel ?? 0), + subtitle: device.name, + onTap: () async => await _hardwareDeviceClicked(device), + trailing: device.getDeviceStatusIcon is Icon + ? device.getDeviceStatusIcon as Icon + : Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: CACHET.DEPLOYMENT_DEPLOYING, + borderRadius: BorderRadius.circular(100), + ), + child: Text( + locale.translate(device.getDeviceStatusIcon as String), + style: fs20fw700.copyWith(color: Colors.white), + ), ), - subtitle: device.name, - onTap: () async => await _hardwareDeviceClicked(device), - trailing: device.getDeviceStatusIcon is Icon - ? device.getDeviceStatusIcon as Icon - : Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: CACHET.DEPLOYMENT_DEPLOYING, - borderRadius: BorderRadius.circular(100)), - child: Text( - locale.translate( - device.getDeviceStatusIcon as String), - style: aboutCardTitleStyle.copyWith( - color: Colors.white)), - ), - ), - ); - }, ), - ), - ]; + ); + }), + ), + ]; /// The list of online services (like a Location service) List _onlineServicesList(RPLocalizations locale) => [ - DevicesPageListTitle(locale: locale, type: DevicesPageTypes.services), - SliverList( - delegate: SliverChildBuilderDelegate( - childCount: _onlineServices.length, - (BuildContext context, int index) { - DeviceViewModel service = _onlineServices[index]; - return _devicesPageCardStream( - service.statusEvents, - DeviceStatus.unknown, - () => _cardListBuilder( - leading: service.icon!, - title: (locale.translate(service.typeName), null), - subtitle: null, - onTap: () async => await _onlineServiceClicked(service), - trailing: service.getServiceStatusIcon is String - ? Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: CACHET.DEPLOYMENT_DEPLOYING, - borderRadius: BorderRadius.circular(100)), - child: Text( - locale.translate( - service.getServiceStatusIcon as String), - style: aboutCardTitleStyle.copyWith( - color: Colors.white), - ), - ) - : service.getServiceStatusIcon as Icon, - ), - ); - }, + DevicesPageListTitle(locale: locale, type: DevicesPageTypes.services), + SliverList( + delegate: SliverChildBuilderDelegate(childCount: _onlineServices.length, (BuildContext context, int index) { + DeviceViewModel service = _onlineServices[index]; + return _devicesPageCardStream( + service.statusEvents, + DeviceStatus.unknown, + () => _cardListBuilder( + leading: service.icon!, + title: (locale.translate(service.typeName), null), + subtitle: null, + onTap: () async => await _onlineServiceClicked(service), + trailing: service.getServiceStatusIcon is String + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: CACHET.DEPLOYMENT_DEPLOYING, + borderRadius: BorderRadius.circular(100), + ), + child: Text( + locale.translate(service.getServiceStatusIcon as String), + style: fs20fw700.copyWith(color: Colors.white), + ), + ) + : service.getServiceStatusIcon as Icon, ), - ), - ]; + ); + }), + ), + ]; Widget _cardListBuilder({ bool enableFeedback = false, @@ -237,91 +223,70 @@ class DeviceListPageState extends State { String? subtitle, void Function()? onTap, Widget? trailing, - }) => - ListTile( - contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - enableFeedback: enableFeedback, - leading: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [leading!], - ), - title: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + }) => ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + enableFeedback: enableFeedback, + leading: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [leading!], + ), + title: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text(title!.$1, style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900)), + SizedBox(width: 6), + if (title.$2 != null && title.$2! > 0) BatteryPercentage(batteryLevel: title.$2 ?? 0), + ], + ), + ), + subtitle: subtitle != null && subtitle.isNotEmpty + ? Column( mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title!.$1, - style: deviceTitle.copyWith( - color: Theme.of(context).extension()!.grey900, + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + subtitle, + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), ), ), - SizedBox(width: 6), - if (title.$2 != null && title.$2! > 0) - BatteryPercentage(batteryLevel: title.$2 ?? 0), ], - ), - ), - subtitle: subtitle != null && subtitle.isNotEmpty - ? Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - subtitle, - style: deviceSubtitle.copyWith( - color: Theme.of(context).extension()!.grey700, - ), - ), - ), - ], - ) - : null, - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (trailing != null) trailing, - ], - ), - onTap: onTap, - ); + ) + : null, + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [?trailing], + ), + onTap: onTap, + ); - Widget _devicesPageCardStream( - Stream stream, - T? initialData, - Widget Function() childBuilder, - ) => - Center( - child: StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, - child: StreamBuilder( - stream: stream, - initialData: initialData, - builder: (context, AsyncSnapshot snapshot) => childBuilder(), - ), - ), - ); + Widget _devicesPageCardStream(Stream stream, T? initialData, Widget Function() childBuilder) => Center( + child: StudiesMaterial( + backgroundColor: Theme.of(context).extension()!.grey50!, + child: StreamBuilder( + stream: stream, + initialData: initialData, + builder: (context, AsyncSnapshot snapshot) => childBuilder(), + ), + ), + ); Future _onlineServiceClicked(DeviceViewModel service) async { - if (service.status == DeviceStatus.connected || - service.status == DeviceStatus.connecting) { + if (service.status == DeviceStatus.connected || service.status == DeviceStatus.connecting) { return; } if (!(await service.deviceManager.hasPermissions())) { if (service.type == HealthService.DEVICE_TYPE) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => HealthServiceConnectPage1()), - ); + Navigator.push(context, MaterialPageRoute(builder: (context) => HealthServiceConnectPage())); } else { await service.deviceManager.requestPermissions(); } @@ -337,17 +302,16 @@ class DeviceListPageState extends State { if (Platform.isAndroid) await FlutterBluePlus.turnOn(); if (context.mounted) { - if (bluetoothAdapterState == BluetoothAdapterState.off && - Platform.isIOS) { + if (bluetoothAdapterState == BluetoothAdapterState.off && Platform.isIOS) { await showDialog( context: context, barrierDismissible: true, builder: (context) => EnableBluetoothDialog(device: device), ); } else if (bluetoothAdapterState == BluetoothAdapterState.on) { - if (device.status == DeviceStatus.connected || - device.status == DeviceStatus.connecting) { - bool disconnect = await showDialog( + if (device.status == DeviceStatus.connected || device.status == DeviceStatus.connecting) { + bool disconnect = + await showDialog( context: context, barrierDismissible: true, builder: (context) => DisconnectionDialog(device: device), @@ -355,22 +319,18 @@ class DeviceListPageState extends State { false; if (disconnect) await device.disconnectFromDevice(); } else { - final hasSeenInstructions = - LocalSettings().hasSeenConnectionInstructions; + final hasSeenInstructions = LocalSettings().hasSeenBluetoothConnectionInstructions; Navigator.push( context, MaterialPageRoute( builder: (context) => BluetoothConnectionPage( - hasSeenInstructions - ? CurrentStep.scan - : CurrentStep.instructions, + hasSeenInstructions ? CurrentStep.scan : CurrentStep.instructions, device: device, ), ), ); } - } else if (bluetoothAdapterState == BluetoothAdapterState.unauthorized && - Platform.isIOS) { + } else if (bluetoothAdapterState == BluetoothAdapterState.unauthorized && Platform.isIOS) { await showDialog( context: context, barrierDismissible: true, diff --git a/lib/ui/pages/devices_page.authorization_dialog.dart b/lib/ui/pages/devices_page.authorization_dialog.dart index ec22957e..9a945369 100644 --- a/lib/ui/pages/devices_page.authorization_dialog.dart +++ b/lib/ui/pages/devices_page.authorization_dialog.dart @@ -8,20 +8,18 @@ class AuthorizationDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - scrollable: true, - titlePadding: const EdgeInsets.symmetric(vertical: 4), - insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), - title: const DialogTitle( - title: "pages.devices.connection.bluetooth_authorization.title", - ), - content: SizedBox( - height: MediaQuery.of(context).size.height * 0.45, - child: authorizationInstructions(context, device), - )); + scrollable: true, + titlePadding: const EdgeInsets.symmetric(vertical: 4), + insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), + title: const DialogTitle(title: "pages.devices.connection.bluetooth_authorization.title"), + content: SizedBox( + height: MediaQuery.of(context).size.height * 0.45, + child: authorizationInstructions(context, device), + ), + ); } - Widget authorizationInstructions( - BuildContext context, DeviceViewModel device) { + Widget authorizationInstructions(BuildContext context, DeviceViewModel device) { RPLocalizations locale = RPLocalizations.of(context)!; return Column( children: [ @@ -30,16 +28,17 @@ class AuthorizationDialog extends StatelessWidget { child: Column( children: [ Text( - locale.translate( - "pages.devices.connection.bluetooth_authorization.message"), - style: aboutCardContentStyle, + locale.translate("pages.devices.connection.bluetooth_authorization.message"), + style: fs16fw400, textAlign: TextAlign.justify, ), Image( - image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/bluetooth_enable_bar.png'), - width: MediaQuery.of(context).size.height * 0.2, - height: MediaQuery.of(context).size.height * 0.2), + image: AssetImage( + 'assets/instructions/${Localizations.localeOf(context).languageCode}/bluetooth_enable_bar.png', + ), + width: MediaQuery.of(context).size.height * 0.2, + height: MediaQuery.of(context).size.height * 0.2, + ), ], ), ), @@ -55,8 +54,7 @@ class AuthorizationDialog extends StatelessWidget { }, ), TextButton( - child: - Text(locale.translate("pages.devices.connection.settings")), + child: Text(locale.translate("pages.devices.connection.settings")), onPressed: () => OpenSettingsPlusIOS().bluetooth(), ), ], diff --git a/lib/ui/pages/devices_page.bluetooth_connection_page.dart b/lib/ui/pages/devices_page.bluetooth_connection_page.dart index 9d952dfc..daaddc48 100644 --- a/lib/ui/pages/devices_page.bluetooth_connection_page.dart +++ b/lib/ui/pages/devices_page.bluetooth_connection_page.dart @@ -6,15 +6,13 @@ enum CurrentStep { scan, instructions, done } class BluetoothConnectionPage extends StatefulWidget { final DeviceViewModel device; - const BluetoothConnectionPage(CurrentStep currentStep, - {super.key, required this.device}) - : _currentStep = currentStep; + const BluetoothConnectionPage(CurrentStep currentStep, {super.key, required this.device}) + : _currentStep = currentStep; final CurrentStep _currentStep; @override - State createState() => - _BluetoothConnectionPageState(_currentStep); + State createState() => _BluetoothConnectionPageState(_currentStep); } class _BluetoothConnectionPageState extends State { @@ -23,6 +21,7 @@ class _BluetoothConnectionPageState extends State { CurrentStep currentStep; bool isConnecting = false; Timer? _connectionTimeoutTimer; + StreamSubscription? _statusSubscription; @override initState() { @@ -34,6 +33,7 @@ class _BluetoothConnectionPageState extends State { void dispose() { FlutterBluePlus.stopScan(); _connectionTimeoutTimer?.cancel(); + _statusSubscription?.cancel(); LocalSettings().hasSeenBluetoothConnectionInstructions = true; super.dispose(); } @@ -46,16 +46,15 @@ class _BluetoothConnectionPageState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Stack( children: [ Container( - color: Theme.of(context).colorScheme.secondary, child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 16), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16), child: const CarpAppBar(hasProfileIcon: true), ), Expanded( @@ -67,14 +66,11 @@ class _BluetoothConnectionPageState extends State { Expanded( child: Padding( padding: const EdgeInsets.all(8.0), - child: SizedBox( - child: _buildStepContent(locale), - ), + child: SizedBox(child: _buildStepContent(locale)), ), ), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: _buildActionButtons(locale), @@ -90,9 +86,7 @@ class _BluetoothConnectionPageState extends State { if (isConnecting) Container( color: Colors.black26, - child: const Center( - child: CircularProgressIndicator(), - ), + child: const Center(child: CircularProgressIndicator()), ), ], ), @@ -102,13 +96,10 @@ class _BluetoothConnectionPageState extends State { Widget _buildDialogTitle(RPLocalizations locale) { final stepTitleMap = { - CurrentStep.scan: - locale.translate("pages.devices.connection.step.start.title"), - CurrentStep.instructions: - locale.translate("pages.devices.connection.step.how_to.title"), + CurrentStep.scan: locale.translate("pages.devices.connection.step.start.title"), + CurrentStep.instructions: locale.translate("pages.devices.connection.step.how_to.title"), CurrentStep.done: - locale.translate("pages.devices.connection.step.confirm.title") + - (" ${selectedDevice?.platformName} "), + locale.translate("pages.devices.connection.step.confirm.title") + (" ${selectedDevice?.platformName} "), }; return Padding( padding: const EdgeInsets.only(bottom: 16), @@ -120,9 +111,7 @@ class _BluetoothConnectionPageState extends State { Flexible( child: Text( stepTitleMap[currentStep] ?? '', - style: healthServiceConnectMessageStyle.copyWith( - color: Theme.of(context).primaryColor, - ), + style: fs22fw700.copyWith(color: Theme.of(context).primaryColor), textAlign: TextAlign.center, ), ), @@ -143,42 +132,52 @@ class _BluetoothConnectionPageState extends State { } List _buildActionButtons(RPLocalizations locale) { - Widget buildTranslatedButton(String key, VoidCallback onPressed, - bool enabled, ButtonStyle? buttonStyle, TextStyle? buttonTextStyle) { + Widget buildTranslatedButton( + String key, + VoidCallback onPressed, + bool enabled, + ButtonStyle? buttonStyle, + TextStyle? buttonTextStyle, + ) { return ElevatedButton( onPressed: enabled ? onPressed : null, - child: Text( - locale.translate(key).toUpperCase(), - style: buttonTextStyle, - ), + child: Text(locale.translate(key).toUpperCase(), style: buttonTextStyle), style: buttonStyle, ); } final stepButtonConfigs = { CurrentStep.scan: [ - buildTranslatedButton("cancel", () { - context.pop(true); - }, true, null, null), + buildTranslatedButton( + "cancel", + () { + context.pop(true); + }, + true, + null, + null, + ), buildTranslatedButton( "next", _connectDevice(), selectedDevice != null, ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).extension()!.primary, + backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), - TextStyle( - color: Colors.white, - ), + TextStyle(color: Colors.white), ), ], CurrentStep.instructions: [ - buildTranslatedButton("settings", () { - Platform.isAndroid - ? OpenSettingsPlusAndroid().bluetooth() - : OpenSettingsPlusIOS().bluetooth(); - }, true, null, null), + buildTranslatedButton( + "settings", + () { + Platform.isAndroid ? OpenSettingsPlusAndroid().bluetooth() : OpenSettingsPlusIOS().bluetooth(); + }, + true, + null, + null, + ), buildTranslatedButton( "ok", () { @@ -186,18 +185,22 @@ class _BluetoothConnectionPageState extends State { }, true, ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).extension()!.primary, + backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), - TextStyle( - color: Colors.white, - ), + TextStyle(color: Colors.white), ), ], CurrentStep.done: [ - buildTranslatedButton("back", () { - setState(() => currentStep = CurrentStep.scan); - }, true, null, null), + buildTranslatedButton( + "back", + () { + setState(() => currentStep = CurrentStep.scan); + }, + true, + null, + null, + ), buildTranslatedButton( "done", () { @@ -206,12 +209,10 @@ class _BluetoothConnectionPageState extends State { }, true, ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).extension()!.primary, + backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), - TextStyle( - color: Colors.white, - ), + TextStyle(color: Colors.white), ), ], }; @@ -228,7 +229,9 @@ class _BluetoothConnectionPageState extends State { FlutterBluePlus.stopScan(); widget.device.connectToDevice(selectedDevice!); - widget.device.statusEvents.listen((state) { + // Repeated connect attempts must not stack listeners. + _statusSubscription?.cancel(); + _statusSubscription = widget.device.statusEvents.listen((state) { if (state == DeviceStatus.connected) { _connectionTimeoutTimer?.cancel(); if (mounted) { @@ -259,10 +262,8 @@ class _BluetoothConnectionPageState extends State { context: context, builder: (BuildContext context) { return AlertDialog( - title: Text(locale.translate( - "pages.devices.connection.connection_failed.title")), - content: Text(locale.translate( - "pages.devices.connection.connection_failed.message")), + title: Text(locale.translate("pages.devices.connection.connection_failed.title")), + content: Text(locale.translate("pages.devices.connection.connection_failed.message")), actions: [ TextButton( onPressed: () { @@ -281,10 +282,7 @@ class _BluetoothConnectionPageState extends State { }; } - Widget stepContent( - CurrentStep currentStep, - DeviceViewModel device, - ) { + Widget stepContent(CurrentStep currentStep, DeviceViewModel device) { if (currentStep == CurrentStep.scan) { return scanWidget(device, context); } else if (currentStep == CurrentStep.instructions) { @@ -305,41 +303,47 @@ class _BluetoothConnectionPageState extends State { "${locale.translate("pages.devices.connection.step.scan.1")} " "${locale.translate(device.typeName)} " "${locale.translate("pages.devices.connection.step.scan.2")}", - style: healthServiceConnectMessageStyle, + style: fs22fw700, textAlign: TextAlign.justify, ), Expanded( child: StreamBuilder>( stream: FlutterBluePlus.scanResults, initialData: const [], - builder: (context, snapshot) => SingleChildScrollView( - padding: const EdgeInsets.only(top: 16), - child: Column( - children: snapshot.data! - .where( - (element) => element.device.platformName.isNotEmpty) - .toList() - .asMap() - .entries - .map( - (bluetoothDevice) => ListTile( - selected: bluetoothDevice.key == selected, - title: Text( - bluetoothDevice.value.device.platformName, - style: healthServiceConnectMessageStyle, + builder: (context, snapshot) => Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + padding: const EdgeInsets.only(top: 16), + child: Column( + children: snapshot.data! + .where((element) => element.device.platformName.isNotEmpty) + .toList() + .asMap() + .entries + .map( + (bluetoothDevice) => StudiesMaterial( + // hasBorder: true, + backgroundColor: Theme.of(context).extension()!.grey50!, + child: InkWell( + child: ListTile( + selected: bluetoothDevice.key == selected, + title: Text( + bluetoothDevice.value.device.platformName, + style: fs22fw700.copyWith(fontSize: 20), + ), + selectedTileColor: Theme.of(context).primaryColor.withValues(alpha: 0.2), + ), + onTap: () { + selectedDevice = bluetoothDevice.value.device; + setState(() { + selected = bluetoothDevice.key; + }); + }, + ), ), - selectedTileColor: Theme.of(context) - .primaryColor - .withValues(alpha: 0.2), - onTap: () { - selectedDevice = bluetoothDevice.value.device; - setState(() { - selected = bluetoothDevice.key; - }); - }, - ), - ) - .toList(), + ) + .toList(), + ), ), ), ), @@ -349,15 +353,11 @@ class _BluetoothConnectionPageState extends State { child: Text.rich( TextSpan( children: [ + TextSpan(text: locale.translate("pages.devices.connection.step.start.1")), TextSpan( - text: locale - .translate("pages.devices.connection.step.start.1"), - ), - TextSpan( - text: locale - .translate("pages.devices.connection.instructions"), + text: locale.translate("pages.devices.connection.instructions"), style: TextStyle( - color: Theme.of(context).extension()!.primary, + color: Theme.of(context).extension()!.primary, decoration: TextDecoration.underline, fontWeight: FontWeight.bold, ), @@ -366,18 +366,13 @@ class _BluetoothConnectionPageState extends State { setState(() => currentStep = CurrentStep.instructions); }, ), - TextSpan( - text: locale.translate( - "pages.devices.connection.step.start.2", - ), - ), + TextSpan(text: locale.translate("pages.devices.connection.step.start.2")), ], ), - style: healthServiceConnectMessageStyle.copyWith( - color: Theme.of(context).extension()!.grey900), + style: fs22fw700.copyWith(color: Theme.of(context).extension()!.grey900), textAlign: TextAlign.center, ), - ) + ), ], ), ); @@ -390,28 +385,22 @@ class _BluetoothConnectionPageState extends State { switch (device.deviceManager) { case PolarDeviceManager _ when device.type == PolarDevice.DEVICE_TYPE && - (device.polarDeviceType == PolarDeviceType.H10 || - device.polarDeviceType == PolarDeviceType.H9): - assetImage = - AssetImage('assets/instructions/polar_h9_h10_instructions.png'); + (device.polarDeviceType == PolarDeviceType.H10 || device.polarDeviceType == PolarDeviceType.H9): + assetImage = AssetImage('assets/instructions/polar_h9_h10_instructions.png'); break; case PolarDeviceManager _ - when device.type == PolarDevice.DEVICE_TYPE && - device.polarDeviceType == PolarDeviceType.SENSE: - assetImage = - AssetImage('assets/instructions/polar_sense_instructions.png'); + when device.type == PolarDevice.DEVICE_TYPE && device.polarDeviceType == PolarDeviceType.Verity: + assetImage = AssetImage('assets/instructions/polar_sense_instructions.png'); break; // if device type is not defined in the protocol, show h9, h10 instructions case PolarDeviceManager _: - assetImage = - AssetImage('assets/instructions/polar_h9_h10_instructions.png'); + assetImage = AssetImage('assets/instructions/polar_h9_h10_instructions.png'); break; case MovesenseDeviceManager _: - assetImage = - AssetImage('assets/instructions/movesense_instructions.png'); + assetImage = AssetImage('assets/instructions/movesense_instructions.png'); break; default: @@ -420,8 +409,8 @@ class _BluetoothConnectionPageState extends State { Image connectionImage = Image( image: assetImage, - width: MediaQuery.of(context).size.height * 0.5, - height: MediaQuery.of(context).size.height * 0.5, + width: MediaQuery.of(context).size.height * 0.3, + height: MediaQuery.of(context).size.height * 0.3, ); return Column( children: [ @@ -436,7 +425,7 @@ class _BluetoothConnectionPageState extends State { padding: const EdgeInsets.only(bottom: 20.0), child: Text( locale.translate(device.connectionInstructions!), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), ), @@ -459,15 +448,16 @@ class _BluetoothConnectionPageState extends State { child: Column( children: [ Image( - image: const AssetImage('assets/icons/connection_done.png'), - width: MediaQuery.of(context).size.height * 0.2, - height: MediaQuery.of(context).size.height * 0.2), + image: const AssetImage('assets/icons/connection_done.png'), + width: MediaQuery.of(context).size.height * 0.2, + height: MediaQuery.of(context).size.height * 0.2, + ), Padding( padding: const EdgeInsets.only(top: 32), child: Text( ("${locale.translate("pages.devices.connection.step.confirm.1")} '${device?.platformName}' ${locale.translate("pages.devices.connection.step.confirm.2")}") .trim(), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), ), diff --git a/lib/ui/pages/devices_page.disconnection_dialog.dart b/lib/ui/pages/devices_page.disconnection_dialog.dart index a1326908..a27e9321 100644 --- a/lib/ui/pages/devices_page.disconnection_dialog.dart +++ b/lib/ui/pages/devices_page.disconnection_dialog.dart @@ -8,15 +8,12 @@ class DisconnectionDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - scrollable: true, - titlePadding: const EdgeInsets.symmetric(vertical: 4), - insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), - title: const DialogTitle( - title: "pages.devices.connection.disconnect_bluetooth.title", - ), - content: SizedBox( - child: disconnectBluetooth(context, device), - )); + scrollable: true, + titlePadding: const EdgeInsets.symmetric(vertical: 4), + insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), + title: const DialogTitle(title: "pages.devices.connection.disconnect_bluetooth.title"), + content: SizedBox(child: disconnectBluetooth(context, device)), + ); } Widget disconnectBluetooth(BuildContext context, DeviceViewModel device) { @@ -26,16 +23,14 @@ class DisconnectionDialog extends StatelessWidget { children: [ Text( "${locale.translate("pages.devices.connection.disconnect_bluetooth.message")} ${locale.translate(device.name)}?", - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ TextButton( - child: Text( - locale.translate("cancel"), - ), + child: Text(locale.translate("cancel")), onPressed: () { if (context.canPop()) context.pop(false); }, @@ -44,13 +39,10 @@ class DisconnectionDialog extends StatelessWidget { onPressed: () { if (context.canPop()) context.pop(true); }, - child: Text( - locale.translate( - "pages.devices.connection.disconnect_bluetooth.disconnect"), - ), + child: Text(locale.translate("pages.devices.connection.disconnect_bluetooth.disconnect")), ), ], - ) + ), ], ); } diff --git a/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart b/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart index 7c6d3aa6..b31d02c3 100644 --- a/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart +++ b/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart @@ -8,19 +8,18 @@ class EnableBluetoothDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - scrollable: true, - titlePadding: const EdgeInsets.symmetric(vertical: 4), - insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), - title: const DialogTitle( - title: "pages.devices.connection.enable_bluetooth.title"), - content: SizedBox( - height: MediaQuery.of(context).size.height * 0.45, - child: enableBluetoothInstructions(context, device), - )); + scrollable: true, + titlePadding: const EdgeInsets.symmetric(vertical: 4), + insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), + title: const DialogTitle(title: "pages.devices.connection.enable_bluetooth.title"), + content: SizedBox( + height: MediaQuery.of(context).size.height * 0.45, + child: enableBluetoothInstructions(context, device), + ), + ); } - Widget enableBluetoothInstructions( - BuildContext context, DeviceViewModel device) { + Widget enableBluetoothInstructions(BuildContext context, DeviceViewModel device) { RPLocalizations locale = RPLocalizations.of(context)!; return Column( children: [ @@ -29,18 +28,14 @@ class EnableBluetoothDialog extends StatelessWidget { child: Column( children: [ Text( - locale.translate( - "pages.devices.connection.enable_bluetooth.message1"), - style: aboutCardContentStyle, + locale.translate("pages.devices.connection.enable_bluetooth.message1"), + style: fs16fw400, textAlign: TextAlign.justify, ), - Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - ), + Padding(padding: EdgeInsets.symmetric(vertical: 16.0)), Text( - locale.translate( - "pages.devices.connection.enable_bluetooth.message2"), - style: aboutCardContentStyle, + locale.translate("pages.devices.connection.enable_bluetooth.message2"), + style: fs16fw400, textAlign: TextAlign.justify, ), if (Platform.isAndroid || Platform.isIOS) @@ -48,13 +43,13 @@ class EnableBluetoothDialog extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 16.0), child: Image( image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/bluetooth_enable_connections_bar.png'), + 'assets/instructions/${Localizations.localeOf(context).languageCode}/bluetooth_enable_connections_bar.png', + ), ), ), Text( - locale.translate( - "pages.devices.connection.enable_bluetooth.message3"), - style: aboutCardContentStyle, + locale.translate("pages.devices.connection.enable_bluetooth.message3"), + style: fs16fw400, textAlign: TextAlign.justify, ), ], diff --git a/lib/ui/pages/devices_page.health_service_connect.dart b/lib/ui/pages/devices_page.health_service_connect.dart new file mode 100644 index 00000000..9c86b0d1 --- /dev/null +++ b/lib/ui/pages/devices_page.health_service_connect.dart @@ -0,0 +1,168 @@ +part of carp_study_app; + +class HealthServiceConnectPage extends StatelessWidget { + const HealthServiceConnectPage({super.key}); + + @override + Widget build(BuildContext context) { + RPLocalizations locale = RPLocalizations.of(context)!; + + DeviceViewModel healthServive = bloc.appViewModel.devicesPageViewModel.healthService!; + + return Scaffold( + backgroundColor: Theme.of(context).extension()!.grey100, + body: SafeArea( + child: Container( + child: Column( + children: [ + Padding(padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), child: const CarpAppBar()), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Center( + child: Image.asset( + Platform.isAndroid + ? 'assets/instructions/google_health_connect_preview.png' + : 'assets/instructions/apple_health_preview.png', + fit: BoxFit.contain, + width: double.infinity, + ), + ), + ), + const SizedBox(height: 20), + _dataDisclosure(context, locale), + const SizedBox(height: 20), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: "${locale.translate("pages.devices.type.health.instructions.page2.part1")} ", + style: fs22fw700.copyWith(color: Theme.of(context).extension()!.grey900), + ), + TextSpan( + text: + "${Platform.isAndroid ? locale.translate("pages.devices.type.health.instructions.page2.android.allow_all") : locale.translate("pages.devices.type.health.instructions.page2.ios.turn_on_all")} ", + style: fs22fw700.copyWith( + color: Theme.of(context).extension()!.primary, // Change to desired color + ), + ), + TextSpan( + text: "${locale.translate("pages.devices.type.health.instructions.page2.part2")} ", + style: fs22fw700.copyWith(color: Theme.of(context).extension()!.grey900), + ), + TextSpan( + text: "${locale.translate("pages.devices.type.health.instructions.page2.allow")} ", + style: fs22fw700.copyWith( + color: Theme.of(context).extension()!.primary, // Change to desired color + ), + ), + TextSpan( + text: Platform.isAndroid + ? locale.translate("pages.devices.type.health.instructions.page2.part3.android") + : locale.translate("pages.devices.type.health.instructions.page2.part3.ios"), + style: fs22fw700.copyWith(color: Theme.of(context).extension()!.grey900), + ), + ], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 30), + ], + ), + ), + ), + ], + ), + ), + ), + bottomNavigationBar: Padding( + padding: const EdgeInsets.symmetric(vertical: 26), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + OutlinedButton( + child: const Text("Cancel"), + onPressed: () { + Navigator.pop(context); + }, + ), + ElevatedButton( + child: const Text("Next", style: TextStyle(color: Colors.white)), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).extension()!.primary, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), + ), + onPressed: () async { + await healthServive.deviceManager.requestPermissions(); + await healthServive.deviceManager.connect(); + + if (!context.mounted) return; + // If access still isn't granted (e.g. permanently denied, so the + // system sheet no longer appears), guide the user to grant it. + if (!healthServive.deviceManager.isConnected) { + await showDialog(context: context, builder: (context) => _accessDeniedDialog(context, locale)); + } + if (context.mounted) Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } + + Widget _accessDeniedDialog(BuildContext context, RPLocalizations locale) => AlertDialog( + title: Text(locale.translate("pages.devices.type.health.access_denied.title")), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(locale.translate("pages.devices.type.health.access_denied.message")), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset('assets/instructions/health_permission_allow_all.png'), + ), + ], + ), + ), + actions: [ + TextButton(child: Text(locale.translate("cancel")), onPressed: () => Navigator.pop(context)), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).extension()!.primary), + child: Text(locale.translate("settings"), style: const TextStyle(color: Colors.white)), + onPressed: () { + Platform.isAndroid ? OpenSettingsPlusAndroid().applicationDetails() : OpenSettingsPlusIOS().appSettings(); + Navigator.pop(context); + }, + ), + ], + ); + + Widget _dataDisclosure(BuildContext context, RPLocalizations locale) { + final colors = Theme.of(context).extension()!; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: colors.grey200, borderRadius: BorderRadius.circular(12)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.favorite_outline, color: colors.primary, size: 24), + const SizedBox(width: 12), + Expanded( + child: Text( + locale.translate("pages.devices.type.health.instructions.data.title"), + style: fs16fw600.copyWith(color: colors.grey900), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/pages/devices_page.health_service_connect1.dart b/lib/ui/pages/devices_page.health_service_connect1.dart deleted file mode 100644 index 70f358e8..00000000 --- a/lib/ui/pages/devices_page.health_service_connect1.dart +++ /dev/null @@ -1,116 +0,0 @@ -part of carp_study_app; - -class HealthServiceConnectPage1 extends StatelessWidget { - const HealthServiceConnectPage1({super.key}); - - @override - Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; - return Scaffold( - body: SafeArea( - child: Container( - color: Theme.of(context).colorScheme.secondary, - child: Column( - children: [ - Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), - child: const CarpAppBar(hasProfileIcon: true), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 10, - spreadRadius: 2, - ), - ], - ), - child: Image.asset( - Platform.isAndroid - ? 'assets/instructions/google_health_connect_icon.png' - : 'assets/instructions/apple_health_icon.png', - height: 250, - width: 250, - ), - ), - const SizedBox(height: 20), - Text( - Platform.isAndroid - ? locale.translate( - "pages.devices.type.health.instructions.page1.android") - : locale.translate( - "pages.devices.type.health.instructions.page1.ios"), - style: healthServiceConnectTitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .primary), - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - Text( - "${locale.translate("pages.devices.type.health.instructions.page1.part1")} " - "${Platform.isAndroid ? locale.translate("pages.devices.type.health.instructions.page1.android") : locale.translate("pages.devices.type.health.instructions.page1.ios")} " - "${locale.translate("pages.devices.type.health.instructions.page1.part2")}", - style: healthServiceConnectMessageStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900), - textAlign: TextAlign.center, - ), - const SizedBox(height: 30), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton( - child: const Text("Cancel"), - onPressed: () { - Navigator.pop(context); - }, - ), - ElevatedButton( - child: Text( - locale.translate("Next"), - style: TextStyle( - color: Colors.white, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context) - .extension()! - .primary, - padding: const EdgeInsets.symmetric( - horizontal: 30, vertical: 12), - ), - onPressed: () { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => - HealthServiceConnectPage2()), - ); - }, - ), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/ui/pages/devices_page.health_service_connect2.dart b/lib/ui/pages/devices_page.health_service_connect2.dart deleted file mode 100644 index 9d79643d..00000000 --- a/lib/ui/pages/devices_page.health_service_connect2.dart +++ /dev/null @@ -1,154 +0,0 @@ -part of carp_study_app; - -class HealthServiceConnectPage2 extends StatelessWidget { - const HealthServiceConnectPage2({super.key}); - - @override - Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; - - DeviceViewModel healthServive = bloc.deploymentDevices - .where((element) => - element.deviceManager is OnlineServiceManager && - element.type == HealthService.DEVICE_TYPE) - .first; - - return Scaffold( - body: SafeArea( - child: Container( - color: Theme.of(context).colorScheme.secondary, - child: Column( - children: [ - Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), - child: const CarpAppBar(hasProfileIcon: true), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 10, - spreadRadius: 2, - ), - ], - ), - child: Image.asset( - Platform.isAndroid - ? 'assets/instructions/google_health_connect_icon.png' - : 'assets/instructions/apple_health_icon.png', - height: 250, - width: 250, - ), - ), - const SizedBox(height: 20), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: - "${locale.translate("pages.devices.type.health.instructions.page2.part1")} ", - style: healthServiceConnectMessageStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900, - ), - ), - TextSpan( - text: - "${Platform.isAndroid ? locale.translate("pages.devices.type.health.instructions.page2.android.allow_all") : locale.translate("pages.devices.type.health.instructions.page2.ios.turn_on_all")} ", - style: healthServiceConnectMessageStyle.copyWith( - color: Theme.of(context) - .extension()! - .primary, // Change to desired color - ), - ), - TextSpan( - text: - "${locale.translate("pages.devices.type.health.instructions.page2.part2")} ", - style: healthServiceConnectMessageStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900, - ), - ), - TextSpan( - text: - "${locale.translate("pages.devices.type.health.instructions.page2.allow")} ", - style: healthServiceConnectMessageStyle.copyWith( - color: Theme.of(context) - .extension()! - .primary, // Change to desired color - ), - ), - TextSpan( - text: Platform.isAndroid - ? locale.translate( - "pages.devices.type.health.instructions.page2.part3.android") - : locale.translate( - "pages.devices.type.health.instructions.page2.part3.ios"), - style: healthServiceConnectMessageStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900, - ), - ), - ], - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 30), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton( - child: const Text("Cancel"), - onPressed: () { - Navigator.pop(context); - }, - ), - ElevatedButton( - child: const Text( - "Next", - style: TextStyle( - color: Colors.white, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context) - .extension()! - .primary, - padding: const EdgeInsets.symmetric( - horizontal: 30, vertical: 12), - ), - onPressed: () async { - await healthServive.deviceManager - .requestPermissions(); - await healthServive.deviceManager.connect(); - - Navigator.pop(context); - }, - ), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/ui/pages/devices_page.list_title.dart b/lib/ui/pages/devices_page.list_title.dart index 339c4c7f..1afaf6ba 100644 --- a/lib/ui/pages/devices_page.list_title.dart +++ b/lib/ui/pages/devices_page.list_title.dart @@ -1,17 +1,9 @@ part of carp_study_app; -enum DevicesPageTypes { - phone, - services, - devices, -} +enum DevicesPageTypes { phone, services, devices } class DevicesPageListTitle extends StatelessWidget { - const DevicesPageListTitle({ - super.key, - required this.locale, - required this.type, - }); + const DevicesPageListTitle({super.key, required this.locale, required this.type}); final RPLocalizations locale; final DevicesPageTypes type; @@ -22,10 +14,12 @@ class DevicesPageListTitle extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), child: Text( - locale.translate("pages.devices.${type.name}.title").toUpperCase(), - style: dataCardTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey900, - fontWeight: FontWeight.bold)), + locale.translate("pages.devices.${type.name}.title").toUpperCase(), + style: fs16fw400ls1.copyWith( + color: Theme.of(context).extension()!.grey900, + fontWeight: FontWeight.bold, + ), + ), ), ); } diff --git a/lib/ui/pages/enable_connection_dialog.dart b/lib/ui/pages/enable_connection_dialog.dart index 5ebb653d..ef7bcaad 100644 --- a/lib/ui/pages/enable_connection_dialog.dart +++ b/lib/ui/pages/enable_connection_dialog.dart @@ -9,9 +9,7 @@ class EnableInternetConnectionDialog extends StatelessWidget { scrollable: true, titlePadding: const EdgeInsets.symmetric(vertical: 4), insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), - title: DialogTitle( - title: - "pages.login.internet_connection.enable_internet_connections.title"), + title: DialogTitle(title: "pages.login.internet_connection.enable_internet_connections.title"), content: SizedBox( height: MediaQuery.of(context).size.height * 0.45, child: (() { @@ -35,32 +33,31 @@ class EnableInternetConnectionDialog extends StatelessWidget { child: Column( children: [ Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.general_message"), - style: aboutCardContentStyle, + locale.translate("pages.login.internet_connection.enable_internet_connections.general_message"), + style: fs16fw400, textAlign: TextAlign.justify, ), Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.wifi_message"), - style: aboutCardContentStyle, - textAlign: TextAlign.justify, - )), + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Text( + locale.translate("pages.login.internet_connection.enable_internet_connections.wifi_message"), + style: fs16fw400, + textAlign: TextAlign.justify, + ), + ), Padding( padding: EdgeInsets.symmetric(vertical: 16.0), child: Image( image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_wifi_android.png'), + 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_wifi_android.png', + ), ), ), Padding( padding: EdgeInsets.symmetric(vertical: 16.0), child: Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.mobile_data_message"), - style: aboutCardContentStyle, + locale.translate("pages.login.internet_connection.enable_internet_connections.mobile_data_message"), + style: fs16fw400, textAlign: TextAlign.justify, ), ), @@ -68,7 +65,8 @@ class EnableInternetConnectionDialog extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 16.0), child: Image( image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_mobile_data_android.png'), + 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_mobile_data_android.png', + ), ), ), ], @@ -109,42 +107,46 @@ class EnableInternetConnectionDialog extends StatelessWidget { child: Column( children: [ Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.general_message"), - style: aboutCardContentStyle, + locale.translate("pages.login.internet_connection.enable_internet_connections.general_message"), + style: fs16fw400, textAlign: TextAlign.justify, ), Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.wifi_message"), - style: aboutCardContentStyle, - textAlign: TextAlign.justify, - )), + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Text( + locale.translate("pages.login.internet_connection.enable_internet_connections.wifi_message"), + style: fs16fw400, + textAlign: TextAlign.justify, + ), + ), Padding( padding: EdgeInsets.symmetric(vertical: 16.0), child: Image( image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_wifi_ios.png'), + 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_wifi_ios.png', + ), ), ), Padding( padding: EdgeInsets.symmetric(vertical: 16.0), - child: Column(children: [ - Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.mobile_data_message"), - style: aboutCardContentStyle, - textAlign: TextAlign.justify, - ), - ]), + child: Column( + children: [ + Text( + locale.translate( + "pages.login.internet_connection.enable_internet_connections.mobile_data_message", + ), + style: fs16fw400, + textAlign: TextAlign.justify, + ), + ], + ), ), Padding( padding: EdgeInsets.symmetric(vertical: 16.0), child: Image( image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_mobile_data_ios.png'), + 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_mobile_data_ios.png', + ), ), ), ], diff --git a/lib/ui/pages/error_page.dart b/lib/ui/pages/error_page.dart index 9cd9db47..76dde662 100644 --- a/lib/ui/pages/error_page.dart +++ b/lib/ui/pages/error_page.dart @@ -6,22 +6,14 @@ class ErrorPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Error'), - ), + appBar: AppBar(title: const Text('Error')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( - "Error", - style: TextStyle(fontSize: 18.0), - ), + const Text("Error", style: TextStyle(fontSize: 18.0)), const SizedBox(height: 16.0), - ElevatedButton( - onPressed: () => context.go(CarpStudyAppState.homeRoute), - child: const Text('Go back'), - ), + ElevatedButton(onPressed: () => context.go(CarpAppState.homeRoute), child: const Text('Go back')), ], ), ), diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index aa6aacc8..31f21a0d 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -1,82 +1,35 @@ part of carp_study_app; -/// The home page of the app. +/// The home page of the app - the navigation bar around the shell pages. /// -/// Shown once the onboarding process is done. +/// Shown once the onboarding process is done. All setup orchestration +/// (consent gating, study configuration, starting sensing) is owned by the +/// [AppBloc] and the router redirect - not this page. class HomePage extends StatefulWidget { + final HomePageViewModel model; final Widget child; - const HomePage({required this.child, super.key}); + const HomePage({required this.model, required this.child, super.key}); @override HomePageState createState() => HomePageState(); } class HomePageState extends State { - /// Ask for location permissions. - /// - /// The method opens the [LocationPermissionPage] if location permissions are - /// needed and not yet granted. - /// - /// Android requires the app to show a modal window explaining "why" the app - /// needs access to location. Best practice for doing this is explain on the - /// [Request location permissions](https://developer.android.com/develop/sensors-and-location/location/permissions) - /// Android Developer page. - /// - /// This approach is used on both Android and iOS, even though it is an - /// Android recommendation / requirement. - Future askForLocationPermissions(BuildContext context) async { - if (!context.mounted) return; - - if (bloc.usingLocationPermissions) { - var granted = await LocationManager().isGranted(); - if (!granted) { - await showGeneralDialog( - context: context, - barrierDismissible: false, - barrierColor: Colors.black38, - transitionBuilder: (ctx, anim1, anim2, child) => BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: 4 * anim1.value, sigmaY: 4 * anim1.value), - child: FadeTransition( - opacity: anim1, - child: child, - ), - ), - pageBuilder: (context, anim1, anim2) => - LocationPermissionPage().build( - context, - "dialog.location.info", - )); - await LocationManager().requestPermission(); - } - } - } - @override void initState() { super.initState(); + widget.model.addListener(_onModelChanged); + } - // Setting up sensing, which entails; - // - asking for location permissions - // - configuring the study - // - loading localizations - // - starting sensing - askForLocationPermissions(context) - .then((_) => bloc.configureStudy().then((_) { - // Load localizations for the current locale and study - CarpStudyApp.reloadLocale(context); - bloc.start(); - })); - - if (Platform.isAndroid) { - // Check if HealthConnect is installed - _checkHealthConnectInstallation(); - } + @override + void dispose() { + widget.model.removeListener(_onModelChanged); + super.dispose(); } - Future _checkHealthConnectInstallation() async { - bool isInstalled = await bloc.isHealthInstalled(); - if (!isInstalled) { + void _onModelChanged() { + if (widget.model.shouldPromptHealthConnectInstall && mounted) { + widget.model.healthConnectPromptShown(); showDialog( context: context, barrierDismissible: true, @@ -89,45 +42,34 @@ class HomePageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; - // Save the localization for the app - bloc.localization = locale; - - // Listen for user task notification clicked in the OS - AppTaskController().userTaskEvents.listen((userTask) { - if (userTask.state == UserTaskState.notified) { - userTask.onStart(); - if (userTask.hasWidget) context.push('/task/${userTask.id}'); - } - }); - return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, - body: SafeArea( - child: widget.child, - ), + backgroundColor: Theme.of(context).extension()!.backgroundGray, + body: SafeArea(child: widget.child), bottomNavigationBar: BottomNavigationBar( - backgroundColor: Theme.of(context).extension()!.white, + backgroundColor: Theme.of(context).extension()!.white, type: BottomNavigationBarType.fixed, - selectedItemColor: Theme.of(context).extension()!.primary, - //unselectedItemColor: Theme.of(context).primaryColor.withOpacity(0.8), + selectedItemColor: Theme.of(context).extension()!.primary, items: [ BottomNavigationBarItem( - icon: const Icon(Icons.announcement), - label: locale.translate('app_home.nav_bar_item.about'), - activeIcon: const Icon(Icons.announcement)), + icon: const Icon(Icons.announcement), + label: locale.translate('app_home.nav_bar_item.about'), + activeIcon: const Icon(Icons.announcement), + ), BottomNavigationBarItem( icon: const Icon(Icons.playlist_add_check), label: locale.translate('app_home.nav_bar_item.tasks'), activeIcon: const Icon(Icons.playlist_add_check), ), BottomNavigationBarItem( - icon: const Icon(Icons.leaderboard), - label: locale.translate('app_home.nav_bar_item.data'), - activeIcon: const Icon(Icons.leaderboard)), + icon: const Icon(Icons.leaderboard), + label: locale.translate('app_home.nav_bar_item.data'), + activeIcon: const Icon(Icons.leaderboard), + ), BottomNavigationBarItem( - icon: const Icon(Icons.devices_other), - label: locale.translate('app_home.nav_bar_item.devices'), - activeIcon: const Icon(Icons.devices_other)), + icon: const Icon(Icons.devices_other), + label: locale.translate('app_home.nav_bar_item.devices'), + activeIcon: const Icon(Icons.devices_other), + ), ], currentIndex: _calculateSelectedIndex(context), onTap: (int idx) => _onItemTapped(idx, context), @@ -167,7 +109,7 @@ class HomePageState extends State { context.go(DeviceListPage.route); break; case -1: - context.go(CarpStudyAppState.homeRoute); + context.go(CarpAppState.homeRoute); break; } } diff --git a/lib/ui/pages/home_page.install_health_connect_dialog.dart b/lib/ui/pages/home_page.install_health_connect_dialog.dart index 2a60a0cf..d36b8df1 100644 --- a/lib/ui/pages/home_page.install_health_connect_dialog.dart +++ b/lib/ui/pages/home_page.install_health_connect_dialog.dart @@ -9,19 +9,14 @@ class InstallHealthConnectDialog extends StatelessWidget { return AlertDialog( titlePadding: const EdgeInsets.symmetric(vertical: 4), insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), - title: const DialogTitle( - title: "pages.about.install_health_connect.title", - ), + title: const DialogTitle(title: "pages.about.install_health_connect.title"), content: Text( locale.translate('pages.about.install_health_connect.description'), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(locale.translate('cancel')), - ), + TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(locale.translate('cancel'))), TextButton( child: Text(locale.translate('install')), onPressed: () async { @@ -35,7 +30,8 @@ class InstallHealthConnectDialog extends StatelessWidget { void _redirectToHealthConnectPlayStore() async { final Uri url = Uri.parse( - 'https://play.google.com/store/apps/details?id=${LocalSettings.healthConnectPackageName}'); + 'https://play.google.com/store/apps/details?id=${LocalSettings.healthConnectPackageName}', + ); var canLaunch = await canLaunchUrl(url); if (canLaunch) { await launchUrl(url); diff --git a/lib/ui/pages/informed_consent_page.dart b/lib/ui/pages/informed_consent_page.dart index ffcd2acf..d9afa95c 100644 --- a/lib/ui/pages/informed_consent_page.dart +++ b/lib/ui/pages/informed_consent_page.dart @@ -1,7 +1,7 @@ part of carp_study_app; class InformedConsentPage extends StatefulWidget { - static const String route = '/consent'; + static const String route = '/study/consent'; final InformedConsentViewModel model; const InformedConsentPage({super.key, required this.model}); @@ -12,11 +12,27 @@ class InformedConsentPage extends StatefulWidget { class InformedConsentState extends State { final GlobalKey _scaffoldKey = GlobalKey(); - void resultCallback(RPTaskResult result) { - widget.model.informedConsentHasBeenAccepted(result); - if (context.mounted) { - context.go(CarpStudyAppState.homeRoute); + // Tracks whether the user actually completed consent. Anything else that + // tears down this page (cancel dialog, system back, programmatic redirect) + // is treated as "user did not consent" and leaveStudy() is called from + // dispose(). Set BEFORE awaiting the upload so a router-driven redirect + // mid-await doesn't get misread as a cancel. + bool _submitted = false; + + Future resultCallback(RPTaskResult result) async { + _submitted = true; + await widget.model.informedConsentHasBeenAccepted(result); + if (!mounted) return; + context.go(CarpAppState.homeRoute); + } + + @override + void dispose() { + // Bypassing onCancel entirely is intentional — see issue carp-dk/research.package#168. + if (!_submitted) { + widget.model.abandonConsent(); } + super.dispose(); } @override @@ -26,22 +42,21 @@ class InformedConsentState extends State { return Scaffold( key: _scaffoldKey, body: FutureBuilder( - future: widget.model.getInformedConsent(localization.locale).then( - (document) { - if (document == null) { - bloc.hasInformedConsentBeenAccepted = true; - context.go(CarpStudyAppState.homeRoute); - } - return document; - }, - ), + future: widget.model.getInformedConsent(localization.locale).then((document) async { + // No consent document configured for this study → mark accepted + // and navigate to /study. Set _submitted so dispose() doesn't tear + // the study back down. + if (document == null && !_submitted) { + _submitted = true; + await widget.model.acceptWithoutDocument(); + if (mounted) context.go(CarpAppState.homeRoute); + } + return document; + }), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.hasData) { - return RPUITask( - task: snapshot.data!, - onSubmit: resultCallback, - ); + return RPUITask(task: snapshot.data!, onSubmit: resultCallback); } } diff --git a/lib/ui/pages/invitation_page.dart b/lib/ui/pages/invitation_details_page.dart similarity index 69% rename from lib/ui/pages/invitation_page.dart rename to lib/ui/pages/invitation_details_page.dart index 76199656..fc709221 100644 --- a/lib/ui/pages/invitation_page.dart +++ b/lib/ui/pages/invitation_details_page.dart @@ -5,11 +5,7 @@ class InvitationDetailsPage extends StatelessWidget { final String invitationId; final InvitationsViewModel model; - const InvitationDetailsPage({ - super.key, - required this.invitationId, - required this.model, - }); + const InvitationDetailsPage({super.key, required this.invitationId, required this.model}); @override Widget build(BuildContext context) { @@ -17,7 +13,7 @@ class InvitationDetailsPage extends StatelessWidget { var invitation = model.getInvitation(invitationId); return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: SafeArea( @@ -46,10 +42,7 @@ class InvitationDetailsPage extends StatelessWidget { child: Text( locale.translate('invitation.invited_to_study'), textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22.0, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 22.0), ), ), ], @@ -58,23 +51,16 @@ class InvitationDetailsPage extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 16.0), child: StudiesMaterial( - backgroundColor: - Theme.of(context).extension()!.white!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), + backgroundColor: Theme.of(context).extension()!.white!, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - locale - .translate('invitation.roles_in_the_study.title'), - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 20.0, - ), + locale.translate('invitation.roles_in_the_study.title'), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20.0), ), Padding( padding: const EdgeInsets.only(top: 8), @@ -92,18 +78,10 @@ class InvitationDetailsPage extends StatelessWidget { child: Padding( padding: const EdgeInsets.only(top: 16.0), child: StudiesMaterial( - backgroundColor: - Theme.of(context).extension()!.white!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), + backgroundColor: Theme.of(context).extension()!.white!, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), child: Padding( - padding: const EdgeInsets.only( - right: 24.0, - left: 24.0, - top: 16.0, - bottom: 16.0, - ), + padding: const EdgeInsets.only(right: 24.0, left: 24.0, top: 16.0, bottom: 16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -117,14 +95,11 @@ class InvitationDetailsPage extends StatelessWidget { style: TextStyle( fontWeight: FontWeight.bold, fontSize: 22.0, - color: Theme.of(context) - .extension()! - .primary, + color: Theme.of(context).extension()!.primary, ), ), Padding( - padding: const EdgeInsets.only( - top: 8, bottom: 24), + padding: const EdgeInsets.only(top: 8, bottom: 24), child: FittedBox( fit: BoxFit.scaleDown, child: Text( @@ -132,9 +107,7 @@ class InvitationDetailsPage extends StatelessWidget { style: TextStyle( fontWeight: FontWeight.bold, fontSize: 14, - color: Theme.of(context) - .extension()! - .grey600, + color: Theme.of(context).extension()!.grey600, ), maxLines: 1, textScaler: TextScaler.linear(0.9), @@ -143,9 +116,7 @@ class InvitationDetailsPage extends StatelessWidget { ), Text( invitation.invitation.description ?? '', - style: const TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold), + style: const TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold), ), ], ), @@ -161,19 +132,15 @@ class InvitationDetailsPage extends StatelessWidget { Container( margin: const EdgeInsets.only(left: 16, right: 16, bottom: 16), height: 56, - decoration: BoxDecoration( - color: const Color(0xff006398), - borderRadius: BorderRadius.circular(100), - ), + decoration: BoxDecoration(color: const Color(0xff006398), borderRadius: BorderRadius.circular(100)), child: TextButton( onPressed: () { - bloc.setStudyInvitation(invitation, context); - context.push(InformedConsentPage.route); + model.accept(invitation); + context.go(StudyPage.route); }, child: Text( locale.translate("invitation.accept_invite"), - style: - const TextStyle(color: Color(0xffffffff), fontSize: 22), + style: const TextStyle(color: Color(0xffffffff), fontSize: 22), textAlign: TextAlign.center, ), ), diff --git a/lib/ui/pages/invitation_list_page.dart b/lib/ui/pages/invitation_list_page.dart index 0180d634..f21bda27 100644 --- a/lib/ui/pages/invitation_list_page.dart +++ b/lib/ui/pages/invitation_list_page.dart @@ -1,109 +1,101 @@ part of carp_study_app; -class InvitationListPage extends StatelessWidget { +class InvitationListPage extends StatefulWidget { static const String route = '/invitations'; final InvitationsViewModel model; const InvitationListPage({super.key, required this.model}); + @override + State createState() => _InvitationListPageState(); +} + +class _InvitationListPageState extends State { + @override + void initState() { + super.initState(); + widget.model.ensureInvitationsLoaded(); + } + @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, - body: FutureBuilder>( - future: bloc.backend.getInvitations(), - builder: (context, snapshot) { - Widget child; - if (snapshot.hasData) { - child = SliverFixedExtentList( - itemExtent: 150, - delegate: SliverChildBuilderDelegate( - (context, index) { - return Container( - child: InvitationMaterial( - invitation: snapshot.data![index], - ), - ); - }, - childCount: snapshot.data!.length, - ), - ); - } else { - child = const SliverToBoxAdapter( - child: Center(child: CircularProgressIndicator()), - ); - } + backgroundColor: Theme.of(context).extension()!.backgroundGray, + body: RefreshIndicator( + onRefresh: widget.model.loadInvitations, + child: ListenableBuilder( + listenable: widget.model, + builder: (context, _) { + Widget child; + if (widget.model.isLoading) { + child = const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator())); + } else { + final invitations = widget.model.invitations; + child = SliverFixedExtentList( + itemExtent: 150, + delegate: SliverChildBuilderDelegate((context, index) { + return Container(child: InvitationMaterial(invitation: invitations[index])); + }, childCount: invitations.length), + ); + } - return CustomScrollView( - slivers: [ - SliverAppBar( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, - title: const CarpAppBar(), - centerTitle: true, - pinned: true, - stretch: true, - stretchTriggerOffset: 20, - scrolledUnderElevation: 0, - onStretchTrigger: () async => bloc.backend.invitations, - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: IntrinsicHeight( - child: Stack( - children: [ - Positioned( - left: 8, - top: 0, - bottom: 0, - child: IconButton( - icon: const Icon(Icons.arrow_back_ios), - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - context.go(LoginPage.route); - } - }, + return CustomScrollView( + // Always scrollable so pull-to-refresh still works when there's + // zero or one invitation and the content doesn't fill the screen. + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverAppBar( + backgroundColor: Theme.of(context).extension()!.backgroundGray, + title: const CarpAppBar(), + centerTitle: true, + pinned: true, + scrolledUnderElevation: 0, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: IntrinsicHeight( + child: Stack( + children: [ + Positioned( + left: 8, + top: 0, + bottom: 0, + child: IconButton( + icon: const Icon(Icons.arrow_back_ios), + onPressed: () => widget.model.signOut(), + ), ), - ), - Center( - child: Text( - locale.translate('invitation.invitations'), - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22.0, + Center( + child: Text( + locale.translate('invitation.invitations'), + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 22.0), ), ), - ), - ], + ], + ), ), ), ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - bottom: 8.0, left: 16.0, right: 16.0), - child: Container( - padding: EdgeInsets.all(10.0), - child: Text( - locale.translate('invitation.subtitle'), - textAlign: TextAlign.left, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14.0, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 8.0, left: 16.0, right: 16.0), + child: Container( + padding: EdgeInsets.all(10.0), + child: Text( + locale.translate('invitation.subtitle'), + textAlign: TextAlign.left, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14.0), ), ), ), ), - ), - child, - ], - ); - }, + child, + ], + ); + }, + ), ), ); } @@ -112,23 +104,17 @@ class InvitationListPage extends StatelessWidget { class InvitationMaterial extends StatelessWidget { final ActiveParticipationInvitation invitation; - const InvitationMaterial({ - super.key, - required this.invitation, - }); + const InvitationMaterial({super.key, required this.invitation}); @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), + backgroundColor: Theme.of(context).extension()!.white!, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), child: InkWell( onTap: () { - context.push( - '${InvitationDetailsPage.route}/${invitation.participation.participantId}'); + context.push('${InvitationDetailsPage.route}/${invitation.participation.participantId}'); }, child: Padding( padding: const EdgeInsets.all(16.0), @@ -138,25 +124,22 @@ class InvitationMaterial extends StatelessWidget { Text( invitation.invitation.name, maxLines: 1, - style: studyTitleStyle.copyWith( - color: CACHET.TASK_COMPLETED_BLUE, - overflow: TextOverflow.ellipsis), + style: fs24fw600.copyWith(color: CACHET.TASK_COMPLETED_BLUE, overflow: TextOverflow.ellipsis), ), Text.rich( TextSpan( children: [ TextSpan( - text: locale.translate( - 'invitation_list.roles_in_the_study.description'), - style: studyDetailsInfoTitle.copyWith( - color: Theme.of(context).extension()!.grey600, + text: locale.translate('invitation_list.roles_in_the_study.description'), + style: fs16fw700.copyWith( + color: Theme.of(context).extension()!.grey600, fontSize: 12, ), ), TextSpan( text: invitation.participantRoleName, - style: studyDetailsInfoTitle.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs16fw700.copyWith( + color: Theme.of(context).extension()!.grey600, fontSize: 12, ), ), @@ -166,8 +149,8 @@ class InvitationMaterial extends StatelessWidget { Text( invitation.invitation.description ?? '', maxLines: 2, - style: studyDetailsInfoTitle.copyWith( - color: Theme.of(context).extension()!.grey900, + style: fs16fw700.copyWith( + color: Theme.of(context).extension()!.grey900, overflow: TextOverflow.ellipsis, ), ), diff --git a/lib/ui/pages/login_page.dart b/lib/ui/pages/login_page.dart index fcc72f02..2e1d9bb9 100644 --- a/lib/ui/pages/login_page.dart +++ b/lib/ui/pages/login_page.dart @@ -2,114 +2,106 @@ part of carp_study_app; class LoginPage extends StatefulWidget { static const String route = '/login'; - const LoginPage({super.key}); + final LoginViewModel model; + const LoginPage({required this.model, super.key}); @override State createState() => _LoginPageState(); } class _LoginPageState extends State { - final GlobalKey webViewKey = GlobalKey(); - - @override - void initState() { - super.initState(); - } - @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - body: SafeArea( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Container( - margin: const EdgeInsets.symmetric(vertical: 32, horizontal: 56), - child: Image.asset( - 'assets/carp_logo.png', - fit: BoxFit.contain, + body: SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 32, horizontal: 56), + child: Image.asset('assets/carp_logo.png', fit: BoxFit.contain), + ), ), - ), - ), - Container( - margin: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), - width: MediaQuery.of(context).size.width, - height: 56, - decoration: BoxDecoration( - color: const Color(0xff006398), - borderRadius: BorderRadius.circular(100), - ), - child: TextButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => QRViewExample(), - ); - }, - child: Text( - locale.translate("scan"), - style: const TextStyle(color: Color(0xffffffff), fontSize: 22), - textAlign: TextAlign.center, + Container( + margin: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), + width: MediaQuery.of(context).size.width, + height: 56, + decoration: BoxDecoration(color: const Color(0xff006398), borderRadius: BorderRadius.circular(100)), + child: TextButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => QRViewExample(model: widget.model), + ); + }, + child: Text( + locale.translate("scan"), + style: const TextStyle(color: Color(0xffffffff), fontSize: 22), + textAlign: TextAlign.center, + ), + ), ), - ), - ), - Container( - margin: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), - width: MediaQuery.of(context).size.width, - height: 56, - decoration: BoxDecoration( - color: const Color(0xff006398), - borderRadius: BorderRadius.circular(100), - ), - child: TextButton( - onPressed: () async { - bool isConnected = await bloc.checkConnectivity(); - if (isConnected) { - await bloc.backend.initialize(); - await bloc.backend.authenticate(); - if (context.mounted) context.go(CarpStudyAppState.homeRoute); - } else { - showDialog( - context: context, - builder: (context) => PopScope( - onPopInvokedWithResult: (didPop, result) async { - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (didPop && result == true) { - Navigator.of(context).pop(); - } - }); - }, - child: EnableInternetConnectionDialog(), - ), - ); - } - }, - child: Text( - locale.translate("pages.login.login"), - style: const TextStyle(color: Color(0xffffffff), fontSize: 22), - textAlign: TextAlign.center, + Container( + margin: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), + width: MediaQuery.of(context).size.width, + height: 56, + decoration: BoxDecoration(color: const Color(0xff006398), borderRadius: BorderRadius.circular(100)), + child: TextButton( + onPressed: () async { + final result = await widget.model.signIn(); + if (!context.mounted) return; + if (result == SignInResult.success) { + final invitations = bloc.appViewModel.invitationsListViewModel; + await invitations.loadInvitations(); + if (!context.mounted) return; + logApp( + 'LoginPage - sign-in success, loaded ${invitations.invitations.length} invitation(s), ' + 'navigating to landingRoute=${invitations.landingRoute}', + ); + context.go(invitations.landingRoute); + } else if (result == SignInResult.offline) { + showDialog( + context: context, + builder: (context) => PopScope( + onPopInvokedWithResult: (didPop, result) async { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (didPop && result == true) { + Navigator.of(context).pop(); + } + }); + }, + child: EnableInternetConnectionDialog(), + ), + ); + } + }, + child: Text( + locale.translate("pages.login.login"), + style: const TextStyle(color: Color(0xffffffff), fontSize: 22), + textAlign: TextAlign.center, + ), + ), ), - ), + if (widget.model.isAuthenticated) + TextButton( + onPressed: () { + showDialog(context: context, builder: (context) => const LogoutMessage()).then((value) async { + if (value == true) { + await widget.model.signOut(); + if (mounted) setState(() {}); + } + }); + }, + child: Text(locale.translate('pages.login.logout')), + ), + ], ), - if (bloc.backend.isAuthenticated) - TextButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => const LogoutMessage(), - ).then((value) async { - if (value == true) { - await bloc.backend.signOut(); - setState(() {}); - } - }); - }, - child: Text(locale.translate('pages.login.logout')), - ) - ])))); + ), + ), + ); } } diff --git a/lib/ui/pages/message_details_page.dart b/lib/ui/pages/message_details_page.dart index 93817864..20893829 100644 --- a/lib/ui/pages/message_details_page.dart +++ b/lib/ui/pages/message_details_page.dart @@ -4,25 +4,13 @@ class MessageDetailsPage extends StatelessWidget { static const String route = '/message'; final String messageId; - const MessageDetailsPage({ - super.key, - required this.messageId, - }); + const MessageDetailsPage({super.key, required this.messageId}); @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; - Message message = bloc.messages - .firstWhere((element) => element.id == messageId, orElse: () { - return Message( - id: '0', - title: 'Unknown message', - subTitle: 'Unknown message', - type: MessageType.announcement, - timestamp: DateTime.now(), - image: './assets/images/kids.png'); - }); + Message message = bloc.appViewModel.studyPageViewModel.messageById(messageId); return Scaffold( body: SafeArea( @@ -31,99 +19,85 @@ class MessageDetailsPage extends StatelessWidget { child: Column( children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), child: const CarpAppBar(hasProfileIcon: true), ), Row( children: [ IconButton( - padding: const EdgeInsets.only( - left: 26, right: 10, top: 16, bottom: 16), - icon: Icon( - Icons.arrow_back_ios, - color: Theme.of(context).extension()!.grey600, - ), + padding: const EdgeInsets.only(left: 26, right: 10, top: 16, bottom: 16), + icon: Icon(Icons.arrow_back_ios, color: Theme.of(context).extension()!.grey600), onPressed: () { if (context.canPop()) { context.pop(); } else { - context.go(CarpStudyAppState.homeRoute); + context.go(CarpAppState.homeRoute); } }, ), - Material( - color: CACHET.DEPLOYMENT_DEPLOYING, - borderRadius: BorderRadius.circular(100.0), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - locale.translate(message.type - .toString() - .split('.') - .last - .toLowerCase()), - style: aboutCardSubtitleStyle.copyWith( - color: Colors.white)), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Text( + locale.translate(message.title!), + style: fs20fw700.copyWith(color: Theme.of(context).extension()!.grey900), + ), + ), + Spacer(), + Padding( + padding: const EdgeInsets.only(right: 24), + child: Material( + color: Theme.of(context).extension()!.primary, + borderRadius: BorderRadius.circular(100.0), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + child: Text( + locale.translate(message.type.toString().split('.').last.toLowerCase()), + style: fs16fw600.copyWith(color: Colors.white), + ), + ), ), ), ], ), Flexible( child: ListView( - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: Text(locale.translate(message.title!), - style: aboutCardTitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900)), - ), message.subTitle != null ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, vertical: 6.0), - child: Text(locale.translate(message.subTitle!), - style: aboutCardContentStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey700)), + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0), + child: Text( + locale.translate(message.subTitle!), + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey700), + ), ) : const SizedBox.shrink(), if (message.image != null && message.image!.isNotEmpty) - LayoutBuilder(builder: (context, constraints) { - final screenHeight = MediaQuery.of(context).size.height; - final screenWidth = MediaQuery.of(context).size.height; - return ConstrainedBox( - constraints: BoxConstraints( - maxWidth: screenHeight, - maxHeight: screenWidth, - ), - child: FittedBox( + LayoutBuilder( + builder: (context, constraints) { + final screenHeight = MediaQuery.of(context).size.height; + final screenWidth = MediaQuery.of(context).size.height; + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: screenHeight, maxHeight: screenWidth), + child: FittedBox( fit: BoxFit.contain, - child: bloc.appViewModel.studyPageViewModel - .getMessageImage(message.image)), - ); - }), + child: bloc.appViewModel.studyPageViewModel.getMessageImage(message.image), + ), + ); + }, + ), // DetailsBanner(message.title ?? '', message.image), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (message.message != null) Text( locale.translate(message.message!), - style: aboutCardContentStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900), + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey900), textAlign: TextAlign.justify, - ) + ), ], ), ), diff --git a/lib/ui/pages/process_message_page.dart b/lib/ui/pages/process_message_page.dart index df00b325..4f523923 100644 --- a/lib/ui/pages/process_message_page.dart +++ b/lib/ui/pages/process_message_page.dart @@ -1,10 +1,6 @@ part of carp_study_app; -enum ProcessStatus { - done, - error, - other, -} +enum ProcessStatus { done, error, other } class ProcessMessagePage extends StatelessWidget { // Type of message to display (e.g Error, Success, Informative) @@ -25,14 +21,15 @@ class ProcessMessagePage extends StatelessWidget { // Display cancel button. If true the button is displayed. final bool canCancel; - const ProcessMessagePage( - {super.key, - required this.statusType, - required this.title, - required this.description, - required this.actionFunction, - required this.actionText, - this.canCancel = true}); + const ProcessMessagePage({ + super.key, + required this.statusType, + required this.title, + required this.description, + required this.actionFunction, + required this.actionText, + this.canCancel = true, + }); @override Widget build(BuildContext context) { @@ -41,18 +38,21 @@ class ProcessMessagePage extends StatelessWidget { switch (statusType) { case ProcessStatus.done: image = Image( - image: const AssetImage('assets/icons/done.png'), - height: MediaQuery.of(context).size.height * 0.35); + image: const AssetImage('assets/icons/done.png'), + height: MediaQuery.of(context).size.height * 0.35, + ); break; case ProcessStatus.error: image = Image( - image: const AssetImage('assets/icons/error.png'), - height: MediaQuery.of(context).size.height * 0.35); + image: const AssetImage('assets/icons/error.png'), + height: MediaQuery.of(context).size.height * 0.35, + ); break; case ProcessStatus.other: image = Image( - image: const AssetImage('assets/icons/info.png'), - height: MediaQuery.of(context).size.height * 0.35); + image: const AssetImage('assets/icons/info.png'), + height: MediaQuery.of(context).size.height * 0.35, + ); break; } @@ -61,56 +61,57 @@ class ProcessMessagePage extends StatelessWidget { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - body: Padding( - padding: const EdgeInsets.all(15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const SizedBox(height: 40), - messageImage(), - const SizedBox(height: 20), - Center( - child: - Text(locale.translate(title), style: audioTitleStyle)), - const SizedBox(height: 10), - Text(locale.translate(description), style: audioContentStyle), - ]), - ), - bottomSheet: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + body: Padding( + padding: const EdgeInsets.all(15.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, children: [ - canCancel == true - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(width: 15), - OutlinedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(locale.translate('cancel').toUpperCase(), - style: TextStyle( - color: Theme.of(context).primaryColor))), - const SizedBox(width: 10), - ], - ) - : const SizedBox.shrink(), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).primaryColor), - onPressed: () { - actionFunction(); - }, - child: Text(locale.translate(actionText).toUpperCase()), - ), - const SizedBox(width: 15), - ], - ), + const SizedBox(height: 40), + messageImage(), + const SizedBox(height: 20), + Center(child: Text(locale.translate(title), style: fs22fw700)), + const SizedBox(height: 10), + Text(locale.translate(description), style: fs16fw600), ], - )); + ), + ), + bottomSheet: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + canCancel == true + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(width: 15), + OutlinedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + locale.translate('cancel').toUpperCase(), + style: TextStyle(color: Theme.of(context).primaryColor), + ), + ), + const SizedBox(width: 10), + ], + ) + : const SizedBox.shrink(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor), + onPressed: () { + actionFunction(); + }, + child: Text(locale.translate(actionText).toUpperCase()), + ), + const SizedBox(width: 15), + ], + ), + ], + ), + ); } } diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index 14e53592..67cdb267 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -26,7 +26,7 @@ class ProfilePageState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.grey100, + backgroundColor: Theme.of(context).extension()!.grey100, body: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, @@ -40,15 +40,14 @@ class ProfilePageState extends State { children: [ TextButton.icon( onPressed: () {}, - icon: Icon(Icons.account_circle, - color: Theme.of(context).primaryColor, size: 30), - label: Text(locale.translate("pages.profile.title"), - style: aboutCardTitleStyle.copyWith( - color: Theme.of(context).primaryColor)), + icon: Icon(Icons.account_circle, color: Theme.of(context).primaryColor, size: 30), + label: Text( + locale.translate("pages.profile.title"), + style: fs20fw700.copyWith(color: Theme.of(context).primaryColor), + ), ), IconButton( - icon: Icon(Icons.close, - color: Theme.of(context).primaryColor, size: 30), + icon: Icon(Icons.close, color: Theme.of(context).primaryColor, size: 30), tooltip: locale.translate('Back'), onPressed: () { Navigator.of(context).pop(); @@ -57,129 +56,82 @@ class ProfilePageState extends State { ], ), ), - LocalSettings().isAnonymous ? AnonymousCard() : SizedBox.shrink(), + widget.model.isAnonymous ? AnonymousCard() : SizedBox.shrink(), Flexible( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 15), child: ListView( padding: EdgeInsets.zero, children: [ - _buildSectionCard( - context, - [ - _buildListTile( - locale.translate('pages.profile.username'), - widget.model.username, - ), - _buildListTile( - locale.translate('pages.profile.account_id'), - widget.model.userId, - ), - _buildListTile( - locale.translate('pages.profile.full_name'), - LocalSettings().isAnonymous - ? locale - .translate('pages.about.anonymous.anonymous') - : widget.model.fullName, - ), - _buildListTile( - locale.translate('pages.profile.email'), - LocalSettings().isAnonymous - ? locale - .translate('pages.about.anonymous.anonymous') - : widget.model.email, - ), - ], - ), - _buildSectionCard( - context, - [ - _buildListTile( - locale.translate('pages.profile.study_id'), - widget.model.studyId, - ), - _buildListTile( - locale.translate('pages.profile.study_deployment_id'), - widget.model.studyDeploymentId, - ), - _buildListTile( - locale.translate('pages.profile.study_name'), - locale.translate(widget.model.studyDeploymentTitle), - ), - _buildListTile( - locale.translate('pages.profile.participant_id'), - widget.model.participantId, - ), - _buildListTile( - locale.translate('pages.profile.participant_role'), - widget.model.participantRole, - ), - _buildListTile( - locale.translate('pages.profile.device_role'), - widget.model.deviceRole, - ), - ], - ), _buildSectionCard(context, [ + _buildListTile(locale.translate('pages.profile.username'), widget.model.username), + _buildListTile(locale.translate('pages.profile.account_id'), widget.model.userId), _buildListTile( - locale.translate('pages.profile.app_version'), - appVersion, + locale.translate('pages.profile.full_name'), + widget.model.isAnonymous + ? locale.translate('pages.about.anonymous.anonymous') + : widget.model.fullName, ), _buildListTile( - locale.translate('pages.profile.app_version_code'), - buildNumber, + locale.translate('pages.profile.email'), + widget.model.isAnonymous + ? locale.translate('pages.about.anonymous.anonymous') + : widget.model.email, ), + ]), + _buildSectionCard(context, [ + _buildListTile(locale.translate('pages.profile.study_id'), widget.model.studyId), _buildListTile( - locale.translate('pages.profile.server_name'), - widget.model.currentServer, + locale.translate('pages.profile.study_deployment_id'), + widget.model.studyDeploymentId, ), _buildListTile( - locale.translate('pages.profile.device_id'), - widget.model.deviceID, + locale.translate('pages.profile.study_name'), + locale.translate(widget.model.studyDeploymentTitle), + ), + _buildListTile(locale.translate('pages.profile.participant_id'), widget.model.participantId), + _buildListTile(locale.translate('pages.profile.participant_role'), widget.model.participantRole), + _buildListTile(locale.translate('pages.profile.device_role'), widget.model.deviceRole), + ]), + _buildSectionCard(context, [ + _buildListTile(locale.translate('pages.profile.app_version'), appVersion), + _buildListTile(locale.translate('pages.profile.app_version_code'), buildNumber), + _buildListTile(locale.translate('pages.profile.server_name'), widget.model.currentServer), + _buildListTile(locale.translate('pages.profile.device_id'), widget.model.deviceID), + ]), + _buildSectionCard(context, [ + _buildActionListTile( + leading: Icon(Icons.mail, color: Theme.of(context).primaryColor), + trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), + title: locale.translate('pages.profile.contact'), + onTap: () async { + _sendEmailToContactResearcher( + locale.translate(widget.model.responsibleEmail), + 'Support for study: ${locale.translate(widget.model.studyDeploymentTitle)} - User: ${widget.model.username}', + ); + }, + ), + _buildActionListTile( + leading: Icon(Icons.policy, color: Theme.of(context).primaryColor), + trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), + title: locale.translate('pages.profile.privacy'), + onTap: () async { + try { + launchUrl(Uri.parse(CarpBackend.carpPrivacyUrl)); + } finally {} + }, + ), + _buildActionListTile( + leading: Icon(Icons.public, color: Theme.of(context).primaryColor), + trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), + title: locale.translate('pages.profile.study_website'), + onTap: () async { + try { + launchUrl(Uri.parse(CarpBackend.carpWebsiteUrl)); + } finally {} + }, ), ]), - _buildSectionCard( - context, - [ - _buildActionListTile( - leading: Icon(Icons.mail, - color: Theme.of(context).primaryColor), - trailing: const Icon(Icons.arrow_forward_ios, - color: CACHET.GREY_6), - title: locale.translate('pages.profile.contact'), - onTap: () async { - _sendEmailToContactResearcher( - locale.translate(widget.model.responsibleEmail), - 'Support for study: ${locale.translate(widget.model.studyDeploymentTitle)} - User: ${widget.model.username}', - ); - }, - ), - _buildActionListTile( - leading: Icon(Icons.policy, - color: Theme.of(context).primaryColor), - trailing: const Icon(Icons.arrow_forward_ios, - color: CACHET.GREY_6), - title: locale.translate('pages.profile.privacy'), - onTap: () async { - try { - launchUrl(Uri.parse(CarpBackend.carpPrivacyUrl)); - } finally {} - }, - ), - _buildActionListTile( - leading: Icon(Icons.public, - color: Theme.of(context).primaryColor), - trailing: const Icon(Icons.arrow_forward_ios, - color: CACHET.GREY_6), - title: locale.translate('pages.profile.study_website'), - onTap: () async { - try { - launchUrl(Uri.parse(CarpBackend.carpWebsiteUrl)); - } finally {} - }, - ), - ], - ), _buildSectionCard(context, [ _buildActionListTile( leading: const Icon(Icons.logout, color: CACHET.RED_1), @@ -191,11 +143,10 @@ class ProfilePageState extends State { ]), _buildSectionCard(context, [ _buildActionListTile( - leading: const Icon(Icons.power_settings_new, - color: CACHET.RED_1), + leading: const Icon(Icons.power_settings_new, color: CACHET.RED_1), title: locale.translate('pages.profile.log_out'), onTap: () async { - bool isConnected = await bloc.checkConnectivity(); + bool isConnected = await widget.model.checkConnectivity(); if (isConnected) { _showLogoutConfirmationDialog(); } else { @@ -203,7 +154,7 @@ class ProfilePageState extends State { } }, ), - ]) + ]), ], ), ), @@ -215,17 +166,15 @@ class ProfilePageState extends State { Widget _buildSectionCard(BuildContext context, List children) { return Card( - color: Theme.of(context).extension()!.grey50, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ), + color: Theme.of(context).extension()!.grey50, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), child: Padding( padding: const EdgeInsets.all(8.0), child: Column( children: ListTile.divideTiles( context: context, tiles: children, - color: Theme.of(context).extension()!.grey400, + color: Theme.of(context).extension()!.grey400, ).toList(), ), ), @@ -234,21 +183,16 @@ class ProfilePageState extends State { Widget _buildListTile(String title, String subtitle) { return ListTile( - title: Text(title, - style: profileSectionStyle.copyWith(color: CACHET.GREY_6)), + title: Text(title, style: fs12fw600.copyWith(color: CACHET.GREY_6)), subtitle: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, - child: Text( - subtitle, - style: profileTitleStyle, - maxLines: 1, - ), + child: Text(subtitle, style: fs14fw600, maxLines: 1), ), ); } -// Helper method to build a ListTile for actions with an icon + // Helper method to build a ListTile for actions with an icon Widget _buildActionListTile({ required Icon leading, required String title, @@ -257,9 +201,7 @@ class ProfilePageState extends State { }) { return ListTile( leading: leading, - title: Text(title, - style: profileActionStyle.copyWith( - color: Theme.of(context).extension()!.grey900)), + title: Text(title, style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900)), trailing: trailing, onTap: onTap, contentPadding: EdgeInsets.symmetric(vertical: 4, horizontal: 16), @@ -279,11 +221,10 @@ class ProfilePageState extends State { /// Sends and email to the researcher with the name of the study + user id void _sendEmailToContactResearcher(String email, String subject) async { final url = Uri( - scheme: 'mailto', - path: email, - queryParameters: {'subject': subject}) - .toString() - .replaceAll("+", "%20"); + scheme: 'mailto', + path: email, + queryParameters: {'subject': subject}, + ).toString().replaceAll("+", "%20"); try { await launchUrl(Uri.parse(url)); } finally {} @@ -299,21 +240,23 @@ class ProfilePageState extends State { title: Text(locale.translate("pages.profile.log_out.confirmation")), actions: [ TextButton( - child: Text(locale.translate("NO")), - onPressed: () async { - if (builderContext.mounted) { - Navigator.of(builderContext).pop(); - } - }), + child: Text(locale.translate("NO")), + onPressed: () async { + if (builderContext.mounted) { + Navigator.of(builderContext).pop(); + } + }, + ), TextButton( - child: Text(locale.translate("YES")), - onPressed: () async { - if (builderContext.mounted) { - await bloc.signOutAndLeaveStudy(); - builderContext.pop(); - builderContext.go(CarpStudyAppState.homeRoute); - } - }), + child: Text(locale.translate("YES")), + onPressed: () async { + if (builderContext.mounted) { + await widget.model.signOutAndLeaveStudy(); + builderContext.pop(); + builderContext.go(CarpAppState.homeRoute); + } + }, + ), ], ); }, @@ -327,8 +270,7 @@ class ProfilePageState extends State { context: context, builder: (BuildContext builderContext) { return AlertDialog( - title: - Text(locale.translate("pages.profile.leave_study.confirmation")), + title: Text(locale.translate("pages.profile.leave_study.confirmation")), actions: [ TextButton( child: Text(locale.translate("NO")), @@ -339,14 +281,15 @@ class ProfilePageState extends State { }, ), TextButton( - child: Text(locale.translate("YES")), - onPressed: () async { - if (builderContext.mounted) { - await bloc.leaveStudy(); - builderContext.pop(); - builderContext.go(InvitationListPage.route); - } - }), + child: Text(locale.translate("YES")), + onPressed: () async { + if (builderContext.mounted) { + await widget.model.leaveStudy(); + builderContext.pop(); + builderContext.go(InvitationListPage.route); + } + }, + ), ], ); }, @@ -366,19 +309,15 @@ class ProfilePageState extends State { class SlidePageRoute extends PageRouteBuilder { final Widget page; SlidePageRoute(this.page) - : super( - pageBuilder: (context, animation, secondaryAnimation) => page, - transitionsBuilder: (context, animation, secondaryAnimation, child) { - var begin = Offset(1.0, 0.0); - var end = Offset.zero; - var curve = Curves.easeInOut; - var tween = - Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); - var offsetAnimation = animation.drive(tween); - return SlideTransition( - position: offsetAnimation, - child: child, - ); - }, - ); + : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + var begin = Offset(1.0, 0.0); + var end = Offset.zero; + var curve = Curves.easeInOut; + var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); + var offsetAnimation = animation.drive(tween); + return SlideTransition(position: offsetAnimation, child: child); + }, + ); } diff --git a/lib/ui/pages/qr_scanner.dart b/lib/ui/pages/qr_scanner.dart index b67ec16e..15164792 100644 --- a/lib/ui/pages/qr_scanner.dart +++ b/lib/ui/pages/qr_scanner.dart @@ -1,7 +1,8 @@ part of carp_study_app; class QRViewExample extends StatefulWidget { - const QRViewExample({super.key}); + final LoginViewModel model; + const QRViewExample({required this.model, super.key}); @override State createState() => _QRViewExampleState(); @@ -10,6 +11,7 @@ class QRViewExample extends StatefulWidget { class _QRViewExampleState extends State { qr.Barcode? result; qr.QRViewController? controller; + StreamSubscription? _scanSubscription; final GlobalKey qrKey = GlobalKey(debugLabel: 'QR'); // In order to get hot reload to work we need to pause the camera if the platform @@ -23,6 +25,12 @@ class _QRViewExampleState extends State { controller!.resumeCamera(); } + @override + void dispose() { + _scanSubscription?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -45,20 +53,21 @@ class _QRViewExampleState extends State { margin: const EdgeInsets.all(8), height: 30, child: ElevatedButton( - onPressed: () async { - await controller?.flipCamera(); - setState(() {}); + onPressed: () async { + await controller?.flipCamera(); + setState(() {}); + }, + child: FutureBuilder( + future: controller?.getCameraInfo(), + builder: (context, snapshot) { + if (snapshot.data != null) { + return Icon(Icons.cameraswitch); + } else { + return const Text('loading'); + } }, - child: FutureBuilder( - future: controller?.getCameraInfo(), - builder: (context, snapshot) { - if (snapshot.data != null) { - return Icon(Icons.cameraswitch); - } else { - return const Text('loading'); - } - }, - )), + ), + ), ), Container( margin: const EdgeInsets.all(8), @@ -75,7 +84,7 @@ class _QRViewExampleState extends State { ], ), ), - ) + ), ], ), ), @@ -84,8 +93,7 @@ class _QRViewExampleState extends State { Widget _buildQrView(BuildContext context) { // For this example we check how width or tall the device is and change the scanArea and overlay accordingly. - var scanArea = (MediaQuery.of(context).size.width < 400 || - MediaQuery.of(context).size.height < 400) + var scanArea = (MediaQuery.of(context).size.width < 400 || MediaQuery.of(context).size.height < 400) ? 150.0 : 300.0; // To ensure the Scanner view is properly sizes after rotation @@ -94,11 +102,12 @@ class _QRViewExampleState extends State { key: qrKey, onQRViewCreated: _onQRViewCreated, overlay: qr.QrScannerOverlayShape( - borderColor: Colors.red, - borderRadius: 10, - borderLength: 30, - borderWidth: 10, - cutOutSize: scanArea), + borderColor: Colors.red, + borderRadius: 10, + borderLength: 30, + borderWidth: 10, + cutOutSize: scanArea, + ), onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p), ); } @@ -107,7 +116,7 @@ class _QRViewExampleState extends State { setState(() { this.controller = controller; }); - controller.scannedDataStream.listen((scanData) async { + _scanSubscription = controller.scannedDataStream.listen((scanData) async { await controller.pauseCamera(); if (result != null) return; setState(() { @@ -115,22 +124,23 @@ class _QRViewExampleState extends State { }); final qrcode = scanData.code; + if (qrcode == null) return; - if (qrcode != null && Uri.tryParse(qrcode)?.hasAbsolutePath == true) { - await bloc.backend.authenticateWithMagicLink(qrcode).then((_) { - context.go('/'); - Navigator.of(context).pop(); - }); + final success = await widget.model.signInWithMagicLink(qrcode); + if (!mounted) return; + if (success) { + final invitations = bloc.appViewModel.invitationsListViewModel; + await invitations.loadInvitations(); + if (!mounted) return; + context.go(invitations.landingRoute); } + Navigator.of(context).pop(); }); } - void _onPermissionSet( - BuildContext context, qr.QRViewController ctrl, bool p) { + void _onPermissionSet(BuildContext context, qr.QRViewController ctrl, bool p) { if (!p) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('no Permission')), - ); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('no Permission'))); } } } diff --git a/lib/ui/pages/study_details_page.dart b/lib/ui/pages/study_details_page.dart index b4a6102b..a8c8453c 100644 --- a/lib/ui/pages/study_details_page.dart +++ b/lib/ui/pages/study_details_page.dart @@ -10,124 +10,99 @@ class StudyDetailsPage extends StatelessWidget { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Container( - color: Theme.of(context).extension()!.backgroundGray, + color: Theme.of(context).extension()!.backgroundGray, child: Column( children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), child: const CarpAppBar(hasProfileIcon: true), ), Row( children: [ IconButton( - padding: const EdgeInsets.only( - left: 26, right: 10, top: 16, bottom: 16), - icon: Icon( - Icons.arrow_back_ios, - color: Theme.of(context).extension()!.grey600, - ), + padding: const EdgeInsets.only(left: 26, right: 10, top: 16, bottom: 16), + icon: Icon(Icons.arrow_back_ios, color: Theme.of(context).extension()!.grey600), onPressed: () { if (context.canPop()) { context.pop(); } else { - context.go(CarpStudyAppState.homeRoute); + context.go(CarpAppState.homeRoute); } }, ), Padding( padding: const EdgeInsets.symmetric(vertical: 10.0), - child: Text(locale.translate(model.title), - style: aboutCardTitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .primary)), + child: Text( + locale.translate(model.title), + style: fs20fw700.copyWith(color: Theme.of(context).extension()!.primary), + ), ), ], ), Flexible( child: ListView( - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), - child: LayoutBuilder(builder: (context, constraints) { - final screenHeight = MediaQuery.of(context).size.height; - final screenWidth = MediaQuery.of(context).size.height; - return ConstrainedBox( - constraints: BoxConstraints( - maxWidth: screenHeight, - maxHeight: screenWidth, - ), - child: FittedBox( - fit: BoxFit.contain, - child: model.image, - )); - }), + child: LayoutBuilder( + builder: (context, constraints) { + final screenHeight = MediaQuery.of(context).size.height; + final screenWidth = MediaQuery.of(context).size.height; + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: screenHeight, maxHeight: screenWidth), + child: FittedBox(fit: BoxFit.contain, child: model.image), + ); + }, + ), ), - _buildSectionCard( - context, - [ - _buildActionListTile( - context: context, - leading: Icon(Icons.mail, - color: Theme.of(context) - .extension()! - .primary), - trailing: const Icon(Icons.arrow_forward_ios, - color: CACHET.GREY_6), - title: locale.translate('pages.profile.contact'), - onTap: () async { - _sendEmailToContactResearcher( - locale.translate(model.responsibleEmail), - 'Support for study: ${locale.translate(model.title)} - User: ${model.responsibleName}', + _buildSectionCard(context, [ + _buildActionListTile( + context: context, + leading: Icon(Icons.mail, color: Theme.of(context).extension()!.primary), + trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), + title: locale.translate('pages.profile.contact'), + onTap: () async { + _sendEmailToContactResearcher( + locale.translate(model.responsibleEmail), + 'Support for study: ${locale.translate(model.title)} - User: ${model.responsibleName}', + ); + }, + ), + _buildActionListTile( + context: context, + leading: Icon(Icons.policy, color: Theme.of(context).extension()!.primary), + trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), + title: locale.translate('pages.about.study.privacy'), + onTap: () async { + try { + await launchUrl(Uri.parse(locale.translate(model.privacyPolicyUrl))); + } catch (error) { + warning( + "Could not launch study description URL - ${locale.translate(model.privacyPolicyUrl)}", ); - }, - ), - _buildActionListTile( - context: context, - leading: Icon(Icons.policy, - color: Theme.of(context) - .extension()! - .primary), - trailing: const Icon(Icons.arrow_forward_ios, - color: CACHET.GREY_6), - title: - locale.translate('pages.about.study.privacy'), - onTap: () async { - try { - await launchUrl(Uri.parse( - locale.translate(model.privacyPolicyUrl))); - } catch (error) { - warning( - "Could not launch study description URL - ${locale.translate(model.privacyPolicyUrl)}"); - } - }), - _buildActionListTile( - context: context, - leading: Icon(Icons.public, - color: Theme.of(context) - .extension()! - .primary), - trailing: const Icon(Icons.arrow_forward_ios, - color: CACHET.GREY_6), - title: locale.translate('pages.about.study.website'), - onTap: () async { - try { - await launchUrl(Uri.parse( - locale.translate(model.studyDescriptionUrl))); - } catch (error) { - warning( - "Could not launch study description URL - ${locale.translate(model.studyDescriptionUrl)}"); - } - }, - ), - ], - ), + } + }, + ), + _buildActionListTile( + context: context, + leading: Icon(Icons.public, color: Theme.of(context).extension()!.primary), + trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), + title: locale.translate('pages.about.study.website'), + onTap: () async { + try { + await launchUrl(Uri.parse(locale.translate(model.studyDescriptionUrl))); + } catch (error) { + warning( + "Could not launch study description URL - ${locale.translate(model.studyDescriptionUrl)}", + ); + } + }, + ), + ]), Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( @@ -136,62 +111,41 @@ class StudyDetailsPage extends StatelessWidget { children: [ Text( locale.translate('widgets.study_card.responsible'), - style: studyDetailsInfoTitle.copyWith( - color: Theme.of(context) - .extension()! - .grey900), + style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), ), Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8), child: Text( locale.translate(model.responsibleName), - style: studyDetailsInfoMessage.copyWith( - color: Theme.of(context) - .extension()! - .grey700), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), ), ), Text( - locale.translate( - 'widgets.study_card.participant_role'), - style: studyDetailsInfoTitle.copyWith( - color: Theme.of(context) - .extension()! - .grey900), + locale.translate('widgets.study_card.participant_role'), + style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), ), Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8), child: Text( locale.translate(model.participantRole), - style: studyDetailsInfoMessage.copyWith( - color: Theme.of(context) - .extension()! - .grey700), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), ), ), Text( locale.translate('widgets.study_card.device_role'), - style: studyDetailsInfoTitle.copyWith( - color: Theme.of(context) - .extension()! - .grey900), + style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), ), Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8), child: Text( locale.translate(model.deviceRole), - style: studyDetailsInfoMessage.copyWith( - color: Theme.of(context) - .extension()! - .grey700), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), ), ), ], ), ), - Divider( - color: Theme.of(context).extension()!.grey300, - ), + Divider(color: Theme.of(context).extension()!.grey300), Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( @@ -199,39 +153,25 @@ class StudyDetailsPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - locale.translate( - 'widgets.study_card.study_description'), - style: studyDetailsInfoTitle.copyWith( - color: Theme.of(context) - .extension()! - .grey900), + locale.translate('widgets.study_card.study_description'), + style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), ), Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8), child: Text( locale.translate(model.description), - style: studyDetailsInfoMessage.copyWith( - color: Theme.of(context) - .extension()! - .grey700), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), ), ), Text( - locale - .translate('widgets.study_card.study_purpose'), - style: studyDetailsInfoTitle.copyWith( - color: Theme.of(context) - .extension()! - .grey900), + locale.translate('widgets.study_card.study_purpose'), + style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), ), Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8), child: Text( locale.translate(model.purpose), - style: studyDetailsInfoMessage.copyWith( - color: Theme.of(context) - .extension()! - .grey700), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), ), ), ], @@ -249,24 +189,23 @@ class StudyDetailsPage extends StatelessWidget { Widget _buildSectionCard(BuildContext context, List children) { return Card( - color: Theme.of(context).extension()!.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ), + margin: EdgeInsets.zero, + color: Theme.of(context).extension()!.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), child: Padding( padding: const EdgeInsets.all(8.0), child: Column( children: ListTile.divideTiles( context: context, tiles: children, - color: Theme.of(context).extension()!.grey300, + color: Theme.of(context).extension()!.grey300, ).toList(), ), ), ); } -// Helper method to build a ListTile for actions with an icon + // Helper method to build a ListTile for actions with an icon Widget _buildActionListTile({ required BuildContext context, required Icon leading, @@ -276,9 +215,7 @@ class StudyDetailsPage extends StatelessWidget { }) { return ListTile( leading: leading, - title: Text(title, - style: profileActionStyle.copyWith( - color: Theme.of(context).extension()!.grey900)), + title: Text(title, style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900)), trailing: trailing, onTap: onTap, contentPadding: EdgeInsets.symmetric(vertical: 4, horizontal: 16), @@ -288,11 +225,10 @@ class StudyDetailsPage extends StatelessWidget { // Sends and email to the researcher with the name of the study + user id void _sendEmailToContactResearcher(String email, String subject) async { final url = Uri( - scheme: 'mailto', - path: email, - queryParameters: {'subject': subject}) - .toString() - .replaceAll("+", "%20"); + scheme: 'mailto', + path: email, + queryParameters: {'subject': subject}, + ).toString().replaceAll("+", "%20"); try { await launchUrl(Uri.parse(url)); } finally {} diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index f96e3a42..55bf68aa 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -13,36 +13,40 @@ class StudyPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar(hasProfileIcon: true), ), Flexible( - child: StreamBuilder( - stream: widget.model.messageStream, - builder: (context, AsyncSnapshot snapshot) { - final cards = _buildCards(context); - return RefreshIndicator( - onRefresh: () async { - await bloc.refreshMessages(); - final status = await Sensing().tryDeployment(); - if (status == StudyStatus.Deployed) { - bloc.start(); - } - bloc.deploymentService.getStudyDeploymentStatus( - widget.model.studyDeploymentId); + // Re-render when configureStudy completes; until then show a + // loader, with pull-to-refresh as the retry affordance. + child: ListenableBuilder( + listenable: widget.model, + builder: (context, _) { + if (!widget.model.isConfigured) { + return RefreshIndicator( + onRefresh: widget.model.retryConfiguration, + child: const CustomScrollView( + physics: AlwaysScrollableScrollPhysics(), + slivers: [SliverFillRemaining(hasScrollBody: false, child: _ConfiguringStudyLoader())], + ), + ); + } + return StreamBuilder( + stream: widget.model.messageStream, + builder: (context, AsyncSnapshot snapshot) { + final cards = _buildCards(context); + return RefreshIndicator( + onRefresh: widget.model.refresh, + child: ListView.builder(itemCount: cards.length, itemBuilder: (context, index) => cards[index]), + ); }, - child: ListView.builder( - itemCount: cards.length, - itemBuilder: (context, index) => cards[index], - ), ); }, ), @@ -55,90 +59,71 @@ class StudyPageState extends State { List _buildCards(BuildContext context) { final items = []; - final updateCard = _hasUpdateCard(); - items.add(updateCard); - items.add(_studyCard( - context, - widget.model.studyDescriptionMessage, - onTap: () { - context.push(StudyDetailsPage.route); - }, - )); + if (widget.model.appUpdateAvailable) items.add(_hasUpdateCard()); + items.add( + _studyCard( + context, + widget.model.studyDescriptionMessage, + onTap: () { + context.push(StudyDetailsPage.route); + }, + ), + ); items.add(_studyStatusCard()); - if (LocalSettings().isAnonymous) { + if (widget.model.isAnonymousUser) { items.add(AnonymousCard()); } - if (bloc.messages.isNotEmpty) { + if (widget.model.messages.isNotEmpty) { items.add(_buildAnnouncementsTitle(context)); - items.addAll(bloc.messages.map((message) { - return _announcementCard(context, message); - }).toList()); + // Show newest announcements first: sort by timestamp descending + final messages = List.from(widget.model.messages)..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + items.addAll( + messages.map((message) { + return _announcementCard(context, message); + }).toList(), + ); } return items; } Widget _hasUpdateCard() { RPLocalizations locale = RPLocalizations.of(context)!; - return FutureBuilder( - future: bloc.getAppHasUpdate(), - builder: (context, snapshot) { - if (snapshot.data == true) { - return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, - elevation: 8, + return StudiesMaterial( + backgroundColor: Theme.of(context).extension()!.grey50!, + elevation: 8, + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Row( + children: [ + Expanded( child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - locale.translate('pages.about.app_update'), - style: aboutCardSubtitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(right: 16), - child: ElevatedButton( - onPressed: () async { - _redirectToUpdateStore(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: CACHET.DEPLOYMENT_DEPLOYING, - padding: const EdgeInsets.symmetric( - horizontal: 30, - vertical: 12, - ), - ), - child: Text( - locale.translate("get"), - style: TextStyle( - color: Colors.white, - ), - ), - ), - ), - ], + padding: const EdgeInsets.all(8.0), + child: Text( + locale.translate('pages.about.app_update'), + style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900), ), ), - ); - } else { - return SizedBox.shrink(); - } - }); + ), + Padding( + padding: const EdgeInsets.only(right: 16), + child: ElevatedButton( + onPressed: () async { + _redirectToUpdateStore(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: CACHET.DEPLOYMENT_DEPLOYING, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), + ), + child: Text(locale.translate("get"), style: TextStyle(color: Colors.white)), + ), + ), + ], + ), + ), + ); } - Widget _studyCard( - BuildContext context, - Message message, { - Function? onTap, - }) { + Widget _studyCard(BuildContext context, Message message, {Function? onTap}) { RPLocalizations locale = RPLocalizations.of(context)!; // Initialization the language of the timeago package @@ -146,7 +131,7 @@ class StudyPageState extends State { timeago.setLocaleMessages('es', timeago.EsMessages()); return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, child: InkWell( onTap: () { if (onTap != null) { @@ -171,10 +156,10 @@ class StudyPageState extends State { ), Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text(locale.translate(message.title!), - style: aboutStudyCardTitleStyle.copyWith( - color: - Theme.of(context).extension()!.primary)), + child: Text( + locale.translate(message.title!), + style: fs24fw700.copyWith(color: Theme.of(context).extension()!.primary), + ), ), if (message.subTitle != null && message.subTitle!.isNotEmpty) Row( @@ -182,25 +167,23 @@ class StudyPageState extends State { Expanded( child: Text( locale.translate(message.subTitle!), - style: aboutCardContentStyle.copyWith( - color: - Theme.of(context).extension()!.grey700, - ), + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey700), ), ), ], ), if (message.message != null && message.message!.isNotEmpty) - Row(children: [ - Expanded( + Row( + children: [ + Expanded( child: Text( - "${locale.translate(message.message!).substring(0, (message.message!.length > 150) ? 150 : null)}...", - style: aboutCardContentStyle.copyWith( - color: - Theme.of(context).extension()!.grey900), - textAlign: TextAlign.start, - )), - ]), + "${locale.translate(message.message!).substring(0, (message.message!.length > 150) ? 150 : null)}...", + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey900), + textAlign: TextAlign.start, + ), + ), + ], + ), ], ), ), @@ -212,71 +195,59 @@ class StudyPageState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return FutureBuilder( - future: bloc.studyDeploymentStatus, + future: widget.model.studyDeploymentStatus, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return Container(); + return StudiesMaterial( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 28), + child: Center(child: CircularProgressIndicator()), + ), + ); } else if (snapshot.hasError) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 22), - child: Text('Error: ${snapshot.error}'), + return StudiesMaterial( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 28), + child: Text('Error: ${snapshot.error}', textAlign: TextAlign.center), + ), ); // Show an error message if the future fails } else if (!snapshot.hasData || snapshot.data == null) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 22), - child: Center( - child: CircularProgressIndicator(), - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 28), + child: Center(child: CircularProgressIndicator()), ); // Handle the case where data is null } - final deploymentStatus = snapshot.data!.status; + // `status` may be null; fall back to the domain default. + final deploymentStatus = snapshot.data!.status ?? StudyDeploymentStatusTypes.Invited; return StudiesMaterial( margin: const EdgeInsets.all(16.0), - backgroundColor: Theme.of(context).extension()!.grey50!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + backgroundColor: Theme.of(context).extension()!.grey50!, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.0), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(16.0)), child: Row( children: [ Expanded( child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, vertical: 22.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 22.0), child: Row( children: [ Column( children: [ Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6.0, vertical: 4), - child: CircleAvatar( - radius: 18, - backgroundColor: - studyStatusColors[deploymentStatus], - ), + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4), + child: CircleAvatar(radius: 18, backgroundColor: studyStatusColors[deploymentStatus]), ), Padding( - padding: - const EdgeInsets.symmetric(horizontal: 6.0), + padding: const EdgeInsets.symmetric(horizontal: 6.0), child: Text( - deploymentStatus == - StudyDeploymentStatusTypes - .DeployingDevices - ? locale.translate( - 'pages.about.status.deploying_devices') - : deploymentStatus - .toString() - .split('.') - .last, + deploymentStatus == StudyDeploymentStatusTypes.DeployingDevices + ? locale.translate('pages.about.status.deploying_devices') + : deploymentStatus.toString().split('.').last, maxLines: 2, - style: aboutCardSubtitleStyle.copyWith( - color: studyStatusColors[deploymentStatus]), + style: fs16fw600.copyWith(color: studyStatusColors[deploymentStatus]), ), ), ], @@ -286,10 +257,8 @@ class StudyPageState extends State { padding: const EdgeInsets.only(left: 16.0), child: Text( getStatusText(locale, deploymentStatus, snapshot), - style: aboutCardSubtitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + style: fs16fw600.copyWith( + color: Theme.of(context).extension()!.grey900, fontSize: 14, ), ), @@ -319,11 +288,13 @@ class StudyPageState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(locale.translate('Announcements'), - style: aboutStudyCardTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey900, - fontWeight: FontWeight.bold, - )), + Text( + locale.translate('Announcements'), + style: fs24fw700.copyWith( + color: Theme.of(context).extension()!.grey900, + fontWeight: FontWeight.bold, + ), + ), ], ), ), @@ -331,11 +302,7 @@ class StudyPageState extends State { ); } - Widget _announcementCard( - BuildContext context, - Message message, { - Function? onTap, - }) { + Widget _announcementCard(BuildContext context, Message message, {Function? onTap}) { RPLocalizations locale = RPLocalizations.of(context)!; // Initialization the language of the timeago package @@ -344,7 +311,7 @@ class StudyPageState extends State { return Container( child: StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, child: InkWell( onTap: () { if (onTap != null) { @@ -363,16 +330,11 @@ class StudyPageState extends State { children: [ Expanded( child: Padding( - padding: const EdgeInsets.only( - top: 8.0, bottom: 8, right: 8), + padding: const EdgeInsets.only(top: 8.0, bottom: 8, right: 8), child: Text( locale.translate(message.title!), overflow: TextOverflow.ellipsis, - style: aboutCardTitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900, - ), + style: fs20fw700.copyWith(color: Theme.of(context).extension()!.grey900), ), ), ), @@ -380,15 +342,8 @@ class StudyPageState extends State { color: CACHET.DEPLOYMENT_DEPLOYING, borderRadius: BorderRadius.circular(100.0), child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - locale.translate(message.type - .toString() - .split('.') - .last - .toLowerCase()), - style: aboutCardSubtitleStyle.copyWith( - color: Colors.white)), + padding: const EdgeInsets.all(4.0), + child: Icon(message.type.icon, color: Colors.white), ), ), ], @@ -397,26 +352,18 @@ class StudyPageState extends State { padding: const EdgeInsets.only(bottom: 12.0), child: Row( children: [ - if (message.subTitle != null && - message.subTitle!.isNotEmpty) + if (message.subTitle != null && message.subTitle!.isNotEmpty) Expanded( child: Text( locale.translate(message.subTitle!), - style: aboutCardContentStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey700, - ), + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey700), ), ), Spacer(), Text( timeago.format(message.timestamp.toLocal()), - style: aboutCardTimeAgoStyle.copyWith( - color: - Theme.of(context).extension()!.grey600, - ), - ) + style: fs10fw600.copyWith(color: Theme.of(context).extension()!.grey600), + ), ], ), ), @@ -424,13 +371,14 @@ class StudyPageState extends State { Row( children: [ Expanded( - child: Text( - locale.translate(message.message!).length > 150 - ? '${locale.translate(message.message!).substring(0, 150)}...' - : locale.translate(message.message!), - style: aboutCardContentStyle, - textAlign: TextAlign.start, - )), + child: Text( + locale.translate(message.message!).length > 150 + ? '${locale.translate(message.message!).substring(0, 150)}...' + : locale.translate(message.message!), + style: fs16fw400, + textAlign: TextAlign.start, + ), + ), ], ), ], @@ -445,8 +393,7 @@ class StudyPageState extends State { PackageInfo packageInfo = await PackageInfo.fromPlatform(); Uri url; if (Platform.isAndroid) { - url = Uri.parse( - 'https://play.google.com/store/apps/details?id=${packageInfo.packageName}'); + url = Uri.parse('https://play.google.com/store/apps/details?id=${packageInfo.packageName}'); } else if (Platform.isIOS) { url = Uri.parse('https://apps.apple.com/app/id1569798025'); } else { @@ -467,9 +414,7 @@ class StudyPageState extends State { ) { if (deploymentStatusType == StudyDeploymentStatusTypes.DeployingDevices) { return locale.translate('pages.about.status.deploying_devices.message') + - snapshot.data!.deviceStatusList.first - .remainingDevicesToRegisterBeforeDeployment! - .join(' | '); + snapshot.data!.deviceStatusList.first.remainingDevicesToRegisterBeforeDeployment!.join(' | '); } else { return locale.translate(studyStatusText[deploymentStatusType]!); } @@ -484,8 +429,7 @@ class StudyPageState extends State { static Map studyStatusText = { StudyDeploymentStatusTypes.Invited: 'pages.about.status.invited.message', - StudyDeploymentStatusTypes.DeployingDevices: - 'pages.about.status.deploying_devices.message', + StudyDeploymentStatusTypes.DeployingDevices: 'pages.about.status.deploying_devices.message', StudyDeploymentStatusTypes.Running: 'pages.about.status.running.message', StudyDeploymentStatusTypes.Stopped: 'pages.about.status.stopped.message', }; @@ -514,3 +458,26 @@ extension CopyWithAdditional on DateTime { ); } } + +/// Placeholder shown while [AppBloc.configureStudy] is running. +class _ConfiguringStudyLoader extends StatelessWidget { + const _ConfiguringStudyLoader(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text( + 'Configuring the study', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ], + ), + ); + } +} diff --git a/lib/ui/pages/task_list_page.dart b/lib/ui/pages/task_list_page.dart index 1aec37ac..54cc55cc 100644 --- a/lib/ui/pages/task_list_page.dart +++ b/lib/ui/pages/task_list_page.dart @@ -22,14 +22,13 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { double get maxExtent => _tabBar.preferredSize.height; @override - Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 4), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - color: Theme.of(context).extension()!.grey200, + color: Theme.of(context).extension()!.grey200, ), child: _tabBar, ); @@ -41,42 +40,60 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { } } -class TaskListPageState extends State - with TickerProviderStateMixin { +class TaskListPageState extends State with TickerProviderStateMixin { late TabController _tabController; - bool? showParticipantDataCard = false; - @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); - bloc.getParticipantDataListFromDeployment().then((value) { - setState(() { - showParticipantDataCard = value.isEmpty; - }); - }); + widget.model.addListener(_onModelChanged); + widget.model.checkParticipantData(); _tabController.addListener(() { setState(() {}); }); } + @override + void dispose() { + widget.model.removeListener(_onModelChanged); + _tabController.dispose(); + super.dispose(); + } + + void _onModelChanged() { + if (!mounted) return; + + final autoCompleted = widget.model.autoCompletedTask; + if (autoCompleted != null) { + widget.model.autoCompletedTaskShown(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Theme.of(context).extension()!.grey700, + content: Text(RPLocalizations.of(context)!.translate('Done!')), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + duration: const Duration(seconds: 1), + ), + ); + } + + setState(() {}); + } + @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return DefaultTabController( length: 2, child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar(hasProfileIcon: true), ), Expanded( @@ -91,16 +108,13 @@ class TaskListPageState extends State slivers: [ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Align( alignment: Alignment.centerLeft, child: Text( locale.translate('pages.task_list.title'), - style: aboutStudyCardTitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + style: fs24fw700.copyWith( + color: Theme.of(context).extension()!.grey900, fontWeight: FontWeight.bold, ), ), @@ -109,80 +123,55 @@ class TaskListPageState extends State ), // Scoreboard showing days in study and tasks completed SliverPadding( - padding: const EdgeInsets.only( - top: 4, bottom: 6, left: 40, right: 40), + padding: const EdgeInsets.only(top: 4, bottom: 6, left: 40, right: 40), sliver: ScoreboardCard(widget.model), ), // Tab holder SliverPadding( - padding: const EdgeInsets.only( - top: 8, bottom: 24, left: 64, right: 64), + padding: const EdgeInsets.only(top: 8, bottom: 24, left: 64, right: 64), sliver: SliverPersistentHeader( pinned: true, delegate: _SliverAppBarDelegate( TabBar( controller: _tabController, - labelPadding: const EdgeInsets.only( - top: 4, bottom: 4, left: 4, right: 4), - labelColor: Theme.of(context) - .extension()! - .grey900, - unselectedLabelColor: Theme.of(context) - .extension()! - .grey900, + labelPadding: const EdgeInsets.only(top: 4, bottom: 4, left: 4, right: 4), + labelColor: Theme.of(context).extension()!.grey900, + unselectedLabelColor: Theme.of(context).extension()!.grey900, dividerColor: Colors.transparent, indicator: ShapeDecoration( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - color: Theme.of(context) - .extension()! - .white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + color: Theme.of(context).extension()!.white, ), tabs: [ Container( width: double.infinity, - child: Tab( - text: locale.translate( - 'pages.task_list.pending'), - ), + child: Tab(text: locale.translate('pages.task_list.pending')), ), Container( width: double.infinity, - child: Tab( - text: locale.translate( - 'pages.task_list.completed'), - ), + child: Tab(text: locale.translate('pages.task_list.completed')), ), ], ), ), ), ), - if (showParticipantDataCard!) - SliverToBoxAdapter( - child: _buildParticipantDataCard(), - ), + if (widget.model.showParticipantDataCard) + SliverToBoxAdapter(child: _buildParticipantDataCard()), SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - UserTask userTask = widget.model.tasks[index]; - if (_tabController.index == 0) { - if (userTask.availableForUser) { - return _buildAvailableTaskCard( - context, userTask); - } - } else if (_tabController.index == 1) { - if (userTask.state == UserTaskState.done || - userTask.state == UserTaskState.expired) { - return _buildCompletedTaskCard( - context, userTask); - } + delegate: SliverChildBuilderDelegate((BuildContext context, int index) { + UserTask userTask = widget.model.tasks[index]; + if (_tabController.index == 0) { + if (userTask.availableForUser) { + return _buildAvailableTaskCard(context, userTask); } - return const SizedBox.shrink(); - }, - childCount: widget.model.tasks.length, - ), + } else if (_tabController.index == 1) { + if (userTask.state == UserTaskState.done || userTask.state == UserTaskState.expired) { + return _buildCompletedTaskCard(context, userTask); + } + } + return const SizedBox.shrink(); + }, childCount: widget.model.tasks.length), ), ], ); @@ -203,12 +192,9 @@ class TaskListPageState extends State hasBorder: true, borderColor: taskTypeColors["ExpectedParticipantData"]!, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal( - left: Radius.circular(2.0), - right: Radius.circular(8.0), - ), + borderRadius: BorderRadius.horizontal(left: Radius.circular(2.0), right: Radius.circular(8.0)), ), - backgroundColor: Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, child: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: IntrinsicHeight( @@ -222,15 +208,16 @@ class TaskListPageState extends State children: [ Row( children: [ - Icon(taskTypeIcons["ExpectedParticipantData"]!.icon, - color: taskTypeColors["ExpectedParticipantData"]), + Icon( + taskTypeIcons["ExpectedParticipantData"]!.icon, + color: taskTypeColors["ExpectedParticipantData"], + ), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( "Input Data", style: TextStyle( - color: - taskTypeColors["ExpectedParticipantData"], + color: taskTypeColors["ExpectedParticipantData"], fontWeight: FontWeight.bold, ), ), @@ -245,17 +232,12 @@ class TaskListPageState extends State children: [ Text( "Participant Data", - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16.0, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), ), const SizedBox(height: 4.0), Padding( padding: const EdgeInsets.only(right: 20), - child: Text( - "Fill in the required participant data to continue with the study.", - ), + child: Text("Fill in the required participant data to continue with the study."), ), ], ), @@ -283,15 +265,11 @@ class TaskListPageState extends State hasBorder: true, borderColor: taskTypeColors[userTask.type]!, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal( - left: Radius.circular(2.0), - right: Radius.circular(8.0), - ), + borderRadius: BorderRadius.horizontal(left: Radius.circular(2.0), right: Radius.circular(8.0)), ), - backgroundColor: - userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 - ? CACHET.TASK_TO_EXPIRE_BACKGROUND - : Theme.of(context).extension()!.grey50!, + backgroundColor: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 + ? CACHET.TASK_TO_EXPIRE_BACKGROUND + : Theme.of(context).extension()!.grey50!, child: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: IntrinsicHeight( @@ -306,30 +284,21 @@ class TaskListPageState extends State children: [ Row( children: [ - if (userTask.state == UserTaskState.started) - CircularProgressIndicator(), - if (userTask.state != UserTaskState.started) - _taskTypeIcon(userTask), + if (userTask.state == UserTaskState.started) CircularProgressIndicator(), + if (userTask.state != UserTaskState.started) _taskTypeIcon(userTask), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( - userTask.type[0].toUpperCase() + - userTask.type.substring(1), - style: TextStyle( - color: taskTypeColors[userTask.type], - fontWeight: FontWeight.bold, - ), + userTask.type[0].toUpperCase() + userTask.type.substring(1), + style: TextStyle(color: taskTypeColors[userTask.type], fontWeight: FontWeight.bold), ), ), Spacer(), if (_timeRemainingSubtitle(userTask).isNotEmpty) Icon( Icons.alarm, - color: userTask.expiresIn != null && - userTask.expiresIn!.inHours < 24 - ? Theme.of(context) - .extension()! - .warningColor + color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 + ? Theme.of(context).extension()!.warningColor : Colors.grey, ), const SizedBox(width: 4.0), @@ -338,16 +307,13 @@ class TaskListPageState extends State child: Text( _timeRemainingSubtitle(userTask), style: TextStyle( - color: userTask.expiresIn != null && - userTask.expiresIn!.inHours < 24 - ? Theme.of(context) - .extension()! - .warningColor + color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 + ? Theme.of(context).extension()!.warningColor : Colors.grey, fontSize: 12.0, ), ), - ) + ), ], ), const SizedBox(height: 8.0), @@ -358,17 +324,12 @@ class TaskListPageState extends State children: [ Text( locale.translate(userTask.title), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16.0, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), ), const SizedBox(height: 4.0), Padding( padding: const EdgeInsets.only(right: 20), - child: Text( - locale.translate(userTask.description), - ), + child: Text(locale.translate(userTask.description)), ), Padding( padding: const EdgeInsets.only(top: 8.0), @@ -379,10 +340,7 @@ class TaskListPageState extends State padding: const EdgeInsets.only(right: 20), child: Text( _estimatedTimeSubtitle(userTask), - style: TextStyle( - color: Colors.grey, - fontSize: 12.0, - ), + style: TextStyle(color: Colors.grey, fontSize: 12.0), ), ), ], @@ -400,25 +358,8 @@ class TaskListPageState extends State ), ), onTap: () { - // only start if not already started, done, or expired - if (userTask.state == UserTaskState.enqueued || - userTask.state == UserTaskState.canceled) { - userTask.onStart(); - if (userTask.hasWidget) { - context.push('/task/${userTask.id}'); - } else { - Timer(const Duration(seconds: 10), () { - userTask.onDone(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - backgroundColor: - Theme.of(context).extension()!.grey700, - content: Text(locale.translate('Done!')), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - duration: const Duration(seconds: 1))); - }); - } + if (widget.model.startUserTask(userTask)) { + context.push('/task/${userTask.id}'); } }, ), @@ -436,24 +377,15 @@ class TaskListPageState extends State builder: (context, snapshot) { if (taskTypeIcons[userTask.type] != null && userTask.availableForUser) { return originalIcon; - } else if (taskTypeIcons[userTask.type] != null && - userTask.state == UserTaskState.started) { + } else if (taskTypeIcons[userTask.type] != null && userTask.state == UserTaskState.started) { return Padding( padding: const EdgeInsets.all(4), - child: SizedBox( - child: CircularProgressIndicator( - strokeWidth: 3, - ), - height: 14, - width: 14, - ), + child: SizedBox(child: CircularProgressIndicator(strokeWidth: 3), height: 14, width: 14), ); - } else if (taskTypeIcons[userTask.type] != null && - userTask.state == UserTaskState.done) { + } else if (taskTypeIcons[userTask.type] != null && userTask.state == UserTaskState.done) { return Icon(originalIcon.icon, color: CACHET.TASK_COMPLETED_BLUE); } else { - return Icon(originalIcon.icon, - color: Theme.of(context).extension()!.grey600); + return Icon(originalIcon.icon, color: Theme.of(context).extension()!.grey600); } }, ); @@ -493,17 +425,12 @@ class TaskListPageState extends State return Center( child: GestureDetector( child: StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, hasBorder: true, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal( - left: Radius.circular(2.0), - right: Radius.circular(8.0), - ), + borderRadius: BorderRadius.horizontal(left: Radius.circular(2.0), right: Radius.circular(8.0)), ), - borderColor: (userTask.state == UserTaskState.done) - ? CACHET.TASK_COMPLETED_BLUE - : CACHET.GREY_6, + borderColor: (userTask.state == UserTaskState.done) ? CACHET.TASK_COMPLETED_BLUE : CACHET.GREY_6, child: Padding( padding: const EdgeInsets.only(top: 16, bottom: 16, right: 16), child: IntrinsicHeight( @@ -534,19 +461,15 @@ class TaskListPageState extends State Spacer(), Text( userTask.doneTime != null - ? DateFormat('MMMM dd yyyy') - .format(userTask.doneTime!) + ? DateFormat('MMMM dd yyyy').format(userTask.doneTime!) : 'Done time null', style: TextStyle( - color: userTask.expiresIn != null && - userTask.expiresIn!.inHours < 24 - ? Theme.of(context) - .extension()! - .warningColor + color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 + ? Theme.of(context).extension()!.warningColor : Colors.grey, fontSize: 12.0, ), - ) + ), ], ), const SizedBox(height: 8.0), @@ -557,10 +480,7 @@ class TaskListPageState extends State children: [ Text( locale.translate(userTask.title), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16.0, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), ), ], ), @@ -587,160 +507,67 @@ class TaskListPageState extends State Ink( width: 60, height: 60, - decoration: const ShapeDecoration( - color: CACHET.GREY_1, - shape: CircleBorder(), - ), - child: const Icon( - Icons.playlist_add_check, - color: Colors.white, - ), + decoration: const ShapeDecoration(color: CACHET.GREY_1, shape: CircleBorder()), + child: const Icon(Icons.playlist_add_check, color: Colors.white), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Text( - locale.translate("pages.task_list.no_tasks"), - style: aboutCardSubtitleStyle, - textAlign: TextAlign.center, - )) + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Text(locale.translate("pages.task_list.no_tasks"), style: fs16fw600, textAlign: TextAlign.center), + ), ], ); } static Map taskTypeIcons = { - SurveyUserTask.SURVEY_TYPE: const Icon( - Icons.workspaces, - color: CACHET.TASK_BLUE, - ), - SurveyUserTask.COGNITIVE_ASSESSMENT_TYPE: const Icon( - Icons.psychology, - color: CACHET.LIGHT_PURPLE, - ), - SurveyUserTask.AUDIO_TYPE: const Icon( - Icons.hearing, - color: CACHET.GREEN, - ), - SurveyUserTask.VIDEO_TYPE: const Icon( - Icons.videocam, - color: CACHET.LIGHT_BLUE, - ), - SurveyUserTask.IMAGE_TYPE: const Icon( - Icons.image, - color: CACHET.YELLOW, - ), - HealthUserTask.HEALTH_ASSESSMENT_TYPE: const Icon( - Icons.favorite_rounded, - color: CACHET.RED_1, - ), - BackgroundSensingUserTask.SENSING_TYPE: const Icon( - Icons.sensors, - color: CACHET.LIGHT_BROWN, - ), - "ExpectedParticipantData": const Icon( - Icons.dataset, - color: CACHET.TASK_INPUT_DATA, - ), + AppTask.SURVEY_TYPE: const Icon(Icons.workspaces, color: CACHET.TASK_BLUE), + AppTask.COGNITIVE_ASSESSMENT_TYPE: const Icon(Icons.psychology, color: CACHET.LIGHT_PURPLE), + AppTask.AUDIO_TYPE: const Icon(Icons.hearing, color: CACHET.GREEN), + AppTask.VIDEO_TYPE: const Icon(Icons.videocam, color: CACHET.LIGHT_BLUE), + AppTask.IMAGE_TYPE: const Icon(Icons.image, color: CACHET.YELLOW), + AppTask.HEALTH_ASSESSMENT_TYPE: const Icon(Icons.favorite_rounded, color: CACHET.RED_1), + AppTask.SENSING_TYPE: const Icon(Icons.sensors, color: CACHET.LIGHT_BROWN), + "ExpectedParticipantData": const Icon(Icons.dataset, color: CACHET.TASK_INPUT_DATA), }; static Map taskTypeColors = { - SurveyUserTask.SURVEY_TYPE: CACHET.TASK_BLUE, - SurveyUserTask.COGNITIVE_ASSESSMENT_TYPE: CACHET.LIGHT_PURPLE, - SurveyUserTask.AUDIO_TYPE: CACHET.GREEN, - SurveyUserTask.VIDEO_TYPE: CACHET.LIGHT_BLUE, - SurveyUserTask.IMAGE_TYPE: CACHET.YELLOW, - HealthUserTask.HEALTH_ASSESSMENT_TYPE: CACHET.RED_1, - BackgroundSensingUserTask.SENSING_TYPE: CACHET.LIGHT_BROWN, + AppTask.SURVEY_TYPE: CACHET.TASK_BLUE, + AppTask.COGNITIVE_ASSESSMENT_TYPE: CACHET.LIGHT_PURPLE, + AppTask.AUDIO_TYPE: CACHET.GREEN, + AppTask.VIDEO_TYPE: CACHET.LIGHT_BLUE, + AppTask.IMAGE_TYPE: CACHET.YELLOW, + AppTask.HEALTH_ASSESSMENT_TYPE: CACHET.RED_1, + AppTask.SENSING_TYPE: CACHET.LIGHT_BROWN, "ExpectedParticipantData": CACHET.TASK_INPUT_DATA, }; static Map measureTypeIcons = { - DeviceSamplingPackage.FREE_MEMORY: const Icon( - Icons.memory, - color: CACHET.GREY_4, - ), - DeviceSamplingPackage.DEVICE_INFORMATION: const Icon( - Icons.phone_android, - color: CACHET.GREY_4, - ), - DeviceSamplingPackage.BATTERY_STATE: const Icon( - Icons.battery_charging_full, - color: CACHET.GREEN, - ), - SensorSamplingPackage.STEP_COUNT: const Icon( - Icons.directions_walk, - color: CACHET.LIGHT_PURPLE, - ), - SensorSamplingPackage.ACCELERATION: const Icon( - Icons.adb, - color: CACHET.GREY_4, - ), - SensorSamplingPackage.ROTATION: const Icon( - Icons.adb, - color: CACHET.GREY_4, - ), - SensorSamplingPackage.AMBIENT_LIGHT: const Icon( - Icons.highlight, - color: CACHET.YELLOW, - ), - MediaSamplingPackage.AUDIO: const Icon( - Icons.mic, - color: CACHET.GREEN, - ), - MediaSamplingPackage.NOISE: const Icon( - Icons.hearing, - color: CACHET.YELLOW, - ), - MediaSamplingPackage.VIDEO: const Icon( - Icons.videocam, - color: CACHET.LIGHT_BLUE, - ), - MediaSamplingPackage.IMAGE: const Icon( - Icons.image, - color: CACHET.YELLOW, - ), - DeviceSamplingPackage.SCREEN_EVENT: const Icon( - Icons.screen_lock_portrait, - color: CACHET.LIGHT_PURPLE, - ), - ContextSamplingPackage.LOCATION: const Icon( - Icons.location_searching, - color: CACHET.CYAN, - ), - ContextSamplingPackage.ACTIVITY: const Icon( - Icons.local_fire_department, - color: CACHET.ORANGE, - ), - ContextSamplingPackage.WEATHER: const Icon( - Icons.cloud, - color: CACHET.LIGHT_BLUE, - ), - ContextSamplingPackage.AIR_QUALITY: const Icon( - Icons.air, - color: CACHET.GREY_3, - ), - ContextSamplingPackage.GEOFENCE: const Icon( - Icons.location_on, - color: CACHET.CYAN, - ), - ContextSamplingPackage.MOBILITY: const Icon( - Icons.location_on, - color: CACHET.ORANGE, - ), - SurveySamplingPackage.SURVEY: const Icon( - Icons.workspaces, - color: CACHET.ORANGE, - ), + DeviceSamplingPackage.FREE_MEMORY: const Icon(Icons.memory, color: CACHET.GREY_4), + DeviceSamplingPackage.DEVICE_INFORMATION: const Icon(Icons.phone_android, color: CACHET.GREY_4), + DeviceSamplingPackage.BATTERY_STATE: const Icon(Icons.battery_charging_full, color: CACHET.GREEN), + CarpDataTypes.STEP_COUNT: const Icon(Icons.directions_walk, color: CACHET.LIGHT_PURPLE), + SensorSamplingPackage.ACCELERATION: const Icon(Icons.adb, color: CACHET.GREY_4), + SensorSamplingPackage.ROTATION: const Icon(Icons.adb, color: CACHET.GREY_4), + SensorSamplingPackage.AMBIENT_LIGHT: const Icon(Icons.highlight, color: CACHET.YELLOW), + MediaSamplingPackage.AUDIO: const Icon(Icons.mic, color: CACHET.GREEN), + MediaSamplingPackage.NOISE: const Icon(Icons.hearing, color: CACHET.YELLOW), + MediaSamplingPackage.VIDEO: const Icon(Icons.videocam, color: CACHET.LIGHT_BLUE), + MediaSamplingPackage.IMAGE: const Icon(Icons.image, color: CACHET.YELLOW), + DeviceSamplingPackage.SCREEN_EVENT: const Icon(Icons.screen_lock_portrait, color: CACHET.LIGHT_PURPLE), + ContextSamplingPackage.LOCATION: const Icon(Icons.location_searching, color: CACHET.CYAN), + ContextSamplingPackage.ACTIVITY: const Icon(Icons.local_fire_department, color: CACHET.ORANGE), + ContextSamplingPackage.WEATHER: const Icon(Icons.cloud, color: CACHET.LIGHT_BLUE), + ContextSamplingPackage.AIR_QUALITY: const Icon(Icons.air, color: CACHET.GREY_3), + ContextSamplingPackage.GEOFENCE: const Icon(Icons.location_on, color: CACHET.CYAN), + ContextSamplingPackage.MOBILITY: const Icon(Icons.location_on, color: CACHET.ORANGE), + SurveySamplingPackage.SURVEY: const Icon(Icons.workspaces, color: CACHET.ORANGE), }; static Map get taskStateIcon => { - UserTaskState.initialized: - const Icon(Icons.stream, color: CACHET.YELLOW), - UserTaskState.enqueued: - const Icon(Icons.notifications, color: CACHET.YELLOW), - UserTaskState.dequeued: const Icon(Icons.stop, color: CACHET.YELLOW), - UserTaskState.started: - const Icon(Icons.play_arrow, color: CACHET.GREY_4), - UserTaskState.canceled: const Icon(Icons.pause, color: CACHET.GREY_4), - UserTaskState.done: const Icon(Icons.check, color: CACHET.GREEN), - }; + UserTaskState.initialized: const Icon(Icons.stream, color: CACHET.YELLOW), + UserTaskState.enqueued: const Icon(Icons.notifications, color: CACHET.YELLOW), + UserTaskState.dequeued: const Icon(Icons.stop, color: CACHET.YELLOW), + UserTaskState.started: const Icon(Icons.play_arrow, color: CACHET.GREY_4), + UserTaskState.canceled: const Icon(Icons.pause, color: CACHET.GREY_4), + UserTaskState.done: const Icon(Icons.check, color: CACHET.GREEN), + }; } diff --git a/lib/ui/tasks/audio_page.dart b/lib/ui/tasks/audio_page.dart index 74f9da9a..8b41bdc9 100644 --- a/lib/ui/tasks/audio_page.dart +++ b/lib/ui/tasks/audio_page.dart @@ -28,24 +28,16 @@ class AudioPageState extends State { Row( children: [ Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 10), - child: const CarpAppBar( - hasProfileIcon: false, - ), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + child: const CarpAppBar(hasProfileIcon: false), ), Spacer(), IconButton( - color: Theme.of(context) - .extension()! - .grey900!, + color: Theme.of(context).extension()!.grey900!, onPressed: () { _showCancelConfirmationDialog(); }, - icon: const Icon( - Icons.close, - size: 30, - ), + icon: const Icon(Icons.close, size: 30), ), ], ), @@ -60,36 +52,24 @@ class AudioPageState extends State { child: Column( children: [ Padding( - padding: - const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.only(bottom: 24), child: Text( - locale.translate( - widget.audioUserTask!.title, - ), - style: audioTitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .primary, + locale.translate(widget.audioUserTask!.title), + style: fs22fw700.copyWith( + color: Theme.of(context).extension()!.primary, ), ), ), StudiesMaterial( - backgroundColor: Theme.of(context) - .extension()! - .white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Scrollbar( child: SingleChildScrollView( - scrollDirection: - Axis.vertical, //.horizontal + scrollDirection: Axis.vertical, //.horizontal child: Padding( - padding: - const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8.0), child: Text( - locale.translate( - widget.audioUserTask! - .instructions, - ), - style: audioContentStyle, + locale.translate(widget.audioUserTask!.instructions), + style: fs16fw600, ), ), ), @@ -98,28 +78,16 @@ class AudioPageState extends State { Spacer(), CircleAvatar( radius: 30, - backgroundColor: Theme.of(context) - .extension()! - .primary, + backgroundColor: Theme.of(context).extension()!.primary, child: IconButton( - onPressed: () => widget.audioUserTask! - .onRecordStart(), + onPressed: () => widget.audioUserTask!.onRecordStart(), padding: const EdgeInsets.all(0), - icon: const Icon( - Icons.mic, - color: Colors.white, - size: 30, - ), + icon: const Icon(Icons.mic, color: Colors.white, size: 30), ), ), Padding( - padding: const EdgeInsets.only( - top: 8, bottom: 40), - child: Text( - locale.translate( - "pages.audio_task.play"), - style: audioContentStyle, - ), + padding: const EdgeInsets.only(top: 8, bottom: 40), + child: Text(locale.translate("pages.audio_task.play"), style: fs16fw600), ), ], ), @@ -129,35 +97,24 @@ class AudioPageState extends State { child: Column( children: [ Padding( - padding: - const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.only(bottom: 24), child: Text( - locale.translate( - widget.audioUserTask!.title, - ), - style: audioTitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .primary, + locale.translate(widget.audioUserTask!.title), + style: fs22fw700.copyWith( + color: Theme.of(context).extension()!.primary, ), ), ), StudiesMaterial( - backgroundColor: Theme.of(context) - .extension()! - .white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Scrollbar( child: SingleChildScrollView( - scrollDirection: - Axis.vertical, //.horizontal + scrollDirection: Axis.vertical, //.horizontal child: Padding( - padding: - const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8.0), child: Text( - locale.translate(widget - .audioUserTask! - .instructions), - style: audioContentStyle, + locale.translate(widget.audioUserTask!.instructions), + style: fs16fw600, ), ), ), @@ -165,36 +122,22 @@ class AudioPageState extends State { ), Spacer(), Row( - mainAxisAlignment: - MainAxisAlignment.spaceAround, + mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ CircleAvatar( radius: 30, backgroundColor: CACHET.RED_1, child: IconButton( - onPressed: () => widget - .audioUserTask! - .onRecordStop(), + onPressed: () => widget.audioUserTask!.onRecordStop(), padding: const EdgeInsets.all(0), - icon: const Icon( - Icons.stop, - color: Colors.white, - size: 30, - ), + icon: const Icon(Icons.stop, color: Colors.white, size: 30), ), ), ], ), Padding( - padding: const EdgeInsets.only( - top: 8, - bottom: 40, - ), - child: Text( - locale.translate( - "pages.audio_task.recording"), - style: audioTitleStyle, - ), + padding: const EdgeInsets.only(top: 8, bottom: 40), + child: Text(locale.translate("pages.audio_task.recording"), style: fs22fw700), ), ], ), @@ -204,49 +147,34 @@ class AudioPageState extends State { child: Column( children: [ Padding( - padding: - const EdgeInsets.only(bottom: 32), + padding: const EdgeInsets.only(bottom: 32), child: Text( - locale.translate( - 'pages.audio_task.done'), - style: audioTitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .primary, + locale.translate('pages.audio_task.done'), + style: fs22fw700.copyWith( + color: Theme.of(context).extension()!.primary, ), ), ), Padding( - padding: - const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.only(bottom: 20), child: Text( - locale.translate( - 'pages.audio_task.recording_completed'), - style: audioContentStyle), + locale.translate('pages.audio_task.recording_completed'), + style: fs16fw600, + ), ), Spacer(), Padding( - padding: const EdgeInsets.only( - top: 8, - bottom: 40, - left: 20, - right: 20, - ), + padding: const EdgeInsets.only(top: 8, bottom: 40, left: 20, right: 20), child: Row( - crossAxisAlignment: - CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( flex: 1, child: IconButton( - onPressed: () => widget - .audioUserTask! - .onRecordReset(), + onPressed: () => widget.audioUserTask!.onRecordReset(), icon: Icon( Icons.replay, - color: Theme.of(context) - .extension()! - .grey700, + color: Theme.of(context).extension()!.grey700, size: 30, ), ), @@ -261,8 +189,7 @@ class AudioPageState extends State { Navigator.of(context).pop(); Navigator.of(context).pop(); }, - padding: - const EdgeInsets.all(0), + padding: const EdgeInsets.all(0), icon: const Icon( Icons.check_circle_outline, color: Colors.white, @@ -271,10 +198,7 @@ class AudioPageState extends State { ), ), ), - Expanded( - flex: 1, - child: Container(), - ), + Expanded(flex: 1, child: Container()), ], ), ), @@ -323,7 +247,7 @@ class AudioPageState extends State { // Exit the Ordered Task context.canPop() ? context.pop() : null; }, - ) + ), ], ); }, diff --git a/lib/ui/tasks/audio_task_page.dart b/lib/ui/tasks/audio_task_page.dart index aa589ef8..ae27aabf 100644 --- a/lib/ui/tasks/audio_task_page.dart +++ b/lib/ui/tasks/audio_task_page.dart @@ -18,96 +18,73 @@ class AudioTaskPageState extends State { canPop: true, child: Scaffold( body: Container( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + child: const CarpAppBar(hasProfileIcon: false), + ), + Spacer(), + IconButton( + color: Theme.of(context).extension()!.grey900!, + onPressed: () { + _showCancelConfirmationDialog(); + }, + icon: const Icon(Icons.close, size: 30), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: const Image(image: AssetImage('assets/icons/audio.png'), width: 220, height: 220), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text(locale.translate(widget.audioUserTask!.title), style: fs22fw700), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + child: Text( + '${locale.translate(widget.audioUserTask!.description)}\n\n' + '${locale.translate('pages.audio_task.play')}', + style: fs16fw600, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 10), - child: const CarpAppBar( - hasProfileIcon: false, - ), - ), - Spacer(), - IconButton( - color: - Theme.of(context).extension()!.grey900!, + OutlinedButton( onPressed: () { - _showCancelConfirmationDialog(); + widget.audioUserTask!.onCancel(); + Navigator.pop(context); }, - icon: const Icon( - Icons.close, - size: 30, - ), + child: Text(locale.translate("Cancel")), ), - ], - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 30), - child: const Image( - image: AssetImage('assets/icons/audio.png'), - width: 220, - height: 220), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Text(locale.translate(widget.audioUserTask!.title), - style: audioTitleStyle), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 12, horizontal: 20), - child: Text( - '${locale.translate(widget.audioUserTask!.description)}\n\n' - '${locale.translate('pages.audio_task.play')}', - style: audioContentStyle, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 30), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton( - onPressed: () { - widget.audioUserTask!.onCancel(); - Navigator.pop(context); - }, - child: Text(locale.translate("Cancel")), - ), - ElevatedButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AudioPage( - audioUserTask: widget.audioUserTask, - ), - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context) - .extension()! - .primary, - padding: const EdgeInsets.symmetric( - horizontal: 30, - vertical: 12, - ), - ), - child: Text( - locale.translate("next"), - style: TextStyle( - color: Colors.white, - ), + ElevatedButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AudioPage(audioUserTask: widget.audioUserTask), ), ), - ], - ), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).extension()!.primary, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), + ), + child: Text(locale.translate("next"), style: TextStyle(color: Colors.white)), + ), + ], ), - ], - )), + ), + ], + ), + ), ), ), ), @@ -133,9 +110,9 @@ class AudioTaskPageState extends State { TextButton( child: Text(locale.translate("YES")), onPressed: () { - context.pushReplacement(CarpStudyAppState.homeRoute); + context.pushReplacement(CarpAppState.homeRoute); }, - ) + ), ], ); }, diff --git a/lib/ui/tasks/camera_page.dart b/lib/ui/tasks/camera_page.dart index 07ecc94a..cf728759 100644 --- a/lib/ui/tasks/camera_page.dart +++ b/lib/ui/tasks/camera_page.dart @@ -76,10 +76,7 @@ class CameraPageState extends State { if (context.mounted) { await Navigator.of(context).push( MaterialPageRoute( - builder: (context) => DisplayPicturePage( - file: picture, - videoUserTask: widget.videoUserTask, - ), + builder: (context) => DisplayPicturePage(file: picture, videoUserTask: widget.videoUserTask), ), ); } @@ -101,7 +98,7 @@ class CameraPageState extends State { } } - void stopRecording(details) async { + void stopRecording(LongPressEndDetails details) async { try { var video = await _cameraController.stopVideoRecording(); @@ -109,10 +106,8 @@ class CameraPageState extends State { if (context.mounted) { await Navigator.of(context).push( MaterialPageRoute( - builder: (context) => DisplayPicturePage( - file: video, - isVideo: true, - videoUserTask: widget.videoUserTask)), + builder: (context) => DisplayPicturePage(file: video, isVideo: true, videoUserTask: widget.videoUserTask), + ), ); } setState(() { @@ -127,9 +122,7 @@ class CameraPageState extends State { @override Widget build(BuildContext context) { if (cameras == null || cameraInit == null) { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } return Scaffold( backgroundColor: Colors.black, @@ -142,8 +135,7 @@ class CameraPageState extends State { builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { return LayoutBuilder( - builder: - (BuildContext context, BoxConstraints constraints) { + builder: (BuildContext context, BoxConstraints constraints) { return SizedBox( width: constraints.maxWidth, height: constraints.maxHeight, @@ -152,10 +144,8 @@ class CameraPageState extends State { child: FittedBox( fit: BoxFit.cover, child: SizedBox( - width: - _cameraController.value.previewSize!.height, - height: - _cameraController.value.previewSize!.width, + width: _cameraController.value.previewSize!.height, + height: _cameraController.value.previewSize!.width, child: CameraPreview(_cameraController), ), ), @@ -179,12 +169,7 @@ class CameraPageState extends State { Icons.close, color: Colors.white, size: 30, - shadows: [ - Shadow( - blurRadius: 3.0, - color: Colors.black, - ), - ], + shadows: [Shadow(blurRadius: 3.0, color: Colors.black)], ), ), ), @@ -200,12 +185,7 @@ class CameraPageState extends State { icon: const Icon( Icons.flip_camera_android, color: Colors.white, - shadows: [ - Shadow( - blurRadius: 3.0, - color: Colors.black, - ), - ], + shadows: [Shadow(blurRadius: 3.0, color: Colors.black)], ), ), GestureDetector( @@ -221,18 +201,14 @@ class CameraPageState extends State { height: 65, child: CircularProgressIndicator( backgroundColor: Colors.white54, - valueColor: - AlwaysStoppedAnimation(Colors.black54), + valueColor: AlwaysStoppedAnimation(Colors.black54), strokeWidth: 5, ), ), Container( height: 60, width: 60, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.red, - ), + decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.red), ), ], ) @@ -240,12 +216,7 @@ class CameraPageState extends State { height: 60, width: 60, decoration: const BoxDecoration( - boxShadow: [ - BoxShadow( - color: Colors.black, - blurRadius: 3.0, - ) - ], + boxShadow: [BoxShadow(color: Colors.black, blurRadius: 3.0)], shape: BoxShape.circle, color: Colors.white, ), @@ -256,12 +227,7 @@ class CameraPageState extends State { icon: Icon( flashIcon, color: Colors.white, - shadows: [ - Shadow( - blurRadius: 3.0, - color: Colors.black, - ), - ], + shadows: [Shadow(blurRadius: 3.0, color: Colors.black)], ), ), ], @@ -286,8 +252,7 @@ class CameraPageState extends State { actions: [ TextButton( child: Text(locale.translate("NO")), - onPressed: () => - Navigator.of(context).pop(), // Dismissing the pop-up + onPressed: () => Navigator.of(context).pop(), // Dismissing the pop-up ), TextButton( child: Text(locale.translate("YES")), @@ -301,7 +266,7 @@ class CameraPageState extends State { Navigator.of(context).pop(); Navigator.of(context).pop(); }, - ) + ), ], ); }, diff --git a/lib/ui/tasks/camera_task_page.dart b/lib/ui/tasks/camera_task_page.dart index 251d36e9..f814e5b6 100644 --- a/lib/ui/tasks/camera_task_page.dart +++ b/lib/ui/tasks/camera_task_page.dart @@ -3,10 +3,7 @@ part of carp_study_app; class CameraTaskPage extends StatefulWidget { final VideoUserTask mediaUserTask; - const CameraTaskPage({ - super.key, - required this.mediaUserTask, - }); + const CameraTaskPage({super.key, required this.mediaUserTask}); @override CameraTaskPageState createState() => CameraTaskPageState(); @@ -15,133 +12,94 @@ class CameraTaskPage extends StatefulWidget { class CameraTaskPageState extends State { @override Widget build(BuildContext context) => PopScope( - canPop: true, - child: Scaffold( - body: SafeArea( - child: StreamBuilder( - stream: widget.mediaUserTask.stateEvents, - initialData: UserTaskState.enqueued, - builder: (context, AsyncSnapshot snapshot) { - RPLocalizations locale = RPLocalizations.of(context)!; - switch (snapshot.data) { - case UserTaskState.enqueued: - return StreamBuilder( - stream: widget.mediaUserTask.stateEvents, - initialData: UserTaskState.enqueued, - builder: - (context, AsyncSnapshot snapshot) { - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( + canPop: true, + child: Scaffold( + body: SafeArea( + child: StreamBuilder( + stream: widget.mediaUserTask.stateEvents, + initialData: UserTaskState.enqueued, + builder: (context, AsyncSnapshot snapshot) { + RPLocalizations locale = RPLocalizations.of(context)!; + switch (snapshot.data) { + case UserTaskState.enqueued: + return StreamBuilder( + stream: widget.mediaUserTask.stateEvents, + initialData: UserTaskState.enqueued, + builder: (context, AsyncSnapshot snapshot) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + child: const CarpAppBar(hasProfileIcon: false), + ), + Spacer(), + IconButton( + color: Theme.of(context).extension()!.grey900!, + onPressed: () { + _showCancelConfirmationDialog(); + }, + icon: const Icon(Icons.close, size: 30), + ), + ], + ), + Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: const Image(image: AssetImage('assets/icons/camera.png'), width: 220, height: 220), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text(locale.translate(widget.mediaUserTask.title), style: fs22fw700), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + child: Text(locale.translate(widget.mediaUserTask.description), style: fs16fw600), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 10), - child: const CarpAppBar( - hasProfileIcon: false, - ), - ), - Spacer(), - IconButton( - color: Theme.of(context) - .extension()! - .grey900!, + OutlinedButton( onPressed: () { - _showCancelConfirmationDialog(); + Navigator.pop(context); }, - icon: const Icon( - Icons.close, - size: 30, - ), + child: Text(locale.translate("Cancel")), ), - ], - ), - Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: 30), - child: const Image( - image: AssetImage( - 'assets/icons/camera.png'), - width: 220, - height: 220), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 12), - child: Text( - locale.translate( - widget.mediaUserTask.title), - style: audioTitleStyle, + ElevatedButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CameraPage(videoUserTask: widget.mediaUserTask), + ), ), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 12, horizontal: 20), - child: Text( - locale.translate( - widget.mediaUserTask.description), - style: audioContentStyle, - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 30), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton( - onPressed: () { - Navigator.pop(context); - }, - child: - Text(locale.translate("Cancel")), - ), - ElevatedButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CameraPage( - videoUserTask: - widget.mediaUserTask, - ), - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context) - .extension()! - .primary, - padding: const EdgeInsets.symmetric( - horizontal: 30, - vertical: 12, - ), - ), - child: Text( - locale.translate("next"), - style: TextStyle( - color: Colors.white, - ), - ), - ), - ], + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).extension()!.primary, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), + child: Text(locale.translate("next"), style: TextStyle(color: Colors.white)), ), ], ), - ], - ); - }, - ); - default: - return const SizedBox.shrink(); - } - }), - ), + ), + ], + ), + ], + ); + }, + ); + default: + return const SizedBox.shrink(); + } + }, ), - ); + ), + ), + ); Future _showCancelConfirmationDialog() { RPLocalizations locale = RPLocalizations.of(context)!; @@ -168,7 +126,7 @@ class CameraTaskPageState extends State { // Exit the Ordered Task context.canPop() ? context.pop() : null; }, - ) + ), ], ); }, diff --git a/lib/ui/tasks/display_picture_page.dart b/lib/ui/tasks/display_picture_page.dart index bd7df449..43d87acf 100644 --- a/lib/ui/tasks/display_picture_page.dart +++ b/lib/ui/tasks/display_picture_page.dart @@ -5,11 +5,7 @@ class DisplayPicturePage extends StatefulWidget { final bool isVideo; final VideoUserTask videoUserTask; - const DisplayPicturePage( - {super.key, - required this.file, - required this.videoUserTask, - this.isVideo = false}); + const DisplayPicturePage({super.key, required this.file, required this.videoUserTask, this.isVideo = false}); @override State createState() => DisplayPicturePageState(); @@ -52,22 +48,16 @@ class DisplayPicturePageState extends State { Row( children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), - child: const CarpAppBar( - hasProfileIcon: false, - ), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + child: const CarpAppBar(hasProfileIcon: false), ), Spacer(), IconButton( - color: Theme.of(context).extension()!.grey900!, + color: Theme.of(context).extension()!.grey900!, onPressed: () { _showCancelConfirmationDialog(); }, - icon: const Icon( - Icons.close, - size: 30, - ), + icon: const Icon(Icons.close, size: 30), ), ], ), @@ -80,11 +70,11 @@ class DisplayPicturePageState extends State { width: MediaQuery.of(context).size.width * 0.5, child: (widget.isVideo && _videoPlayerController != null) ? _videoPlayerController!.value.isInitialized - ? AspectRatio( - aspectRatio: - _videoPlayerController!.value.aspectRatio, - child: VideoPlayer(_videoPlayerController!)) - : const CircularProgressIndicator() + ? AspectRatio( + aspectRatio: _videoPlayerController!.value.aspectRatio, + child: VideoPlayer(_videoPlayerController!), + ) + : const CircularProgressIndicator() : Image.file(File(videoFilePath)), ), ), @@ -96,16 +86,12 @@ class DisplayPicturePageState extends State { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: Text(locale.translate('pages.audio_task.done'), - style: audioTitleStyle), + child: Text(locale.translate('pages.audio_task.done'), style: fs22fw700), ), const SizedBox(height: 40), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: Text( - locale.translate('pages.audio_task.recording_completed'), - style: audioContentStyle, - ), + child: Text(locale.translate('pages.audio_task.recording_completed'), style: fs16fw600), ), const SizedBox(height: 20), Align( @@ -119,8 +105,7 @@ class DisplayPicturePageState extends State { IconButton( onPressed: () => Navigator.of(context).pop(), padding: const EdgeInsets.all(0), - icon: const Icon(Icons.replay, - size: 25, color: CACHET.GREY_5), + icon: const Icon(Icons.replay, size: 25, color: CACHET.GREY_5), ), const SizedBox(width: 20), CircleAvatar( @@ -134,8 +119,7 @@ class DisplayPicturePageState extends State { Navigator.of(context).pop(); }, padding: const EdgeInsets.all(0), - icon: const Icon(Icons.check_circle_outline, - color: Colors.white, size: 30), + icon: const Icon(Icons.check_circle_outline, color: Colors.white, size: 30), ), ), const SizedBox(width: 50), @@ -162,10 +146,7 @@ class DisplayPicturePageState extends State { return AlertDialog( title: Text(locale.translate("pages.audio_task.discard")), actions: [ - TextButton( - child: Text(locale.translate("NO")), - onPressed: () => Navigator.of(context).pop(), - ), + TextButton(child: Text(locale.translate("NO")), onPressed: () => Navigator.of(context).pop()), TextButton( child: Text(locale.translate("YES")), onPressed: () { @@ -176,7 +157,7 @@ class DisplayPicturePageState extends State { Navigator.of(context).pop(); Navigator.of(context).pop(); }, - ) + ), ], ); }, diff --git a/lib/ui/tasks/participant_data_page.dart b/lib/ui/tasks/participant_data_page.dart index c3397326..109638bb 100644 --- a/lib/ui/tasks/participant_data_page.dart +++ b/lib/ui/tasks/participant_data_page.dart @@ -1,14 +1,6 @@ part of carp_study_app; -enum ParticipantStep { - presentTypes, - address, - diagnosis, - fullName, - phoneNumber, - socialSecurityNumber, - review -} +enum ParticipantStep { presentTypes, address, diagnosis, fullName, phoneNumber, socialSecurityNumber, review } class ParticipantDataPage extends StatefulWidget { static const String route = '/participant_data'; @@ -81,8 +73,7 @@ class ParticipantDataPageState extends State { widget.model._lastNameFocusNode = FocusNode(); for (final key in _stepMap.keys) { - if (widget.model.expectedData.any( - (dataType) => dataType!.attribute!.inputDataType.contains(key))) { + if (widget.model.expectedData.any((dataType) => dataType!.attribute!.inputDataType.contains(key))) { _includedSteps.add(_stepMap[key]!); } } @@ -187,10 +178,7 @@ class ParticipantDataPageState extends State { controller: widget.model._phoneNumberController, ); - widget.model.ssnField = StepField( - title: "tasks.participant_data.ssn.ssn", - controller: widget.model._ssnController, - ); + widget.model.ssnField = StepField(title: "tasks.participant_data.ssn.ssn", controller: widget.model._ssnController); } @override @@ -231,7 +219,8 @@ class ParticipantDataPageState extends State { setState(() { switch (currentStep) { case ParticipantStep.address: - _nextEnabled = widget.model._address1Controller.text.isNotEmpty && + _nextEnabled = + widget.model._address1Controller.text.isNotEmpty && widget.model._streetController.text.isNotEmpty && widget.model._postalCodeController.text.isNotEmpty && widget.model._countryController.text.isNotEmpty; @@ -239,12 +228,12 @@ class ParticipantDataPageState extends State { case ParticipantStep.diagnosis: _nextEnabled = widget.model._effectiveDateController.text.isNotEmpty && - widget.model._icd11CodeController.text.isNotEmpty && - widget.model._conclusionController.text.isNotEmpty; + widget.model._icd11CodeController.text.isNotEmpty && + widget.model._conclusionController.text.isNotEmpty; break; case ParticipantStep.fullName: - _nextEnabled = widget.model._firstNameController.text.isNotEmpty && - widget.model._lastNameController.text.isNotEmpty; + _nextEnabled = + widget.model._firstNameController.text.isNotEmpty && widget.model._lastNameController.text.isNotEmpty; break; case ParticipantStep.phoneNumber: _nextEnabled = widget.model._phoneNumberController.text.isNotEmpty; @@ -266,7 +255,7 @@ class ParticipantDataPageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray!, + backgroundColor: Theme.of(context).extension()!.backgroundGray!, body: SafeArea( child: Container( padding: const EdgeInsets.all(16.0), @@ -275,22 +264,16 @@ class ParticipantDataPageState extends State { Row( children: [ Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 10), - child: const CarpAppBar( - hasProfileIcon: false, - ), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + child: const CarpAppBar(hasProfileIcon: false), ), Spacer(), IconButton( - color: Theme.of(context).extension()!.grey900!, + color: Theme.of(context).extension()!.grey900!, onPressed: () { _showCancelConfirmationDialog(); }, - icon: const Icon( - Icons.close, - size: 30, - ), + icon: const Icon(Icons.close, size: 30), ), ], ), @@ -304,15 +287,11 @@ class ParticipantDataPageState extends State { Expanded( child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: SizedBox( - child: _buildStepContent( - locale, widget.model.expectedData), - ), + child: SizedBox(child: _buildStepContent(locale, widget.model.expectedData)), ), ), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: _buildActionButtons(locale), @@ -332,20 +311,13 @@ class ParticipantDataPageState extends State { /// Builds the title of the dialog based on the current step. Widget _buildDialogTitle(RPLocalizations locale) { final stepTitleMap = { - ParticipantStep.presentTypes: - locale.translate("tasks.participant_data.present_data.title"), - ParticipantStep.address: - locale.translate("tasks.participant_data.address.title"), - ParticipantStep.diagnosis: - locale.translate("tasks.participant_data.diagnosis.title"), - ParticipantStep.fullName: - locale.translate("tasks.participant_data.full_name.title"), - ParticipantStep.phoneNumber: - locale.translate("tasks.participant_data.phone_number.title"), - ParticipantStep.socialSecurityNumber: - locale.translate("tasks.participant_data.ssn.title"), - ParticipantStep.review: - locale.translate("tasks.participant_data.review.title"), + ParticipantStep.presentTypes: locale.translate("tasks.participant_data.present_data.title"), + ParticipantStep.address: locale.translate("tasks.participant_data.address.title"), + ParticipantStep.diagnosis: locale.translate("tasks.participant_data.diagnosis.title"), + ParticipantStep.fullName: locale.translate("tasks.participant_data.full_name.title"), + ParticipantStep.phoneNumber: locale.translate("tasks.participant_data.phone_number.title"), + ParticipantStep.socialSecurityNumber: locale.translate("tasks.participant_data.ssn.title"), + ParticipantStep.review: locale.translate("tasks.participant_data.review.title"), }; return Padding( padding: const EdgeInsets.only(bottom: 16), @@ -357,9 +329,7 @@ class ParticipantDataPageState extends State { Flexible( child: Text( stepTitleMap[currentStep] ?? '', - style: healthServiceConnectMessageStyle.copyWith( - color: Theme.of(context).primaryColor, - ), + style: fs22fw700.copyWith(color: Theme.of(context).primaryColor), textAlign: TextAlign.center, ), ), @@ -370,19 +340,18 @@ class ParticipantDataPageState extends State { } /// Builds the content of the current step based on the [_includedSteps]. - Widget _buildStepContent( - RPLocalizations locale, Set expectedData) { + Widget _buildStepContent(RPLocalizations locale, Set expectedData) { List fields = []; switch (currentStep) { case ParticipantStep.presentTypes: - fields.add(_buildPresentTypes( - _includedSteps - .where((step) => - step != ParticipantStep.presentTypes && - step != ParticipantStep.review) - .map((step) => participantStepDescriptions[step]) - .toList(), - )); + fields.add( + _buildPresentTypes( + _includedSteps + .where((step) => step != ParticipantStep.presentTypes && step != ParticipantStep.review) + .map((step) => participantStepDescriptions[step]) + .toList(), + ), + ); break; case ParticipantStep.address: fields.addAll([ @@ -395,10 +364,8 @@ class ParticipantDataPageState extends State { break; case ParticipantStep.diagnosis: fields.addAll([ - _buildField(locale, widget.model.effectiveDateField, - isDatePicker: true), - _buildField(locale, widget.model.diagnosisDescriptionField, - isOptional: true), + _buildField(locale, widget.model.effectiveDateField, isDatePicker: true), + _buildField(locale, widget.model.diagnosisDescriptionField, isOptional: true), _buildField(locale, widget.model.icd11CodeField), _buildField(locale, widget.model.conclusionField, isThicc: true), ]); @@ -411,25 +378,18 @@ class ParticipantDataPageState extends State { ]); break; case ParticipantStep.phoneNumber: - fields.add(_buildField(locale, widget.model.phoneNumberField, - isPhoneNumber: true)); + fields.add(_buildField(locale, widget.model.phoneNumberField, isPhoneNumber: true)); break; case ParticipantStep.socialSecurityNumber: fields.add(_buildField(locale, widget.model.ssnField, isCPR: true)); break; case ParticipantStep.review: - fields.add(_buildReviewStep( - locale, - _allUsedStepFields, - )); + fields.add(_buildReviewStep(locale, _allUsedStepFields)); break; } return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: fields, - ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: fields), ); } @@ -443,11 +403,7 @@ class ParticipantDataPageState extends State { child: Text( "\u2022 ${step ?? ''}", textAlign: TextAlign.start, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.4, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.4), ), ); }).toList(), @@ -462,14 +418,10 @@ class ParticipantDataPageState extends State { final String field = fields.elementAt(index).title; String input = ""; if (index < fields.length) { - if (fields.elementAt(index).controller == - widget.model._phoneNumberController) { - input = - "${widget.model._phoneNumberCodeController.text} ${fields.elementAt(index).controller.text}"; - } else if (fields.elementAt(index).controller == - widget.model._ssnController) { - input = - "${widget.model._ssnCountryController.text} ${fields.elementAt(index).controller.text}"; + if (fields.elementAt(index).controller == widget.model._phoneNumberController) { + input = "${widget.model._phoneNumberCodeController.text} ${fields.elementAt(index).controller.text}"; + } else if (fields.elementAt(index).controller == widget.model._ssnController) { + input = "${widget.model._ssnCountryController.text} ${fields.elementAt(index).controller.text}"; } else { input = fields.elementAt(index).controller.text; } @@ -482,20 +434,9 @@ class ParticipantDataPageState extends State { children: [ Text( locale.translate(field), - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.4, - ), - ), - Text( - input, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.4, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.4), ), + Text(input, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.4)), ], ), ); @@ -519,21 +460,16 @@ class ParticipantDataPageState extends State { if (isPhoneNumber) { return InternationalPhoneNumberInput( onInputChanged: (phoneNumber) { - widget.model._phoneNumberCodeController.text = - phoneNumber.dialCode ?? ''; + widget.model._phoneNumberCodeController.text = phoneNumber.dialCode ?? ''; }, textFieldController: stepField.controller, - selectorConfig: SelectorConfig( - selectorType: PhoneInputSelectorType.DIALOG, - useBottomSheetSafeArea: true, - ), + selectorConfig: SelectorConfig(selectorType: PhoneInputSelectorType.DIALOG, useBottomSheetSafeArea: true), inputDecoration: _buildInputDecoration(locale, stepField, isThicc), ignoreBlank: false, autoValidateMode: AutovalidateMode.disabled, selectorTextStyle: TextStyle(color: Colors.black), formatInput: true, - keyboardType: - TextInputType.numberWithOptions(signed: true, decimal: true), + keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true), inputBorder: OutlineInputBorder(), ); } else if (isCPR) { @@ -547,26 +483,20 @@ class ParticipantDataPageState extends State { width: 125, child: Container( decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).extension()!.grey600!, - width: 1.0, - ), + border: Border.all(color: Theme.of(context).extension()!.grey600!, width: 1.0), borderRadius: BorderRadius.circular(16.0), ), child: CountryCodePicker( onChanged: (value) { stepField.controller.clear(); - widget.model._ssnCountryController.text = - value.code ?? ''; + widget.model._ssnCountryController.text = value.code ?? ''; stepField.controller.text = stepField.controller.text; }, initialSelection: 'DK', showCountryOnly: true, showOnlyCountryWhenClosed: true, alignLeft: false, - textStyle: audioContentStyle.copyWith( - color: Theme.of(context).extension()!.grey900!, - ), + textStyle: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900!), ), ), ), @@ -589,88 +519,100 @@ class ParticipantDataPageState extends State { Padding( padding: const EdgeInsets.only(bottom: 16), child: TextFormField( - controller: stepField.controller, - focusNode: stepField.focusNode, - textInputAction: TextInputAction.next, - onFieldSubmitted: (_) { - if (stepField.nextFocusNode != null) { - FocusScope.of(context) - .requestFocus(stepField.nextFocusNode); - } - }, - onTap: isDatePicker - ? () async { - DateTime? pickedDate = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime(1900), - lastDate: DateTime.now(), - ); - if (pickedDate != null) { - stepField.controller.text = - "${pickedDate.toLocal()}".split(' ')[0]; - } + controller: stepField.controller, + focusNode: stepField.focusNode, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) { + if (stepField.nextFocusNode != null) { + FocusScope.of(context).requestFocus(stepField.nextFocusNode); + } + }, + onTap: isDatePicker + ? () async { + DateTime? pickedDate = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + if (pickedDate != null) { + stepField.controller.text = "${pickedDate.toLocal()}".split(' ')[0]; } - : null, - keyboardType: TextInputType.multiline, - maxLines: isThicc ? null : 1, - decoration: _buildInputDecoration(locale, stepField, isThicc)), + } + : null, + keyboardType: TextInputType.multiline, + maxLines: isThicc ? null : 1, + decoration: _buildInputDecoration(locale, stepField, isThicc), + ), ), ], ); } } - InputDecoration _buildInputDecoration( - RPLocalizations locale, StepField stepField, bool isThicc) { + InputDecoration _buildInputDecoration(RPLocalizations locale, StepField stepField, bool isThicc) { return InputDecoration( - labelText: locale.translate(stepField.title), - floatingLabelBehavior: FloatingLabelBehavior.always, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.blue), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.blue, width: 2), - ), - contentPadding: - EdgeInsets.symmetric(horizontal: 16, vertical: isThicc ? 70 : 12)); + labelText: locale.translate(stepField.title), + floatingLabelBehavior: FloatingLabelBehavior.always, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.blue), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.blue, width: 2), + ), + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: isThicc ? 70 : 12), + ); } /// Builds the action buttons at the bottom of the page. /// Includes "Cancel", "Previous", "Next", and "Submit" buttons. /// The "Next" button is enabled only if the required fields for the current step are filled. List _buildActionButtons(RPLocalizations locale) { - Widget buildTranslatedButton(String key, VoidCallback onPressed, - bool enabled, ButtonStyle? buttonStyle, TextStyle? buttonTextStyle) { + Widget buildTranslatedButton( + String key, + VoidCallback onPressed, + bool enabled, + ButtonStyle? buttonStyle, + TextStyle? buttonTextStyle, + ) { return ElevatedButton( onPressed: enabled ? onPressed : null, - child: Text( - locale.translate(key).toUpperCase(), - style: buttonTextStyle, - ), + child: Text(locale.translate(key).toUpperCase(), style: buttonTextStyle), style: buttonStyle, ); } return [ currentStep == ParticipantStep.presentTypes - ? buildTranslatedButton("cancel", () { - context.pop(); - }, true, null, null) - : buildTranslatedButton("previous", () { - setState(() { - final idx = _includedSteps.indexOf(currentStep); - if (currentStep.index - 1 >= 0) { - currentStep = _includedSteps[idx - 1]; - } - }); - }, true, null, null), + ? buildTranslatedButton( + "cancel", + () { + context.pop(); + }, + true, + null, + null, + ) + : buildTranslatedButton( + "previous", + () { + setState(() { + final idx = _includedSteps.indexOf(currentStep); + if (currentStep.index - 1 >= 0) { + currentStep = _includedSteps[idx - 1]; + } + }); + }, + true, + null, + null, + ), currentStep.index == ParticipantStep.values.length - 1 ? buildTranslatedButton( "submit", @@ -680,14 +622,10 @@ class ParticipantDataPageState extends State { }, _nextEnabled, ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).extension()!.primary, - padding: - const EdgeInsets.symmetric(horizontal: 30, vertical: 12), - ), - TextStyle( - color: Colors.white, + backgroundColor: Theme.of(context).extension()!.primary, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), + TextStyle(color: Colors.white), ) : buildTranslatedButton( "next", @@ -701,14 +639,10 @@ class ParticipantDataPageState extends State { }, currentStep == ParticipantStep.presentTypes ? true : _nextEnabled, ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).extension()!.primary, - padding: - const EdgeInsets.symmetric(horizontal: 30, vertical: 12), - ), - TextStyle( - color: Colors.white, + backgroundColor: Theme.of(context).extension()!.primary, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), + TextStyle(color: Colors.white), ), ]; } @@ -721,7 +655,7 @@ class ParticipantDataPageState extends State { final Map> participantStepToDataType = { ParticipantStep.address: { - AddressInput.type: AddressInput( + InputType.ADDRESS: AddressInput( address1: widget.model._address1Controller.text, address2: widget.model._address2Controller.text, street: widget.model._streetController.text, @@ -730,12 +664,11 @@ class ParticipantDataPageState extends State { ), }, ParticipantStep.diagnosis: { - DiagnosisInput.type: DiagnosisInput( + InputType.DIAGNOSIS: DiagnosisInput( effectiveDate: widget.model._effectiveDateController.text.isNotEmpty - ? DateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") - .parse( - '${widget.model._effectiveDateController.text}T00:00:00Z') - .toUtc() + ? DateFormat( + "yyyy-MM-dd'T'HH:mm:ss'Z'", + ).parse('${widget.model._effectiveDateController.text}T00:00:00Z').toUtc() : null, diagnosis: widget.model._diagnosisDescriptionController.text, icd11Code: widget.model._icd11CodeController.text, @@ -743,20 +676,20 @@ class ParticipantDataPageState extends State { ), }, ParticipantStep.fullName: { - FullNameInput.type: FullNameInput( + InputType.FULL_NAME: FullNameInput( firstName: widget.model._firstNameController.text, middleName: widget.model._middleNameController.text, lastName: widget.model._lastNameController.text, ), }, ParticipantStep.phoneNumber: { - PhoneNumberInput.type: PhoneNumberInput( + InputType.PHONE_NUMBER: PhoneNumberInput( countryCode: widget.model._phoneNumberCodeController.text, number: widget.model._phoneNumberController.text, ), }, ParticipantStep.socialSecurityNumber: { - SocialSecurityNumberInput.type: SocialSecurityNumberInput( + InputType.SSN: SocialSecurityNumberInput( country: widget.model._ssnCountryController.text, socialSecurityNumber: widget.model._ssnController.text, ), @@ -769,12 +702,7 @@ class ParticipantDataPageState extends State { } } - bloc.setParticipantData( - bloc.study!.studyDeploymentId, - participantData, - bloc.study!.participantRoleName, - ); - LocalSettings().hasSeenConnectionInstructions = true; + widget.model.setParticipantData(participantData); } Future _showCancelConfirmationDialog() { @@ -799,7 +727,7 @@ class ParticipantDataPageState extends State { context.pop(); context.pop(); }, - ) + ), ], ); }, @@ -813,10 +741,5 @@ class StepField { final FocusNode? focusNode; final FocusNode? nextFocusNode; - StepField({ - required this.title, - required this.controller, - this.focusNode, - this.nextFocusNode, - }); + StepField({required this.title, required this.controller, this.focusNode, this.nextFocusNode}); } diff --git a/lib/ui/widgets/battery_icon.dart b/lib/ui/widgets/battery_icon.dart index 1a3dae64..75a2a9a6 100644 --- a/lib/ui/widgets/battery_icon.dart +++ b/lib/ui/widgets/battery_icon.dart @@ -1,12 +1,8 @@ part of carp_study_app; class BatteryPercentage extends StatelessWidget { - const BatteryPercentage({ - super.key, - required this.batteryLevel, - this.scale = 1.0, - }) : assert(batteryLevel >= 0 && batteryLevel <= 100, - 'Battery level must be between 0 and 100'); + const BatteryPercentage({super.key, required this.batteryLevel, this.scale = 1.0}) + : assert(batteryLevel >= 0 && batteryLevel <= 100, 'Battery level must be between 0 and 100'); // Battery level from 0 to 100 final int batteryLevel; @@ -17,36 +13,43 @@ class BatteryPercentage extends StatelessWidget { Widget build(BuildContext context) { double width = 25 * scale; double height = 12 * scale; - return Row(mainAxisSize: MainAxisSize.min, children: [ - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(2)), - child: Container( - decoration: BoxDecoration( + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(2)), + child: Container( + decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondary, - border: Border.all(color: Theme.of(context).primaryColor)), - width: width, - height: height, - child: Row(children: [ - SizedBox( - width: - batteryLevel != 0 ? batteryLevel * (width * 0.9 / 100) : 0, - height: height * 0.75, - child: Container(color: Theme.of(context).primaryColor)), - ]), + border: Border.all(color: Theme.of(context).primaryColor), + ), + width: width, + height: height, + child: Row( + children: [ + SizedBox( + width: batteryLevel != 0 ? batteryLevel * (width * 0.9 / 100) : 0, + height: height * 0.75, + child: Container(color: Theme.of(context).primaryColor), + ), + ], + ), + ), ), - ), - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(4)), - child: Container( - decoration: BoxDecoration( + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Container( + decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondary, - border: Border.all(color: Theme.of(context).primaryColor)), - width: 2, - height: 4, + border: Border.all(color: Theme.of(context).primaryColor), + ), + width: 2, + height: 4, + ), ), - ), - const SizedBox(width: 4), - Text("$batteryLevel%") - ]); + const SizedBox(width: 4), + Text("$batteryLevel%"), + ], + ); } } diff --git a/lib/ui/widgets/carp_app_bar.dart b/lib/ui/widgets/carp_app_bar.dart index b7598380..38719d87 100644 --- a/lib/ui/widgets/carp_app_bar.dart +++ b/lib/ui/widgets/carp_app_bar.dart @@ -16,31 +16,20 @@ class CarpAppBar extends StatelessWidget { children: [ Container( padding: EdgeInsets.only(left: 8), - child: Image.asset( - 'assets/carp_logo.png', - fit: BoxFit.contain, - height: 16, - ), + child: Image.asset('assets/carp_logo.png', fit: BoxFit.contain, height: 16), ), if (hasProfileIcon) IconButton( - icon: Icon( - Icons.account_circle, - color: Theme.of(context).primaryColor, - size: 30, - ), + icon: Icon(Icons.account_circle, color: Theme.of(context).primaryColor, size: 30), tooltip: 'Profile', onPressed: () { - Navigator.push( - context, - SlidePageRoute( - ProfilePage(ProfilePageViewModel()))); + Navigator.push(context, SlidePageRoute(ProfilePage(ProfilePageViewModel()))); }, ), ], ), ], - ) + ), ], ), ); diff --git a/lib/ui/widgets/charts_legend.dart b/lib/ui/widgets/charts_legend.dart index 1c17583f..1b4434c8 100644 --- a/lib/ui/widgets/charts_legend.dart +++ b/lib/ui/widgets/charts_legend.dart @@ -7,13 +7,14 @@ class ChartsLegend extends StatelessWidget { final String? heroTag; final List colors; - const ChartsLegend( - {super.key, - this.heroTag, - this.iconAssetName, - required this.title, - this.values = const [], - required this.colors}); + const ChartsLegend({ + super.key, + this.heroTag, + this.iconAssetName, + required this.title, + this.values = const [], + required this.colors, + }); @override Widget build(BuildContext context) { @@ -26,7 +27,7 @@ class ChartsLegend extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title.toUpperCase(), style: dataCardTitleStyle), + Text(title.toUpperCase(), style: fs16fw400ls1), Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Row( @@ -37,9 +38,8 @@ class ChartsLegend extends StatelessWidget { (entry) => Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - Icon(Icons.circle, - color: colors[entry.key], size: 12.0), - Text(' ${entry.value} ', style: legendStyle), + Icon(Icons.circle, color: colors[entry.key], size: 12.0), + Text(' ${entry.value} ', style: fs12fw400), ], ), ) diff --git a/lib/ui/widgets/details_banner.dart b/lib/ui/widgets/details_banner.dart index d0f4c06b..4f0d3196 100644 --- a/lib/ui/widgets/details_banner.dart +++ b/lib/ui/widgets/details_banner.dart @@ -6,20 +6,14 @@ class DetailsBanner extends StatelessWidget { final String? imagePath; @override - Widget build( - BuildContext context, - ) { + Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Column( mainAxisAlignment: MainAxisAlignment.start, children: [ // only show this widget if there is an image = imagePath is not null and not empty if (imagePath != null && imagePath!.isNotEmpty) - SizedBox( - height: 300, - child: - bloc.appViewModel.studyPageViewModel.getMessageImage(imagePath), - ), + SizedBox(height: 300, child: bloc.appViewModel.studyPageViewModel.getMessageImage(imagePath)), Padding( padding: const EdgeInsets.all(16), child: Stack( @@ -29,8 +23,7 @@ class DetailsBanner extends StatelessWidget { children: [ Text( locale.translate(title), - style: studyNameStyle.copyWith( - fontSize: 30, color: Theme.of(context).primaryColor), + style: fs30fw800.copyWith(fontSize: 30, color: Theme.of(context).primaryColor), ), ], ), diff --git a/lib/ui/widgets/dialog_title.dart b/lib/ui/widgets/dialog_title.dart index 884f2155..29f1d4d6 100644 --- a/lib/ui/widgets/dialog_title.dart +++ b/lib/ui/widgets/dialog_title.dart @@ -5,8 +5,7 @@ class DialogTitle extends StatelessWidget { final String? deviceName; final String? titleEnd; - const DialogTitle( - {super.key, required this.title, this.deviceName, this.titleEnd}); + const DialogTitle({super.key, required this.title, this.deviceName, this.titleEnd}); @override Widget build(BuildContext context) { @@ -14,17 +13,14 @@ class DialogTitle extends StatelessWidget { return _buildDialogTitle(locale, title, context); } - Widget _buildDialogTitle( - RPLocalizations locale, String title, BuildContext context) { + Widget _buildDialogTitle(RPLocalizations locale, String title, BuildContext context) { return Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( - onPressed: () => Navigator.of(context).canPop() - ? Navigator.of(context).pop() - : null, + onPressed: () => Navigator.of(context).canPop() ? Navigator.of(context).pop() : null, icon: const Icon(Icons.close), padding: const EdgeInsets.only(right: 8), ), @@ -39,18 +35,10 @@ class DialogTitle extends StatelessWidget { children: [ Flexible( child: Text( - locale.translate( - title, - ) + - (deviceName != null - ? " ${locale.translate(deviceName!)} " - : "") + - (titleEnd != null - ? ' ${locale.translate(titleEnd!)}' - : ""), - style: sectionTitleStyle.copyWith( - color: Theme.of(context).primaryColor, - ), + locale.translate(title) + + (deviceName != null ? " ${locale.translate(deviceName!)} " : "") + + (titleEnd != null ? ' ${locale.translate(titleEnd!)}' : ""), + style: fs18fw700.copyWith(color: Theme.of(context).primaryColor), textAlign: TextAlign.center, ), ), diff --git a/lib/ui/widgets/horizontal_bar.dart b/lib/ui/widgets/horizontal_bar.dart index 89e696b4..415a83bb 100644 --- a/lib/ui/widgets/horizontal_bar.dart +++ b/lib/ui/widgets/horizontal_bar.dart @@ -23,10 +23,7 @@ class HorizontalBar extends StatelessWidget { List assetList() { List assetList = []; for (int i = 0; i < names.length; i++) { - assetList.add(MyAsset( - size: values.elementAt(i), - color: colors.elementAt(i), - name: names.elementAt(i))); + assetList.add(MyAsset(size: values.elementAt(i), color: colors.elementAt(i), name: names.elementAt(i))); } return assetList; } @@ -42,8 +39,7 @@ class HorizontalBar extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.all(Radius.circular(height / 2)), child: Container( - decoration: - BoxDecoration(color: Theme.of(context).colorScheme.tertiary), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.tertiary), width: width, height: height, child: const SizedBox.shrink(), @@ -135,9 +131,7 @@ class MyAssetsBar extends StatelessWidget { //single.size : assetsSum = x : width Widget _createSingle(MyAsset singleAsset) { return SizedBox( - width: singleAsset.size! != 0 - ? singleAsset.size! * (width / _getValuesSum()) - : 0, + width: singleAsset.size! != 0 ? singleAsset.size! * (width / _getValuesSum()) : 0, child: Container(color: singleAsset.color), ); } @@ -151,16 +145,15 @@ class MyAssetsBar extends StatelessWidget { .entries .map( (entry) => Padding( - padding: - const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Icon(Icons.circle, color: entry.value.color, size: 12.0), - Text(' ${entry.value.name!} ${entry.value.size}', - style: legendStyle, textAlign: TextAlign.right), - ], - )), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.circle, color: entry.value.color, size: 12.0), + Text(' ${entry.value.name!} ${entry.value.size}', style: fs12fw400, textAlign: TextAlign.right), + ], + ), + ), ) .toList(), ); @@ -180,21 +173,23 @@ class MyAssetsBar extends StatelessWidget { .entries .map( (entry) => Padding( - padding: - const EdgeInsets.symmetric(vertical: 3, horizontal: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Icon(Icons.circle, color: entry.value.color, size: 12.0), - Text(' ${entry.value.size}', - style: legendStyle, textAlign: TextAlign.left), - Expanded( - child: Text(' ${entry.value.name!}', - style: legendStyle, - textAlign: TextAlign.left, - overflow: TextOverflow.ellipsis)), - ], - )), + padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.circle, color: entry.value.color, size: 12.0), + Text(' ${entry.value.size}', style: fs12fw400, textAlign: TextAlign.left), + Expanded( + child: Text( + ' ${entry.value.name!}', + style: fs12fw400, + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), ) .toList(), ); @@ -217,10 +212,7 @@ class MyAssetsBar extends StatelessWidget { decoration: BoxDecoration(color: background), width: width, height: height, - child: Row( - children: assets - .map((singleAsset) => _createSingle(singleAsset)) - .toList()), + child: Row(children: assets.map((singleAsset) => _createSingle(singleAsset)).toList()), ), ), _labelOrientation(), diff --git a/lib/ui/widgets/location_permission_page.dart b/lib/ui/widgets/location_permission_page.dart deleted file mode 100644 index 5732b9f8..00000000 --- a/lib/ui/widgets/location_permission_page.dart +++ /dev/null @@ -1,120 +0,0 @@ -part of carp_study_app; - -class LocationPermissionPage { - Widget build(BuildContext context, String message) { - RPLocalizations locale = RPLocalizations.of(context)!; - - return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, - body: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Text( - locale.translate('dialog.location.permission'), - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22.0, - ), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 16.0), - child: StudiesMaterial( - backgroundColor: - Theme.of(context).extension()!.white!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), - child: Padding( - padding: const EdgeInsets.only( - right: 24.0, - left: 24.0, - top: 16.0, - bottom: 16.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - Row( - children: [ - Text( - locale.translate( - 'dialog.location.location_data'), - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22.0, - color: Theme.of(context) - .extension()! - .primary, - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 24.0), - child: Icon( - Icons.location_on, - color: Theme.of(context) - .extension()! - .primary, - size: 48, - ), - ), - Text( - locale.translate(message), - style: aboutCardContentStyle.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.justify, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ), - // button to accept the invitation - Container( - margin: const EdgeInsets.only(left: 16, right: 16, bottom: 16), - height: 56, - decoration: BoxDecoration( - color: const Color(0xff006398), - borderRadius: BorderRadius.circular(100), - ), - child: TextButton( - onPressed: () { - Permission.locationWhenInUse - .request() - .then((value) => context.pop(true)); - }, - child: Text( - locale.translate("dialog.location.continue"), - style: - const TextStyle(color: Color(0xffffffff), fontSize: 22), - textAlign: TextAlign.center, - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/ui/widgets/location_usage_dialog.dart b/lib/ui/widgets/location_usage_dialog.dart index 35642004..84186a5a 100644 --- a/lib/ui/widgets/location_usage_dialog.dart +++ b/lib/ui/widgets/location_usage_dialog.dart @@ -15,8 +15,7 @@ class LocationUsageDialog { width: MediaQuery.of(context).size.width * 0.15, height: MediaQuery.of(context).size.height * 0.15, ), - Text(locale.translate("dialog.location.permission"), - style: aboutCardTitleStyle), + Text(locale.translate("dialog.location.permission"), style: fs20fw700), ], ), contentPadding: const EdgeInsets.all(15), @@ -25,32 +24,20 @@ class LocationUsageDialog { child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, - children: [ - Text( - locale.translate(message), - style: aboutCardContentStyle, - textAlign: TextAlign.justify, - ), - ], + children: [Text(locale.translate(message), style: fs16fw400, textAlign: TextAlign.justify)], ), ), ), actions: [ ElevatedButton( onPressed: () { - Permission.locationWhenInUse - .request() - .then((value) => context.pop(true)); + Permission.locationWhenInUse.request().then((value) => context.pop(true)); }, style: ButtonStyle( - backgroundColor: - WidgetStateProperty.all(Theme.of(context).primaryColor), - foregroundColor: WidgetStateProperty.all( - Theme.of(context).colorScheme.onPrimary), - ), - child: Text( - locale.translate("dialog.location.continue"), + backgroundColor: WidgetStateProperty.all(Theme.of(context).primaryColor), + foregroundColor: WidgetStateProperty.all(Theme.of(context).colorScheme.onPrimary), ), + child: Text(locale.translate("dialog.location.continue")), ), ], ); @@ -58,12 +45,7 @@ class LocationUsageDialog { return Scaffold( body: Column( crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - flex: 4, - child: locationUsageDialog, - ), - ], + children: [Expanded(flex: 4, child: locationUsageDialog)], ), ); } diff --git a/lib/ui/widgets/studies_material.dart b/lib/ui/widgets/studies_material.dart index 6c9060b0..322c2b35 100644 --- a/lib/ui/widgets/studies_material.dart +++ b/lib/ui/widgets/studies_material.dart @@ -17,9 +17,7 @@ class StudiesMaterial extends StatelessWidget { required this.child, this.elevation = 0, this.margin = const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - this.shape = const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), + this.shape = const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))), this.clipBehavior, this.hasBorder = false, this.hasBox = false, @@ -39,21 +37,14 @@ class StudiesMaterial extends StatelessWidget { child: Container( decoration: hasBorder ? BoxDecoration( - border: Border( - left: BorderSide( - color: borderColor, - width: 4.0, - ), - ), + border: Border(left: BorderSide(color: borderColor, width: 4.0)), ) : hasBox - ? BoxDecoration( - border: Border.all( - color: CACHET.LIGHT_2, - width: 1.0, - ), - borderRadius: BorderRadius.circular(16.0)) - : null, + ? BoxDecoration( + border: Border.all(color: CACHET.LIGHT_2, width: 1.0), + borderRadius: BorderRadius.circular(16.0), + ) + : null, child: child, ), ), diff --git a/lib/view_models/cards/activity_data_model.dart b/lib/view_models/cards/activity_data_model.dart index 4b7519b6..7a1d732d 100644 --- a/lib/view_models/cards/activity_data_model.dart +++ b/lib/view_models/cards/activity_data_model.dart @@ -1,21 +1,18 @@ part of carp_study_app; class ActivityCardViewModel extends SerializableViewModel { - Measurement _lastActivity = - Measurement.fromData(Activity(type: ActivityType.STILL, confidence: 100)); + Measurement _lastActivity = Measurement.fromData(Activity(type: ActivityType.STILL, confidence: 100)); @override WeeklyActivities createModel() => WeeklyActivities(); Map> get activities => model.activities; - List activitiesByType(ActivityType type) => - model.activitiesByType(type); + List activitiesByType(ActivityType type) => model.activitiesByType(type); /// Stream of activity measurements. - Stream? get activityEvents => controller?.measurements - .where((measurement) => measurement.data is Activity); + Stream? get activityEvents => + controller?.measurements.where((measurement) => measurement.data is Activity); - final DateTime _startOfWeek = - DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); + final DateTime _startOfWeek = DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); final DateTime _endOfWeek = DateTime.now() .subtract(Duration(days: DateTime.now().weekday - 1)) .add(Duration(days: 6)); @@ -24,31 +21,25 @@ class ActivityCardViewModel extends SerializableViewModel { String get endOfWeek => DateFormat('dd').format(_endOfWeek); - String get currentMonth => - DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); + String get currentMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); - String get nextMonth => DateFormat('MMM') - .format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); + String get nextMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); - String get currentYear => - DateFormat('yyyy').format(DateTime(DateTime.now().year)); + String get currentYear => DateFormat('yyyy').format(DateTime(DateTime.now().year)); @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); // listen for activity events and count the minutes activityEvents?.listen((measurement) { var lastActivity = _lastActivity; - if ((measurement.data as Activity).type != - (lastActivity.data as Activity).type) { + if ((measurement.data as Activity).type != (lastActivity.data as Activity).type) { // if we have a new type of activity // add the minutes to the last known activity type - DateTime start = - DateTime.fromMicrosecondsSinceEpoch(lastActivity.sensorStartTime); - DateTime end = - DateTime.fromMicrosecondsSinceEpoch(measurement.sensorStartTime); + DateTime start = DateTime.fromMicrosecondsSinceEpoch(lastActivity.sensorStartTime); + DateTime end = DateTime.fromMicrosecondsSinceEpoch(measurement.sensorStartTime); model.increaseActivityDuration( (lastActivity.data as Activity).type, start.weekday, @@ -73,10 +64,8 @@ class WeeklyActivities extends DataModel { Map> activities = {}; /// A list of activities of a specific [type]. - List activitiesByType(ActivityType type) => activities[type]! - .entries - .map((entry) => DailyActivity(entry.key, entry.value)) - .toList(); + List activitiesByType(ActivityType type) => + activities[type]!.entries.map((entry) => DailyActivity(entry.key, entry.value)).toList(); WeeklyActivities() { // initialize every week or if is the first time opening the app @@ -89,26 +78,22 @@ class WeeklyActivities extends DataModel { } /// Increase the number of minutes of doing [activityType] on [weekday] with [minutes]. - void increaseActivityDuration( - ActivityType activityType, - int weekday, - int minutes, - ) { - activities[activityType]![weekday] = - (activities[activityType]![weekday] ?? 0) + minutes; + void increaseActivityDuration(ActivityType activityType, int weekday, int minutes) { + activities[activityType]![weekday] = (activities[activityType]![weekday] ?? 0) + minutes; } @override - WeeklyActivities fromJson(Map json) => - _$WeeklyActivitiesFromJson(json); + WeeklyActivities fromJson(Map json) => _$WeeklyActivitiesFromJson(json); @override Map toJson() => _$WeeklyActivitiesToJson(this); @override String toString() { String str = ' TYPE\t| day | min.\n'; - activities.forEach((type, data) => data.forEach((day, minutes) => - str += '${type.toString().split(".").last}\t| $day | $minutes\n')); + activities.forEach( + (type, data) => + data.forEach((day, minutes) => str += '${type.toString().split(".").last}\t| $day | $minutes\n'), + ); return str; } } diff --git a/lib/view_models/cards/heart_rate_data_model.dart b/lib/view_models/cards/heart_rate_data_model.dart index 75ef5be6..48e935c0 100644 --- a/lib/view_models/cards/heart_rate_data_model.dart +++ b/lib/view_models/cards/heart_rate_data_model.dart @@ -11,8 +11,7 @@ class HeartRateCardViewModel extends SerializableViewModel { /// The current heart rate double? get currentHeartRate => model.currentHeartRate; - HeartRateMinMaxPrHour get dayMinMax => - HeartRateMinMaxPrHour(model.minHeartRate, model.maxHeartRate); + HeartRateMinMaxPrHour get dayMinMax => HeartRateMinMaxPrHour(model.minHeartRate, model.maxHeartRate); final StreamGroup _group = StreamGroup.broadcast(); @@ -21,9 +20,7 @@ class HeartRateCardViewModel extends SerializableViewModel { /// Stream of heart rate based on [PolarHR] measures. Stream? get polarHRStream => controller?.measurements .where((measurement) => measurement.data is PolarHR) - .map((measurement) => - (measurement.data as PolarHR).samples.firstOrNull?.hr.toDouble() ?? - 0); + .map((measurement) => (measurement.data as PolarHR).samples.firstOrNull?.hr.toDouble() ?? 0); /// Stream of heart rate based on [MovesenseHR] measures. Stream? get movesenseHRStream => controller?.measurements @@ -31,24 +28,22 @@ class HeartRateCardViewModel extends SerializableViewModel { .map((measurement) => (measurement.data as MovesenseHR).hr); @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); if (polarHRStream != null) _group.add(polarHRStream!); if (movesenseHRStream != null) _group.add(movesenseHRStream!); - heartRateStream?.listen( - (hr) { - if (!(hr > 0)) { - model.currentHeartRate = null; - return; - } - model.addHeartRate(DateTime.now().hour, hr); - if (hr > (model.maxHeartRate ?? 0)) model.maxHeartRate = hr; - if (hr < (model.minHeartRate ?? 100000)) model.minHeartRate = hr; - model.resetDataAtMidnight(); - }, - ); + heartRateStream?.listen((hr) { + if (!(hr > 0)) { + model.currentHeartRate = null; + return; + } + model.addHeartRate(DateTime.now().hour, hr); + if (hr > (model.maxHeartRate ?? 0)) model.maxHeartRate = hr; + if (hr < (model.minHeartRate ?? 100000)) model.minHeartRate = hr; + model.resetDataAtMidnight(); + }); } } @@ -124,14 +119,12 @@ class HourlyHeartRate extends DataModel { @override String toString() { String str = 'time | heart rate\n'; - hourlyHeartRate - .forEach((time, heartRate) => str += '$time | $heartRate\n'); + hourlyHeartRate.forEach((time, heartRate) => str += '$time | $heartRate\n'); return str; } @override - HourlyHeartRate fromJson(Map json) => - _$HourlyHeartRateFromJson(json); + HourlyHeartRate fromJson(Map json) => _$HourlyHeartRateFromJson(json); @override Map toJson() => _$HourlyHeartRateToJson(this); } @@ -146,17 +139,13 @@ class HeartRateMinMaxPrHour { @override String toString() => {'min': min, 'max': max}.toString(); - factory HeartRateMinMaxPrHour.fromJson(Map json) => - _$HeartRateMinMaxPrHourFromJson(json); + factory HeartRateMinMaxPrHour.fromJson(Map json) => _$HeartRateMinMaxPrHourFromJson(json); Map toJson() => _$HeartRateMinMaxPrHourToJson(this); @override bool operator ==(Object other) => identical(this, other) || - other is HeartRateMinMaxPrHour && - runtimeType == other.runtimeType && - min == other.min && - max == other.max; + other is HeartRateMinMaxPrHour && runtimeType == other.runtimeType && min == other.min && max == other.max; @override int get hashCode => min.hashCode ^ max.hashCode; diff --git a/lib/view_models/cards/measurements_data_model.dart b/lib/view_models/cards/measurements_data_model.dart index 5b95c201..d74a54d1 100644 --- a/lib/view_models/cards/measurements_data_model.dart +++ b/lib/view_models/cards/measurements_data_model.dart @@ -7,13 +7,11 @@ class MeasurementsCardViewModel extends ViewModel { Stream? get measureEvents => controller?.measurements; /// Stream of more quiet [DataPoint] measures. - Stream? get quietMeasureEvents => controller?.measurements - .where((measurement) => measurement.dataType.name != 'sensor'); + Stream? get quietMeasureEvents => + controller?.measurements.where((measurement) => measurement.dataType.name != 'sensor'); - /// The total sampling size - int get samplingSize => - controller?.samplingSize == null ? 0 : controller!.samplingSize; - // samplingTable.values.fold(0, (prev, element) => prev + element); + /// The total sampling size, derived from the per-type counts in [samplingTable]. + int get samplingSize => _samplingTable.values.fold(0, (sum, count) => sum + count); /// A table with sampling size of each measure type Map get samplingTable { @@ -28,8 +26,7 @@ class MeasurementsCardViewModel extends ViewModel { /// The list of measures List get measures { // sort them first - var mapEntries = _samplingTable.entries.toList() - ..sort((b, a) => a.value.compareTo(b.value)); + var mapEntries = _samplingTable.entries.toList()..sort((b, a) => a.value.compareTo(b.value)); Map sortedTasksTable = {}..addEntries(mapEntries); // and map to the [TaskCount] model @@ -42,7 +39,7 @@ class MeasurementsCardViewModel extends ViewModel { MeasurementsCardViewModel() : super(); - // void init(SmartphoneDeploymentController controller) { + // void init(SmartphoneStudyController controller) { // super.init(controller); // // listen to incoming events in order to count the measure types diff --git a/lib/view_models/cards/mobility_data_model.dart b/lib/view_models/cards/mobility_data_model.dart index e53a15c4..f93795dc 100644 --- a/lib/view_models/cards/mobility_data_model.dart +++ b/lib/view_models/cards/mobility_data_model.dart @@ -7,11 +7,10 @@ class MobilityCardViewModel extends SerializableViewModel { Map get weekData => model.weekMobility; /// Stream of mobility [DataPoint] measures. - Stream? get mobilityEvents => controller?.measurements - .where((measurement) => measurement.data is Mobility); + Stream? get mobilityEvents => + controller?.measurements.where((measurement) => measurement.data is Mobility); - final DateTime _startOfWeek = - DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); + final DateTime _startOfWeek = DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); final DateTime _endOfWeek = DateTime.now() .subtract(Duration(days: DateTime.now().weekday - 1)) .add(Duration(days: 6)); @@ -20,18 +19,15 @@ class MobilityCardViewModel extends SerializableViewModel { String get endOfWeek => DateFormat('dd').format(_endOfWeek); - String get currentMonth => - DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); + String get currentMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); - String get nextMonth => DateFormat('MMM') - .format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); + String get nextMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); - String get currentYear => - DateFormat('yyyy').format(DateTime(DateTime.now().year)); + String get currentYear => DateFormat('yyyy').format(DateTime(DateTime.now().year)); MobilityCardViewModel(); @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); // listen for mobility events and update the features @@ -65,17 +61,15 @@ class WeeklyMobility extends DataModel { DateTime day = data.date ?? DateTime.now(); weekMobility[day.weekday] = DailyMobility( - day.weekday, - data.numberOfPlaces ?? 0, - data.homeStay != null && data.homeStay! > 0 - ? (100 * (data.homeStay!)).toInt() - : 0, - data.distanceTraveled ?? 0); + day.weekday, + data.numberOfPlaces ?? 0, + data.homeStay != null && data.homeStay! > 0 ? (100 * (data.homeStay!)).toInt() : 0, + data.distanceTraveled ?? 0, + ); } @override - WeeklyMobility fromJson(Map json) => - _$WeeklyMobilityFromJson(json); + WeeklyMobility fromJson(Map json) => _$WeeklyMobilityFromJson(json); @override Map toJson() => _$WeeklyMobilityToJson(this); } @@ -90,6 +84,5 @@ class DailyMobility extends DailyMeasure { DailyMobility(super.weekday, this.places, this.homeStay, this.distance); Map toJson() => _$DailyMobilityToJson(this); - static DailyMobility fromJson(Map json) => - _$DailyMobilityFromJson(json); + static DailyMobility fromJson(Map json) => _$DailyMobilityFromJson(json); } diff --git a/lib/view_models/cards/steps_data_model.dart b/lib/view_models/cards/steps_data_model.dart index 02ecf2b4..e23c6cbe 100644 --- a/lib/view_models/cards/steps_data_model.dart +++ b/lib/view_models/cards/steps_data_model.dart @@ -12,8 +12,7 @@ class StepsCardViewModel extends SerializableViewModel { /// The list of steps. List get steps => model.steps; - final DateTime _startOfWeek = - DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); + final DateTime _startOfWeek = DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); final DateTime _endOfWeek = DateTime.now() .subtract(Duration(days: DateTime.now().weekday - 1)) .add(Duration(days: 6)); @@ -22,29 +21,25 @@ class StepsCardViewModel extends SerializableViewModel { String get endOfWeek => DateFormat('dd').format(_endOfWeek); - String get currentMonth => - DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); + String get currentMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); - String get nextMonth => DateFormat('MMM') - .format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); + String get nextMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); - String get currentYear => - DateFormat('yyyy').format(DateTime(DateTime.now().year)); + String get currentYear => DateFormat('yyyy').format(DateTime(DateTime.now().year)); /// Stream of pedometer (step) [DataPoint] measures. - Stream? get pedometerEvents => controller?.measurements - .where((dataPoint) => dataPoint.data is StepCount); + Stream? get pedometerEvents => + controller?.measurements.where((dataPoint) => dataPoint.data is StepCount); @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); // listen for pedometer events and count them pedometerEvents?.listen((pedometerDataPoint) { StepCount? step = pedometerDataPoint.data as StepCount?; if (_lastStep != null) { - model.increaseStepCount( - DateTime.now().weekday, step!.steps - _lastStep!.steps); + model.increaseStepCount(DateTime.now().weekday, step!.steps - _lastStep!.steps); } _lastStep = step; @@ -71,12 +66,9 @@ class WeeklySteps extends DataModel { } /// The list of steps listed pr. weekday. - List get steps => weeklySteps.entries - .map((entry) => DailySteps(entry.key, entry.value)) - .toList(); + List get steps => weeklySteps.entries.map((entry) => DailySteps(entry.key, entry.value)).toList(); - void increaseStepCount(int weekday, int steps) => - weeklySteps[weekday] = (weeklySteps[weekday] ?? 0) + steps; + void increaseStepCount(int weekday, int steps) => weeklySteps[weekday] = (weeklySteps[weekday] ?? 0) + steps; @override String toString() { @@ -86,8 +78,7 @@ class WeeklySteps extends DataModel { } @override - WeeklySteps fromJson(Map json) => - _$WeeklyStepsFromJson(json); + WeeklySteps fromJson(Map json) => _$WeeklyStepsFromJson(json); @override Map toJson() => _$WeeklyStepsToJson(this); } diff --git a/lib/view_models/cards/study_progress_data_model.dart b/lib/view_models/cards/study_progress_data_model.dart index 215dbbdb..788e2f34 100644 --- a/lib/view_models/cards/study_progress_data_model.dart +++ b/lib/view_models/cards/study_progress_data_model.dart @@ -20,14 +20,13 @@ class StudyProgressCardViewModel extends ViewModel { Map get progressTable => _progressTable; /// The list of measures - List get progress => _progressTable.entries - .map((entry) => StudyProgress(entry.key, entry.value)) - .toList(); + List get progress => + _progressTable.entries.map((entry) => StudyProgress(entry.key, entry.value)).toList(); StudyProgressCardViewModel() : super(); @override - Future init(SmartphoneDeploymentController ctrl) async { + Future init(SmartphoneStudyController ctrl) async { super.init(ctrl); updateProgress(); } diff --git a/lib/view_models/cards/task_data_model.dart b/lib/view_models/cards/task_data_model.dart index 047ed85e..4bcc426f 100644 --- a/lib/view_models/cards/task_data_model.dart +++ b/lib/view_models/cards/task_data_model.dart @@ -17,35 +17,28 @@ class TaskCardViewModel extends ViewModel { Map get tasksTable { Map tasksTable = {}; - AppTaskController() - .userTaskQueue - .where( - (task) => task.state == UserTaskState.done && task.type == taskType) + AppTaskController().userTaskQueue + .where((task) => task.state == UserTaskState.done && task.type == taskType) .forEach((task) { - if (!tasksTable.containsKey(task.title)) tasksTable[task.title] = 0; - tasksTable[task.title] = tasksTable[task.title]! + 1; - }); + if (!tasksTable.containsKey(task.title)) tasksTable[task.title] = 0; + tasksTable[task.title] = tasksTable[task.title]! + 1; + }); return tasksTable; } /// The total number of tasks done of type [taskType]. - int get tasksDone => AppTaskController() - .userTaskQueue - .where( - (task) => task.state == UserTaskState.done && task.type == taskType) + int get tasksDone => AppTaskController().userTaskQueue + .where((task) => task.state == UserTaskState.done && task.type == taskType) .length; /// The list of [TaskCount]s done. List get taskCount { // sort them first - var mapEntries = tasksTable.entries.toList() - ..sort((b, a) => a.value.compareTo(b.value)); + var mapEntries = tasksTable.entries.toList()..sort((b, a) => a.value.compareTo(b.value)); Map sortedTasksTable = {}..addEntries(mapEntries); // and map to the [TaskCount] model - List tasksList = sortedTasksTable.entries - .map((entry) => TaskCount(entry.key, entry.value)) - .toList(); + List tasksList = sortedTasksTable.entries.map((entry) => TaskCount(entry.key, entry.value)).toList(); return tasksList; } diff --git a/lib/view_models/data_visualization_page_model.dart b/lib/view_models/data_visualization_page_model.dart index 407a63bc..7229918a 100644 --- a/lib/view_models/data_visualization_page_model.dart +++ b/lib/view_models/data_visualization_page_model.dart @@ -1,23 +1,40 @@ part of carp_study_app; class DataVisualizationPageViewModel extends ViewModel { + DataVisualizationPageViewModel({StudyService? studyService}) : _studyService = studyService; + + final StudyService? _studyService; + StudyService get _study => _studyService ?? bloc.study; + + bool _hasUserTasks = false; + bool _hasHeartRateMeasure = false; + bool _hasAudioMeasure = false; + bool _hasVideoMeasure = false; + bool _hasImageMeasure = false; + bool _hasStepsMeasure = false; + bool _hasActivityMeasure = false; + bool _hasMobilityMeasure = false; + + // Card availability for the current deployment, computed once in [init]. + bool get hasUserTasks => _hasUserTasks; + bool get hasHeartRateMeasure => _hasHeartRateMeasure; + bool get hasAudioMeasure => _hasAudioMeasure; + bool get hasVideoMeasure => _hasVideoMeasure; + bool get hasImageMeasure => _hasImageMeasure; + bool get hasStepsMeasure => _hasStepsMeasure; + bool get hasActivityMeasure => _hasActivityMeasure; + bool get hasMobilityMeasure => _hasMobilityMeasure; + final ActivityCardViewModel _activityCardDataModel = ActivityCardViewModel(); final StepsCardViewModel _stepsCardDataModel = StepsCardViewModel(); - final MeasurementsCardViewModel _measuresCardDataModel = - MeasurementsCardViewModel(); + final MeasurementsCardViewModel _measuresCardDataModel = MeasurementsCardViewModel(); final MobilityCardViewModel _mobilityCardDataModel = MobilityCardViewModel(); - final TaskCardViewModel _surveysCardDataModel = - TaskCardViewModel(SurveyUserTask.SURVEY_TYPE); - final TaskCardViewModel _audioCardDataModel = - TaskCardViewModel(SurveyUserTask.AUDIO_TYPE); - final TaskCardViewModel _videoCardDataModel = - TaskCardViewModel(SurveyUserTask.VIDEO_TYPE); - final TaskCardViewModel _imageCardDataModel = - TaskCardViewModel(SurveyUserTask.IMAGE_TYPE); - final StudyProgressCardViewModel _studyProgressCardDataModel = - StudyProgressCardViewModel(); - final HeartRateCardViewModel _heartRateCardDataModel = - HeartRateCardViewModel(); + final TaskCardViewModel _surveysCardDataModel = TaskCardViewModel(AppTask.SURVEY_TYPE); + final TaskCardViewModel _audioCardDataModel = TaskCardViewModel(AppTask.AUDIO_TYPE); + final TaskCardViewModel _videoCardDataModel = TaskCardViewModel(AppTask.VIDEO_TYPE); + final TaskCardViewModel _imageCardDataModel = TaskCardViewModel(AppTask.IMAGE_TYPE); + final StudyProgressCardViewModel _studyProgressCardDataModel = StudyProgressCardViewModel(); + final HeartRateCardViewModel _heartRateCardDataModel = HeartRateCardViewModel(); ActivityCardViewModel get activityCardDataModel => _activityCardDataModel; StepsCardViewModel get stepsCardDataModel => _stepsCardDataModel; @@ -29,28 +46,32 @@ class DataVisualizationPageViewModel extends ViewModel { TaskCardViewModel get imageCardDataModel => _imageCardDataModel; HeartRateCardViewModel get heartRateCardDataModel => _heartRateCardDataModel; - StudyProgressCardViewModel get studyProgressCardDataModel => - _studyProgressCardDataModel; + StudyProgressCardViewModel get studyProgressCardDataModel => _studyProgressCardDataModel; /// A stream of [UserTask]s as they are generated. Stream get userTaskEvents => AppTaskController().userTaskEvents; /// The number of days the user has been part of this study. - int get daysInStudy => (bloc.studyStartTimestamp != null) - ? DateTime.now().difference(bloc.studyStartTimestamp!).inDays + 1 + int get daysInStudy => (bloc.study.studyStartTimestamp != null) + ? DateTime.now().difference(bloc.study.studyStartTimestamp!).inDays + 1 : 0; /// The number of tasks completed so far. - int get taskCompleted => AppTaskController() - .userTaskQueue - .where((task) => task.state == UserTaskState.done) - .length; - - DataVisualizationPageViewModel(); + int get taskCompleted => AppTaskController().userTaskQueue.where((task) => task.state == UserTaskState.done).length; @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); + + _hasUserTasks = _study.hasUserTasks(); + _hasHeartRateMeasure = _study.hasMeasure(PolarSamplingPackage.HR) || _study.hasMeasure(MovesenseSamplingPackage.HR); + _hasAudioMeasure = _study.hasMeasure(MediaSamplingPackage.AUDIO); + _hasVideoMeasure = _study.hasMeasure(MediaSamplingPackage.VIDEO); + _hasImageMeasure = _study.hasMeasure(MediaSamplingPackage.IMAGE); + _hasStepsMeasure = _study.hasMeasure(CarpDataTypes.STEP_COUNT); + _hasActivityMeasure = _study.hasMeasure(ContextSamplingPackage.ACTIVITY); + _hasMobilityMeasure = _study.hasMeasure(ContextSamplingPackage.MOBILITY); + _activityCardDataModel.init(ctrl); _stepsCardDataModel.init(ctrl); _heartRateCardDataModel.init(ctrl); diff --git a/lib/view_models/device_view_models.dart b/lib/view_models/device_view_models.dart index 4ef202f0..1370d1f2 100644 --- a/lib/view_models/device_view_models.dart +++ b/lib/view_models/device_view_models.dart @@ -2,8 +2,29 @@ part of carp_study_app; /// The view model for the [DeviceListPage]. class DeviceListPageViewModel extends ViewModel { - final List _devices = []; - List get devices => _devices; + DeviceListPageViewModel({StudyService? studyService}) : _studyService = studyService; + + final StudyService? _studyService; + StudyService get _study => _studyService ?? bloc.study; + + /// The smartphone (primary) device of this deployment. + List get smartphoneDevice => + _study.deploymentDevices.where((device) => device.deviceManager is SmartphoneDeviceManager).toList(); + + /// The hardware devices (connected devices) of this deployment. + List get hardwareDevices => _study.deploymentDevices + .where( + (device) => device.deviceManager is HardwareDeviceManager && device.deviceManager is! SmartphoneDeviceManager, + ) + .toList(); + + /// The online services of this deployment. + List get onlineServices => + _study.deploymentDevices.where((device) => device.deviceManager is ServiceManager).toList(); + + /// The Health service of this deployment, if any. + DeviceViewModel? get healthService => + onlineServices.where((device) => device.type == HealthService.DEVICE_TYPE).firstOrNull; } /// The view model for each device - [DeviceManager]. @@ -15,11 +36,10 @@ class DeviceViewModel extends ViewModel { DeviceViewModel(this.deviceManager) : super(); /// The type of this device. - String? get type => deviceManager.type; + String? get type => deviceManager.deviceType; /// A printer-friendly name for this [type] of device. - String get typeName => - _deviceTypeName[type!] ?? 'pages.devices.type.unknown.name'; + String get typeName => _deviceTypeName[type!] ?? 'pages.devices.type.unknown.name'; /// The status of this device. DeviceStatus get status => deviceManager.status; @@ -29,12 +49,12 @@ class DeviceViewModel extends ViewModel { Stream get statusEvents => deviceManager.statusEvents; /// The device id - String get id => deviceManager.id; + String get id => deviceManager.displayName ?? ''; /// A printer-friendly name for this device. String get name { - if (deviceManager is BTLEDeviceManager) { - return (deviceManager as BTLEDeviceManager).btleName; + if (deviceManager is BLEDeviceManager) { + return (deviceManager as BLEDeviceManager).bleName ?? ''; } else if (deviceManager is PolarDeviceManager) { return (deviceManager as PolarDeviceManager).displayName ?? ''; } else { @@ -43,16 +63,14 @@ class DeviceViewModel extends ViewModel { } /// A printer-friendly description of this device. - String get description => - '${_deviceTypeDescription[type!]} - ${status.name}\n$batteryLevel% battery remaining.'; + String get description => '${_deviceTypeDescription[type!]} - ${status.name}\n$batteryLevel% battery remaining.'; /// The battery level of this device. /// /// Only relevant if this device is a [HardwareDeviceManager]. /// Returns null if not a hardware device. - int? get batteryLevel => (deviceManager is HardwareDeviceManager) - ? (deviceManager as HardwareDeviceManager).batteryLevel - : null; + int? get batteryLevel => + (deviceManager is HardwareDeviceManager) ? (deviceManager as HardwareDeviceManager).batteryLevel : null; /// The stream of battery level events. /// @@ -77,24 +95,19 @@ class DeviceViewModel extends ViewModel { /// Instructions to the user on how to connect to this type of device. String? get connectionInstructions => _deviceConnectionInstructions[type!]; - String? get connectionInstructionsImage => - _deviceConnectionInstructionsImage[type!]; + String? get connectionInstructionsImage => _deviceConnectionInstructionsImage[type!]; PolarDeviceType get polarDeviceType { if (deviceManager is PolarDeviceManager) { - return (deviceManager as PolarDeviceManager).configuration?.deviceType ?? - PolarDeviceType.UNKNOWN; + return (deviceManager as PolarDeviceManager).polarDeviceType ?? PolarDeviceType.Unknown; } else { - return PolarDeviceType.UNKNOWN; + return PolarDeviceType.Unknown; } } MovesenseDeviceType get movesenseDeviceType { if (deviceManager is MovesenseDeviceManager) { - return (deviceManager as MovesenseDeviceManager) - .configuration - ?.deviceType ?? - MovesenseDeviceType.UNKNOWN; + return (deviceManager as MovesenseDeviceManager).movesenseDeviceType; } else { return MovesenseDeviceType.UNKNOWN; } @@ -102,22 +115,18 @@ class DeviceViewModel extends ViewModel { /// Display information about this phone. Map get phoneInfo => { - 'name': '${DeviceInfo().deviceID}', - 'model': - '${DeviceInfo().deviceModel} (${DeviceInfo().deviceManufacturer?.toUpperCase()})', - 'version': 'SDK ${DeviceInfo().sdk}', - }; + 'name': '${DeviceInfoService().deviceID}', + 'model': '${DeviceInfoService().deviceModel} (${DeviceInfoService().deviceManufacturer?.toUpperCase()})', + 'version': 'SDK ${DeviceInfoService().sdk}', + }; /// Map a selected device to the device in the protocol and connect to it. void connectToDevice(BluetoothDevice selectedDevice) { - if (deviceManager is BTLEDeviceManager) { - (deviceManager as BTLEDeviceManager).btleAddress = - selectedDevice.remoteId.str; - (deviceManager as BTLEDeviceManager).btleName = - selectedDevice.platformName; + if (deviceManager is BLEDeviceManager) { + (deviceManager as BLEDeviceManager).bleAddress = selectedDevice.remoteId.str; + (deviceManager as BLEDeviceManager).bleName = selectedDevice.platformName; } - Sensing().controller?.saveDeployment(); deviceManager.connect(); } @@ -126,16 +135,13 @@ class DeviceViewModel extends ViewModel { try { await deviceManager.disconnect(); - // Erase BTLE information so the user can connect to another device, if needed. - if (deviceManager is BTLEDeviceManager) { - (deviceManager as BTLEDeviceManager).btleAddress = ''; - (deviceManager as BTLEDeviceManager).btleName = ''; + // Erase BLE information so the user can connect to another device, if needed. + if (deviceManager is BLEDeviceManager) { + (deviceManager as BLEDeviceManager).bleAddress = ''; + (deviceManager as BLEDeviceManager).bleName = ''; } - - Sensing().controller?.saveDeployment(); } catch (error) { - warning( - "$runtimeType - Error disconnecting to device '${deviceManager.id}' - $error."); + warning("$runtimeType - Error disconnecting to device '${deviceManager.displayName}' - $error."); } } } @@ -161,64 +167,31 @@ const Map _deviceTypeDescription = { }; const Map _deviceTypeIcon = { - Smartphone.DEVICE_TYPE: Icon( - Icons.phone_android, - size: 30, - color: CACHET.GREEN_1, - ), - WeatherService.DEVICE_TYPE: Icon( - Icons.wb_cloudy, - color: CACHET.BLUE_1, - ), - AirQualityService.DEVICE_TYPE: Icon( - Icons.air, - color: CACHET.LIGHT_BLUE, - ), - LocationService.DEVICE_TYPE: Icon( - Icons.location_on, - color: CACHET.GREEN, - ), - PolarDevice.DEVICE_TYPE: Icon( - Icons.monitor_heart, - size: 30, - color: CACHET.RED, - ), - MovesenseDevice.DEVICE_TYPE: Icon( - Icons.circle, - size: 30, - color: CACHET.GREY_1, - ), - HealthService.DEVICE_TYPE: Icon( - Icons.favorite_rounded, - size: 30, - color: CACHET.RED_1, - ), + Smartphone.DEVICE_TYPE: Icon(Icons.phone_android, size: 30, color: CACHET.GREEN_1), + WeatherService.DEVICE_TYPE: Icon(Icons.wb_cloudy, color: CACHET.BLUE_1), + AirQualityService.DEVICE_TYPE: Icon(Icons.air, color: CACHET.LIGHT_BLUE), + LocationService.DEVICE_TYPE: Icon(Icons.location_on, color: CACHET.GREEN), + PolarDevice.DEVICE_TYPE: Icon(Icons.monitor_heart, size: 30, color: CACHET.RED), + MovesenseDevice.DEVICE_TYPE: Icon(Icons.circle, size: 30, color: CACHET.GREY_1), + HealthService.DEVICE_TYPE: Icon(Icons.favorite_rounded, size: 30, color: CACHET.RED_1), }; const Map _deviceStatusIcon = { - DeviceStatus.initialized: "pages.devices.status.action.connect", - DeviceStatus.connecting: Icon(Icons.bluetooth_searching_rounded, - color: CACHET.DARK_BLUE, size: 30), - DeviceStatus.connected: - Icon(Icons.bluetooth_rounded, color: CACHET.GREEN_1, size: 30), + DeviceStatus.configured: "pages.devices.status.action.connect", + DeviceStatus.connecting: Icon(Icons.bluetooth_searching_rounded, color: CACHET.DARK_BLUE, size: 30), + DeviceStatus.connected: Icon(Icons.bluetooth_rounded, color: CACHET.GREEN_1, size: 30), DeviceStatus.disconnected: "pages.devices.status.action.connect", DeviceStatus.paired: "pages.devices.status.action.connect", - DeviceStatus.error: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), - DeviceStatus.unknown: - Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), + DeviceStatus.unknown: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), }; const Map _serviceStatusIcon = { - DeviceStatus.initialized: "pages.devices.status.action.connect", - DeviceStatus.connecting: - Icon(Icons.sensors_off_rounded, color: CACHET.GREEN_1, size: 30), - DeviceStatus.connected: - Icon(Icons.sensors_rounded, color: CACHET.GREEN_1, size: 30), + DeviceStatus.configured: "pages.devices.status.action.connect", + DeviceStatus.connecting: Icon(Icons.sensors_off_rounded, color: CACHET.GREEN_1, size: 30), + DeviceStatus.connected: Icon(Icons.sensors_rounded, color: CACHET.GREEN_1, size: 30), DeviceStatus.disconnected: "pages.devices.status.action.connect", DeviceStatus.paired: "pages.devices.status.action.connect", - DeviceStatus.error: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), - DeviceStatus.unknown: - Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), + DeviceStatus.unknown: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), }; const Map _deviceStatusText = { @@ -226,8 +199,7 @@ const Map _deviceStatusText = { DeviceStatus.connected: "pages.devices.status.connected", DeviceStatus.disconnected: "pages.devices.status.disconnected", DeviceStatus.paired: "pages.devices.status.paired", - DeviceStatus.error: "pages.devices.status.error", - DeviceStatus.initialized: "pages.devices.status.initialized", + DeviceStatus.configured: "pages.devices.status.initialized", DeviceStatus.unknown: "pages.devices.status.unknown", }; diff --git a/lib/view_models/home_page_model.dart b/lib/view_models/home_page_model.dart new file mode 100644 index 00000000..3b3ab22c --- /dev/null +++ b/lib/view_models/home_page_model.dart @@ -0,0 +1,36 @@ +part of carp_study_app; + +/// The view model for the [HomePage]. +class HomePageViewModel extends ViewModel { + HomePageViewModel({SystemInfoService? systemInfoService}) : _systemInfoService = systemInfoService; + + final SystemInfoService? _systemInfoService; + SystemInfoService get _system => _systemInfoService ?? bloc.system; + + bool _healthConnectPromptPending = false; + + /// Should the user be prompted to install Health Connect? + /// One-shot - the page calls [healthConnectPromptShown] once shown. + bool get shouldPromptHealthConnectInstall => _healthConnectPromptPending; + + void healthConnectPromptShown() => _healthConnectPromptPending = false; + + @override + void init(SmartphoneStudyController ctrl) { + super.init(ctrl); + unawaited(_checkHealthConnectInstallation()); + } + + Future _checkHealthConnectInstallation() async { + if (!Platform.isAndroid) return; + if (await _system.isHealthInstalled()) return; + _healthConnectPromptPending = true; + notifyListeners(); + } + + @override + void clear() { + _healthConnectPromptPending = false; + super.clear(); + } +} diff --git a/lib/view_models/informed_consent_page_model.dart b/lib/view_models/informed_consent_page_model.dart index bf97ab67..d36368c0 100644 --- a/lib/view_models/informed_consent_page_model.dart +++ b/lib/view_models/informed_consent_page_model.dart @@ -15,15 +15,21 @@ class InformedConsentViewModel extends ViewModel { /// local [locale]. Future getInformedConsent(Locale locale) async { if (_informedConsent == null) { - await bloc.localizationLoader.load(locale); - _informedConsent = await bloc.getInformedConsent(); + await bloc.resources.localizationLoader.load(locale); + _informedConsent = await bloc.consent.getDocument(); } return _informedConsent; } /// Called when the informed consent has been accepted by the user. - void informedConsentHasBeenAccepted( - RPTaskResult informedConsentResult, - ) => - bloc.informedConsentHasBeenAccepted(informedConsentResult); + /// Returns once the upload to the backend has completed, so callers can + /// safely route to a page whose redirect re-queries the backend. + Future informedConsentHasBeenAccepted(RPTaskResult informedConsentResult) => + bloc.consent.accept(informedConsentResult); + + /// Accept consent for a study that has no consent document. + Future acceptWithoutDocument() => bloc.consent.accept(); + + /// The user abandoned the consent flow - leave the study. + void abandonConsent() => bloc.leaveStudy(); } diff --git a/lib/view_models/invitations_view_model.dart b/lib/view_models/invitations_view_model.dart index f243ed4c..a314d99c 100644 --- a/lib/view_models/invitations_view_model.dart +++ b/lib/view_models/invitations_view_model.dart @@ -1,10 +1,75 @@ part of carp_study_app; +/// The view model for the [InvitationListPage] and [InvitationDetailsPage]. class InvitationsViewModel extends ViewModel { - List get invitations => - bloc.backend.invitations; + InvitationsViewModel({AuthService? authService}) : _authService = authService; + + final AuthService? _authService; + AuthService get _auth => _authService ?? bloc.auth; + + List? _invitations; + Object? _error; + + /// The invitations loaded so far. Empty until [loadInvitations] completes. + List get invitations => _invitations ?? []; + + /// Have invitations been successfully loaded at least once? + bool get isLoaded => _invitations != null; + + /// Is the (initial) list of invitations being loaded? + bool get isLoading => _invitations == null && _error == null; + + bool get hasError => _error != null; + + /// The route to land on after authenticating: the single invitation's + /// detail if there is exactly one, otherwise the list. + String get landingRoute => invitations.length == 1 + ? '${InvitationDetailsPage.route}/${invitations.first.participation.participantId}' + : InvitationListPage.route; + + /// Load / refresh the list of invitations from CAWS. Always hits the + /// backend - used for sign-in and pull-to-refresh. + Future loadInvitations() async { + logApp('InvitationsViewModel.loadInvitations() START'); + try { + _invitations = await _auth.getInvitations(); + _error = null; + logApp( + 'InvitationsViewModel.loadInvitations() DONE - ${_invitations!.length} invitation(s): ' + '${_invitations!.map((i) => i.studyDeploymentId).toList()}', + ); + } catch (error) { + warning('$runtimeType - Could not load invitations - $error'); + logApp('InvitationsViewModel.loadInvitations() FAILED - $error'); + _error = error; + } + notifyListeners(); + } + + /// Load invitations only if not already loaded. Used on entry to the list + /// page to avoid re-fetching when sign-in just loaded them; pull-to-refresh + /// calls [loadInvitations] directly to force a refresh. + Future ensureInvitationsLoaded() async { + if (isLoaded) return; + await loadInvitations(); + } ActiveParticipationInvitation getInvitation(String invitationId) => - invitations.firstWhere((invitation) => - invitation.participation.participantId == invitationId); + invitations.firstWhere((invitation) => invitation.participation.participantId == invitationId); + + /// Accept [invitation] and make it the active study in the app. + void accept(ActiveParticipationInvitation invitation) { + logApp('InvitationsViewModel.accept() - deploymentId=${invitation.studyDeploymentId}'); + bloc.setStudyInvitation(invitation); + } + + /// Sign out and return to the login page. + Future signOut() => bloc.signOutAndLeaveStudy(); + + @override + void clear() { + _invitations = null; + _error = null; + super.clear(); + } } diff --git a/lib/view_models/login_page_model.dart b/lib/view_models/login_page_model.dart new file mode 100644 index 00000000..37d1b812 --- /dev/null +++ b/lib/view_models/login_page_model.dart @@ -0,0 +1,51 @@ +part of carp_study_app; + +/// The outcome of a sign-in attempt. +enum SignInResult { success, offline, failed } + +/// The view model for the [LoginPage] and [QRViewExample] - owns the +/// authentication flow. +class LoginViewModel extends ViewModel { + LoginViewModel({AuthService? authService, SystemInfoService? systemInfoService}) + : _authService = authService, + _systemInfoService = systemInfoService; + + final AuthService? _authService; + final SystemInfoService? _systemInfoService; + AuthService get _auth => _authService ?? bloc.auth; + SystemInfoService get _system => _systemInfoService ?? bloc.system; + + /// Has the user been authenticated? + bool get isAuthenticated => _auth.isAuthenticated; + + /// Sign in via the CAWS web view. + Future signIn() async { + logAppState('LoginViewModel.signIn() START'); + if (!await _system.checkConnectivity()) return SignInResult.offline; + + await _auth.initialize(); + await _auth.authenticate(); + + notifyListeners(); + final result = _auth.isAuthenticated ? SignInResult.success : SignInResult.failed; + logAppState('LoginViewModel.signIn() DONE - result=${result.name}'); + return result; + } + + /// Sign in anonymously using a scanned magic link. + /// Returns false if [qrCode] is not a link, without attempting to sign in. + Future signInWithMagicLink(String qrCode) async { + if (Uri.tryParse(qrCode)?.hasAbsolutePath != true) return false; + + await _auth.authenticateWithMagicLink(qrCode); + + notifyListeners(); + return _auth.isAuthenticated; + } + + /// Sign out from CAWS, erasing all authentication information. + Future signOut() async { + await _auth.signOut(); + notifyListeners(); + } +} diff --git a/lib/view_models/participant_data_page_model.dart b/lib/view_models/participant_data_page_model.dart index d3d4aab1..5f0c0a83 100644 --- a/lib/view_models/participant_data_page_model.dart +++ b/lib/view_models/participant_data_page_model.dart @@ -1,8 +1,10 @@ part of carp_study_app; class ParticipantDataPageViewModel extends ViewModel { - Set get expectedData => - bloc.expectedParticipantData; + Set get expectedData => bloc.study.expectedParticipantData; + + /// Save the participant [data] for the current study. + void setParticipantData(Map data) => bloc.study.setParticipantData(data); late TextEditingController _address1Controller; late TextEditingController _address2Controller; diff --git a/lib/view_models/profile_page_model.dart b/lib/view_models/profile_page_model.dart index a78f5e6f..ee9e9706 100644 --- a/lib/view_models/profile_page_model.dart +++ b/lib/view_models/profile_page_model.dart @@ -1,30 +1,49 @@ part of carp_study_app; class ProfilePageViewModel extends ViewModel { - String get userId => bloc.user?.id ?? bloc.deployment?.participantId ?? ''; - String get username => bloc.user?.username ?? ''; - String get firstName => bloc.user?.firstName ?? ''; - String get lastName => bloc.user?.lastName ?? ''; + ProfilePageViewModel({AuthService? authService, StudyService? studyService, SystemInfoService? systemInfoService}) + : _authService = authService, + _studyService = studyService, + _systemInfoService = systemInfoService; + + final AuthService? _authService; + final StudyService? _studyService; + final SystemInfoService? _systemInfoService; + + AuthService get _auth => _authService ?? bloc.auth; + StudyService get _study => _studyService ?? bloc.study; + SystemInfoService get _system => _systemInfoService ?? bloc.system; + + /// Did the user authenticate anonymously (magic link / QR)? + bool get isAnonymous => _auth.isAnonymous; + + /// Is the phone connected to the internet? + Future checkConnectivity() => _system.checkConnectivity(); + + /// Sign out and leave the study. + Future signOutAndLeaveStudy() => bloc.signOutAndLeaveStudy(); + + /// Leave the study, returning to the invitation list. + Future leaveStudy() => bloc.leaveStudy(); + + String get userId => _auth.user?.id ?? _study.study?.participantId ?? ''; + String get username => _auth.username; + String get firstName => _auth.user?.firstName ?? ''; + String get lastName => _auth.user?.lastName ?? ''; String get fullName => '$firstName $lastName'; - String get email => bloc.user?.email ?? ''; - - String get studyId => bloc.deployment?.studyId ?? ''; - String get studyDeploymentId => bloc.deployment?.studyDeploymentId ?? ''; - String get studyDeploymentTitle => - bloc.deployment?.studyDescription?.title ?? ''; - String get participantId => bloc.deployment?.participantId ?? ''; - String get participantRole => bloc.deployment?.participantRoleName ?? ''; - String get deviceRole => bloc.deployment?.deviceRoleName ?? ''; - - String get responsibleEmail => - bloc.deployment?.studyDescription?.responsible?.email ?? 'study@carp.dk'; + String get email => _auth.user?.email ?? ''; + + String get studyId => _study.deployment?.studyId ?? ''; + String get studyDeploymentId => _study.deployment?.studyDeploymentId ?? ''; + String get studyDeploymentTitle => _study.deployment?.studyDescription?.title ?? ''; + String get participantId => _study.study?.participantId ?? ''; + String get participantRole => _study.study?.participantRoleName ?? ''; + String get deviceRole => _study.deployment?.deviceRoleName ?? ''; + + String get responsibleEmail => _study.deployment?.studyDescription?.responsible?.email ?? 'study@carp.dk'; String get privacyPolicyUrl => - bloc.deployment?.studyDescription?.privacyPolicyUrl ?? - 'https://carp.dk/privacy-policy-app/'; - String get studyDescriptionUrl => - bloc.deployment?.studyDescription?.studyDescriptionUrl ?? ''; - String get deviceID => DeviceInfo().deviceID ?? ''; - String get currentServer => bloc.backend.uri.toString(); - - ProfilePageViewModel(); + _study.deployment?.studyDescription?.privacyPolicyUrl ?? 'https://carp.dk/privacy-policy-app/'; + String get studyDescriptionUrl => _study.deployment?.studyDescription?.studyDescriptionUrl ?? ''; + String get deviceID => DeviceInfoService().deviceID ?? ''; + String get currentServer => _auth.serverUri.toString(); } diff --git a/lib/view_models/study_page_model.dart b/lib/view_models/study_page_model.dart index b77f3f45..f8e6f9a4 100644 --- a/lib/view_models/study_page_model.dart +++ b/lib/view_models/study_page_model.dart @@ -3,54 +3,117 @@ part of carp_study_app; /// The view model for the [StudyPage]. Mainly holds the list of messages like /// news articles to be shown as part of the study. class StudyPageViewModel extends ViewModel { - String get title => bloc.deployment?.studyDescription?.title ?? 'Unnamed'; - String get description => - bloc.deployment?.studyDescription?.description ?? ''; - String get purpose => bloc.deployment?.studyDescription?.purpose ?? ''; + StudyPageViewModel({ + StudyService? studyService, + MessageService? messageService, + SystemInfoService? systemInfoService, + AuthService? authService, + }) : _studyService = studyService, + _messageService = messageService, + _systemInfoService = systemInfoService, + _authService = authService; + + final StudyService? _studyService; + final MessageService? _messageService; + final SystemInfoService? _systemInfoService; + final AuthService? _authService; + bool _attachedToApp = false; + bool _appUpdateAvailable = false; + + StudyService get _study => _studyService ?? bloc.study; + MessageService get _messages => _messageService ?? bloc.messages; + SystemInfoService get _system => _systemInfoService ?? bloc.system; + AuthService get _auth => _authService ?? bloc.auth; + + // Relay app-state changes (e.g. configuration completing) to the page, so + // it only needs to listen to this view model. Attached lazily since the + // global bloc is not available while this view model is constructed. + @override + void addListener(VoidCallback listener) { + if (!_attachedToApp) { + _attachedToApp = true; + bloc.addListener(notifyListeners); + } + super.addListener(listener); + } + + /// Is the app fully configured with the study? + bool get isConfigured => bloc.isConfigured; + + /// Is the user signed in anonymously? + bool get isAnonymousUser => _auth.isAnonymous; + + /// Is a newer version of this app available? Checked once on [init]. + bool get appUpdateAvailable => _appUpdateAvailable; + + @override + void init(SmartphoneStudyController ctrl) { + super.init(ctrl); + unawaited(_checkAppUpdate()); + } + + Future _checkAppUpdate() async { + try { + _appUpdateAvailable = await _system.getAppHasUpdate() ?? false; + } catch (error) { + _appUpdateAvailable = false; + } + notifyListeners(); + } + + /// Re-fetch messages, re-try deployment if needed, (re)start sensing, and + /// refresh the deployment status. Used on pull-to-refresh. + Future refresh() async { + await _messages.refresh(); + await _study.tryDeployment(); + await _study.start(); + await _study.refreshDeploymentStatus(); + } + + /// Retry study configuration after a failure. + Future retryConfiguration() => bloc.tryConfigureStudy(); + + String get title => _study.deployment?.studyDescription?.title ?? 'Unnamed'; + String get description => _study.deployment?.studyDescription?.description ?? ''; + String get purpose => _study.deployment?.studyDescription?.purpose ?? ''; Image get image => Image.asset('assets/images/exercise.png'); - String? get userID => bloc.deployment?.participantId; - String get studyDeploymentId => bloc.deployment?.studyDeploymentId ?? ''; - String get responsibleName => - bloc.deployment?.studyDescription?.responsible?.name ?? ''; - String get responsibleEmail => - bloc.deployment?.studyDescription?.responsible?.email ?? ''; - String get studyDescriptionUrl => - bloc.deployment?.studyDescription?.studyDescriptionUrl ?? ''; + String? get userID => _study.study?.participantId; + String get studyDeploymentId => _study.deployment?.studyDeploymentId ?? ''; + String get responsibleName => _study.deployment?.studyDescription?.responsible?.name ?? ''; + String get responsibleEmail => _study.deployment?.studyDescription?.responsible?.email ?? ''; + String get studyDescriptionUrl => _study.deployment?.studyDescription?.studyDescriptionUrl ?? ''; String get privacyPolicyUrl => - bloc.deployment?.studyDescription?.privacyPolicyUrl ?? - 'https://carp.dk/privacy-policy-app/'; + _study.deployment?.studyDescription?.privacyPolicyUrl ?? 'https://carp.dk/privacy-policy-app/'; - String get piTitle => bloc.deployment?.responsible?.title ?? ''; - String get piName => bloc.deployment?.responsible?.name ?? ''; - String get piAddress => bloc.deployment?.responsible?.address ?? ''; - String get piEmail => bloc.deployment?.responsible?.email ?? ''; + String get piTitle => _study.deployment?.responsible?.title ?? ''; + String get piName => _study.deployment?.responsible?.name ?? ''; + String get piAddress => _study.deployment?.responsible?.address ?? ''; + String get piEmail => _study.deployment?.responsible?.email ?? ''; String get piAffiliation => - bloc.deployment?.responsible?.affiliation ?? - 'Department of Health Technology, Technical University of Denmark'; + _study.deployment?.responsible?.affiliation ?? 'Department of Health Technology, Technical University of Denmark'; - String get participantRole => bloc.deployment?.participantRoleName ?? ''; - String get deviceRole => bloc.deployment?.deviceRoleName ?? ''; + String get participantRole => _study.study?.participantRoleName ?? ''; + String get deviceRole => _study.deployment?.deviceRoleName ?? ''; - Future get studyDeploymentStatus => - bloc.studyDeploymentStatus; + Future get studyDeploymentStatus => _study.refreshDeploymentStatus(); /// The stream of messages (count) - Stream get messageStream => bloc.messageStream; + Stream get messageStream => _messages.stream; /// The list of messages to be displayed. - List get messages => bloc.messages.reversed.toList(); - - /// The icon for a type of message - Icon getMessageTypeIcon(MessageType type) { - switch (type) { - case MessageType.announcement: - return const Icon(Icons.new_releases); - case MessageType.article: - return const Icon(Icons.description); - case MessageType.news: - return const Icon(Icons.create_new_folder); - } - } + List get messages => _messages.messages.reversed.toList(); + + /// The message with [id], or a placeholder if it is not found. + Message messageById(String id) => + _messages.byId(id) ?? + Message( + id: '0', + title: 'Unknown message', + subTitle: 'Unknown message', + type: MessageType.announcement, + timestamp: DateTime.now(), + image: './assets/images/kids.png', + ); /// Get the image based on [imagePath]. Can be both an asset and a network /// image. See [Message.imagePath]. @@ -70,11 +133,11 @@ class StudyPageViewModel extends ViewModel { static const dummyID = '00000000-0000-0000-0000-000000000000'; Message get studyDescriptionMessage => Message( - id: dummyID, - title: title, - message: description, - type: MessageType.announcement, - timestamp: bloc.studyStartTimestamp ?? DateTime.now(), - image: 'assets/images/kids.png', - ); + id: dummyID, + title: title, + message: description, + type: MessageType.announcement, + timestamp: _study.studyStartTimestamp ?? DateTime.now(), + image: 'assets/images/kids.png', + ); } diff --git a/lib/view_models/tasklist_page_model.dart b/lib/view_models/tasklist_page_model.dart index 1a03b570..ab6dd28e 100644 --- a/lib/view_models/tasklist_page_model.dart +++ b/lib/view_models/tasklist_page_model.dart @@ -2,7 +2,72 @@ part of carp_study_app; /// A view model for the [TaskListPage]. class TaskListPageViewModel extends ViewModel { - TaskListPageViewModel(); + TaskListPageViewModel({StudyService? studyService}) : _studyService = studyService; + + final StudyService? _studyService; + StudyService get _study => _studyService ?? bloc.study; + + bool _showParticipantDataCard = false; + final Map _autoCompleteTimers = {}; + UserTask? _autoCompletedTask; + + /// Should the card prompting for participant data be shown? + bool get showParticipantDataCard => _showParticipantDataCard; + + /// The task most recently auto-completed by [startUserTask], if any. + /// One-shot - the page calls [autoCompletedTaskShown] once it has shown + /// a confirmation. + UserTask? get autoCompletedTask => _autoCompletedTask; + + void autoCompletedTaskShown() => _autoCompletedTask = null; + + /// Check whether participant data is still missing, updating + /// [showParticipantDataCard]. + Future checkParticipantData() async { + final data = await _study.getParticipantDataListFromDeployment(); + _showParticipantDataCard = data.isEmpty; + notifyListeners(); + } + + /// Start [userTask], if it is not already started, done, or expired. + /// Returns true when the task opens its own page. Otherwise the task is a + /// background sensing task, which auto-completes after 10 seconds - even + /// if the user navigates away in the meantime. + bool startUserTask(UserTask userTask) { + if (userTask.state != UserTaskState.enqueued && userTask.state != UserTaskState.canceled) return false; + + userTask.onStart(); + if (userTask.hasWidget) return true; + + _autoCompleteTimers[userTask.id] = Timer(const Duration(seconds: 10), () { + _autoCompleteTimers.remove(userTask.id); + userTask.onDone(); + _autoCompletedTask = userTask; + notifyListeners(); + }); + return false; + } + + void _cancelAutoCompleteTimers() { + for (var timer in _autoCompleteTimers.values) { + timer.cancel(); + } + _autoCompleteTimers.clear(); + } + + @override + void clear() { + _cancelAutoCompleteTimers(); + _showParticipantDataCard = false; + _autoCompletedTask = null; + super.clear(); + } + + @override + void dispose() { + _cancelAutoCompleteTimers(); + super.dispose(); + } List get tasks { var tasks = AppTaskController().userTaskQueue; @@ -34,13 +99,14 @@ class TaskListPageViewModel extends ViewModel { Stream get userTaskEvents => AppTaskController().userTaskEvents; /// The number of days the user has been part of this study. - int get daysInStudy => (bloc.studyStartTimestamp != null) - ? DateTime.now().difference(bloc.studyStartTimestamp!).inDays + 1 + /// + /// This is calculated from the study deployment status creation date from the + /// [StudyDeploymentStatus]. + /// Returns 0 if the study deployment status is not available. + int get daysInStudy => (bloc.study.cachedDeploymentStatus != null) + ? DateTime.now().difference(bloc.study.cachedDeploymentStatus!.createdOn).inDays : 0; /// The number of tasks completed so far. - int get taskCompleted => AppTaskController() - .userTaskQueue - .where((task) => task.state == UserTaskState.done) - .length; + int get taskCompleted => AppTaskController().userTaskQueue.where((task) => task.state == UserTaskState.done).length; } diff --git a/lib/view_models/user_tasks.dart b/lib/view_models/user_tasks.dart index 2b66f65c..82abb869 100644 --- a/lib/view_models/user_tasks.dart +++ b/lib/view_models/user_tasks.dart @@ -3,19 +3,15 @@ part of carp_study_app; /// A [UserTaskFactory] that can handle the user tasks in this app. class AppUserTaskFactory implements UserTaskFactory { @override - List types = [ - SurveyUserTask.AUDIO_TYPE, - SurveyUserTask.VIDEO_TYPE, - SurveyUserTask.IMAGE_TYPE, - ]; + List types = [AppTask.AUDIO_TYPE, AppTask.VIDEO_TYPE, AppTask.IMAGE_TYPE]; @override UserTask create(AppTaskExecutor executor) => switch (executor.task.type) { - SurveyUserTask.AUDIO_TYPE => AudioUserTask(executor), - SurveyUserTask.VIDEO_TYPE => VideoUserTask(executor), - SurveyUserTask.IMAGE_TYPE => VideoUserTask(executor), - _ => BackgroundSensingUserTask(executor), - }; + AppTask.AUDIO_TYPE => AudioUserTask(executor), + AppTask.VIDEO_TYPE => VideoUserTask(executor), + AppTask.IMAGE_TYPE => VideoUserTask(executor), + _ => BackgroundSensingUserTask(executor), + }; } /// A user task handling audio recordings. @@ -36,9 +32,7 @@ class AudioUserTask extends UserTask { int ongoingRecordingDuration = 60; AudioUserTask(AppTaskExecutor executor) : super(executor) { - recordingDuration = (executor.task.minutesToComplete != null) - ? executor.task.minutesToComplete! * 60 - : 60; + recordingDuration = (executor.task.minutesToComplete != null) ? executor.task.minutesToComplete! * 60 : 60; } @override @@ -52,7 +46,7 @@ class AudioUserTask extends UserTask { _countDownController = StreamController.broadcast(); ongoingRecordingDuration = recordingDuration; state = UserTaskState.started; - backgroundTaskExecutor.start(); + backgroundTaskExecutor.resume(); try { backgroundTaskExecutor.measurements @@ -74,7 +68,7 @@ class AudioUserTask extends UserTask { void onRecordStop() { _timer?.cancel(); _countDownController?.close(); - backgroundTaskExecutor.stop(); + backgroundTaskExecutor.pause(); } /// Callback when recording is to start. @@ -83,7 +77,7 @@ class AudioUserTask extends UserTask { _timer?.cancel(); _countDownController?.close(); - backgroundTaskExecutor.stop(); + backgroundTaskExecutor.pause(); } } @@ -110,14 +104,14 @@ class VideoUserTask extends UserTask { _startRecordingTime = DateTime.now(); _endRecordingTime = DateTime.now(); - backgroundTaskExecutor.start(); + backgroundTaskExecutor.resume(); } /// Callback when video recording is started. void onRecordStart() { debug('$runtimeType - onRecordStart()'); _startRecordingTime = DateTime.now(); - backgroundTaskExecutor.start(); + backgroundTaskExecutor.resume(); } /// Callback when video recording is stopped. @@ -132,27 +126,31 @@ class VideoUserTask extends UserTask { /// the data stream. void onSave() { MediaData? media; - backgroundTaskExecutor.stop(); + backgroundTaskExecutor.pause(); if (_file != null) { // create the media measurement ... media = switch (_mediaType) { - MediaType.image => ImageMedia( - filename: _file!.path, - startRecordingTime: _startRecordingTime!, - endRecordingTime: _endRecordingTime) - ..filename = _file!.path.split("/").last - ..path = _file!.path, - MediaType.video => VideoMedia( - filename: _file!.path, - startRecordingTime: _startRecordingTime!, - endRecordingTime: _endRecordingTime) - ..filename = _file!.path.split("/").last - ..path = _file!.path, + MediaType.image => + ImageMedia( + filename: _file!.path, + startRecordingTime: _startRecordingTime!, + endRecordingTime: _endRecordingTime, + ) + ..filename = _file!.path.split("/").last + ..path = _file!.path, + MediaType.video => + VideoMedia( + filename: _file!.path, + startRecordingTime: _startRecordingTime!, + endRecordingTime: _endRecordingTime, + ) + ..filename = _file!.path.split("/").last + ..path = _file!.path, _ => null, }; // ... and add it to the sensing controller - if (media != null) bloc.addMeasurement(Measurement.fromData(media)); + if (media != null) bloc.study.addMeasurement(Measurement.fromData(media)); } super.onDone(result: media); } diff --git a/lib/view_models/view_model.dart b/lib/view_models/view_model.dart index 447535f5..716e0515 100644 --- a/lib/view_models/view_model.dart +++ b/lib/view_models/view_model.dart @@ -5,21 +5,22 @@ part of carp_study_app; /// Note that a view model is a [ChangeNotifier] and will notify its listeners /// if changed, including any [ListenableBuilder] widgets. abstract class ViewModel extends ChangeNotifier { - SmartphoneDeploymentController? _controller; + SmartphoneStudyController? _controller; - SmartphoneDeploymentController? get controller => _controller; + SmartphoneStudyController? get controller => _controller; /// Initialize this view model before use. @mustCallSuper - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { _controller = ctrl; } - /// Called when this view model is to clear its state (e.g., cached data). + /// Clear this view model, i.e. delete all data incl. cached data. @mustCallSuper void clear() {} - /// Called when this view model is disposed and no longer used. + /// Called when this view model is disposed. Typically on app exit, incl. when + /// closed by the OS. @override @mustCallSuper void dispose() { @@ -64,7 +65,7 @@ abstract class SerializableViewModel extends ViewModel { @override @mustCallSuper - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); _model = createModel(); @@ -75,8 +76,7 @@ abstract class SerializableViewModel extends ViewModel { }); // save the data model on a regular basis. - _persistenceTimer = - Timer.periodic(const Duration(minutes: 3), (_) => save()); + _persistenceTimer = Timer.periodic(const Duration(minutes: 3), (_) => save()); /// Check if we are running in a test environment. /// If so, do not listen to app lifecycle events. @@ -92,7 +92,7 @@ abstract class SerializableViewModel extends ViewModel { _filename = null; _persistenceTimer?.cancel(); _persistenceTimer = null; - save(); + delete(); } @override @@ -127,7 +127,7 @@ abstract class SerializableViewModel extends ViewModel { return success; } - /// Permanently delete the [model]. + /// Permanently delete the cached [model]. /// Returns true if successful, false otherwise. bool delete() { bool success = true; @@ -170,9 +170,7 @@ class DailyMeasure { /// Get the localized name of the [weekday]. @override - String toString() => DateFormat('EEEE') - .format(DateTime(2021, 2, 7).add(Duration(days: weekday))) - .substring(0, 3); + String toString() => DateFormat('EEEE').format(DateTime(2021, 2, 7).add(Duration(days: weekday))).substring(0, 3); } /// A measure for a specific hour of the day. [hour] and [minute] is the time of the day in 24 hour format. @@ -188,38 +186,35 @@ class HourlyMeasure { } /// The view model for the entire app. -class CarpStudyAppViewModel extends ViewModel { - final DataVisualizationPageViewModel _dataVisualizationPageViewModel = - DataVisualizationPageViewModel(); +class AppViewModel extends ViewModel { + final HomePageViewModel _homePageViewModel = HomePageViewModel(); + final LoginViewModel _loginViewModel = LoginViewModel(); + final DataVisualizationPageViewModel _dataVisualizationPageViewModel = DataVisualizationPageViewModel(); final StudyPageViewModel _studyPageViewModel = StudyPageViewModel(); final TaskListPageViewModel _taskListPageViewModel = TaskListPageViewModel(); final ProfilePageViewModel _profilePageViewModel = ProfilePageViewModel(); - final DeviceListPageViewModel _devicesPageViewModel = - DeviceListPageViewModel(); + final DeviceListPageViewModel _devicesPageViewModel = DeviceListPageViewModel(); final InvitationsViewModel _invitationsListViewModel = InvitationsViewModel(); - final InformedConsentViewModel _informedConsentViewModel = - InformedConsentViewModel(); - final ParticipantDataPageViewModel _participantDataPageViewModel = - ParticipantDataPageViewModel(); + final InformedConsentViewModel _informedConsentViewModel = InformedConsentViewModel(); + final ParticipantDataPageViewModel _participantDataPageViewModel = ParticipantDataPageViewModel(); - CarpStudyAppViewModel() : super(); + AppViewModel() : super(); - DataVisualizationPageViewModel get dataVisualizationPageViewModel => - _dataVisualizationPageViewModel; + HomePageViewModel get homePageViewModel => _homePageViewModel; + LoginViewModel get loginViewModel => _loginViewModel; + DataVisualizationPageViewModel get dataVisualizationPageViewModel => _dataVisualizationPageViewModel; StudyPageViewModel get studyPageViewModel => _studyPageViewModel; TaskListPageViewModel get taskListPageViewModel => _taskListPageViewModel; ProfilePageViewModel get profilePageViewModel => _profilePageViewModel; DeviceListPageViewModel get devicesPageViewModel => _devicesPageViewModel; - InvitationsViewModel get invitationsListViewModel => - _invitationsListViewModel; - InformedConsentViewModel get informedConsentViewModel => - _informedConsentViewModel; - ParticipantDataPageViewModel get participantDataPageViewModel => - _participantDataPageViewModel; + InvitationsViewModel get invitationsListViewModel => _invitationsListViewModel; + InformedConsentViewModel get informedConsentViewModel => _informedConsentViewModel; + ParticipantDataPageViewModel get participantDataPageViewModel => _participantDataPageViewModel; @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); + _homePageViewModel.init(ctrl); _taskListPageViewModel.init(ctrl); _studyPageViewModel.init(ctrl); _dataVisualizationPageViewModel.init(ctrl); @@ -233,6 +228,7 @@ class CarpStudyAppViewModel extends ViewModel { @override void clear() { + _homePageViewModel.clear(); _taskListPageViewModel.clear(); _studyPageViewModel.clear(); _dataVisualizationPageViewModel.clear(); @@ -247,6 +243,7 @@ class CarpStudyAppViewModel extends ViewModel { @override void dispose() { + _homePageViewModel.dispose(); _taskListPageViewModel.dispose(); _studyPageViewModel.dispose(); _dataVisualizationPageViewModel.dispose(); diff --git a/pubspec.lock b/pubspec.lock index f41a83b7..8760cb3e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "99.0.0" air_quality: dependency: transitive description: @@ -21,34 +21,34 @@ packages: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "12.1.0" app_version_update: dependency: "direct main" description: name: app_version_update - sha256: "9277bd6539b4ced74fbcabd5d6aba47b183b2f0ebc56e1d2689be0e6025ab458" + sha256: a7f351142be6d2ae1c504041e4c875a69dd1a97349b5a34ab16cdf20ac4d4810 url: "https://pub.dev" source: hosted - version: "6.1.3" + version: "6.2.0" appcheck: dependency: "direct main" description: name: appcheck - sha256: "6971b1ae5833b15b1abc29f7d03d08db79577b2934ceb34fe39ab937b53c2f17" + sha256: "63abf1ba508a86cd5f7bf134d202d008562ac9bbdbc713c92c28bf3f45beca1d" url: "https://pub.dev" source: hosted - version: "1.5.4+1" + version: "1.7.0" archive: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" audio_session: dependency: transitive description: @@ -85,10 +85,66 @@ packages: dependency: transitive description: name: audio_streamer - sha256: "77b4271058fb9273d51e59a47729c6c02d046adcae1c93bc71af26822705b2ab" + sha256: aed4bc55f57d65953b16344f5848239e1c9bd058cf5a6c23d7b4173f337d4ea8 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + audioplayers: + dependency: transitive + description: + name: audioplayers + sha256: f16640453cc47487b7de72a2b28d37c7df1ac97999849f4a46d92b1d2b0f093d + url: "https://pub.dev" + source: hosted + version: "6.7.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + url: "https://pub.dev" + source: hosted + version: "6.4.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "24a6f258062bd7da8cb2157e83fccb9816a08dd306cbaaa24f9813d071470545" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "95f875a96c88c3dbbcb608d4f8288e300b0113d256a81d0b3197fcc18f0dc91a" + url: "https://pub.dev" + source: hosted + version: "4.3.1" base_codecs: dependency: transitive description: @@ -101,10 +157,10 @@ packages: dependency: transitive description: name: battery_plus - sha256: "03d5a6bb36db9d2b977c548f6b0262d5a84c4d5a4cfee2edac4a91d57011b365" + sha256: ad16fcb55b7384be6b4bbc763d5e2031ac7ea62b2d9b6b661490c7b9741155bf url: "https://pub.dev" source: hosted - version: "6.2.3" + version: "7.0.0" battery_plus_platform_interface: dependency: transitive description: @@ -133,18 +189,18 @@ packages: dependency: transitive description: name: build - sha256: ce76b1d48875e3233fde17717c23d1f60a91cc631597e49a400c89b475395b1d + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.0.6" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" build_daemon: dependency: transitive description: @@ -153,30 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.1" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: d1d57f7807debd7349b4726a19fd32ec8bc177c71ad0febf91a20f84cd2d4b46 - url: "https://pub.dev" - source: hosted - version: "3.0.3" build_runner: dependency: "direct dev" description: name: build_runner - sha256: b24597fceb695969d47025c958f3837f9f0122e237c6a22cb082a5ac66c3ca30 + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" url: "https://pub.dev" source: hosted - version: "2.7.1" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "066dda7f73d8eb48ba630a55acb50c4a84a2e6b453b1cb4567f581729e794f7b" - url: "https://pub.dev" - source: hosted - version: "9.3.1" + version: "2.15.0" built_collection: dependency: transitive description: @@ -189,122 +229,122 @@ packages: dependency: transitive description: name: built_value - sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" url: "https://pub.dev" source: hosted - version: "8.12.0" + version: "8.12.6" camera: dependency: "direct main" description: name: camera - sha256: eefad89f262a873f38d21e5eec853461737ea074d7c9ede39f3ceb135d201cab + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" url: "https://pub.dev" source: hosted - version: "0.11.3" + version: "0.11.4" camera_android_camerax: dependency: "direct main" description: name: camera_android_camerax - sha256: d5256612833f9169c1698599a87370490622a188c5a7fb601169bb7b2f41f22b + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" url: "https://pub.dev" source: hosted - version: "0.6.24+1" + version: "0.6.30" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: "34bcd5db30e52414f1f0783c5e3f566909fab14141a21b3b576c78bd35382bf6" + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" url: "https://pub.dev" source: hosted - version: "0.9.22+4" + version: "0.9.23+2" camera_platform_interface: dependency: transitive description: name: camera_platform_interface - sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" + sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" camera_web: dependency: transitive description: name: camera_web - sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f" + sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7" url: "https://pub.dev" source: hosted - version: "0.3.5" + version: "0.3.5+3" carp_audio_package: dependency: "direct main" description: name: carp_audio_package - sha256: cdcc18d011c34b1e29ada818596e6b628669705609a490c3d71d704b18e4cc9e + sha256: f309c570207cc14a7dc22dfcf5eeef5d3bddef2147cc1bf77256af8e696f6007 url: "https://pub.dev" source: hosted - version: "1.10.3" + version: "2.0.0" carp_backend: dependency: "direct main" description: name: carp_backend - sha256: f92462c41a38cf85cf0b5f602126b123f60a96dcccfda3d2751dcdf0b650feec + sha256: f5998fd5cf14b54e5c159d6f3e18ad2b63c3b97d3062a592d311425dc45dcf4b url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "2.0.0" carp_connectivity_package: dependency: "direct main" description: name: carp_connectivity_package - sha256: "3a65729dadeead390071f5e54faf017c5d2977f492d1e709e3a501ffdb8b4951" + sha256: a23dfbe0ed987439a3af3d5228bf68d63c2b68a9e10f496561a8501736b36065 url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "2.0.0" carp_context_package: dependency: "direct main" description: name: carp_context_package - sha256: a1a708be991d8e7a66e43b43ad606c2915ac9c55851f5c57f63a61f35baefb6e + sha256: "838c9db9d2d14da005acd461fedd76519e4db606a20d1169d701362ef6ab5db7" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "2.0.2" carp_core: dependency: "direct main" description: name: carp_core - sha256: "1fcac456368fc32ec9738834b5e0e59132985cb6bf0d8157bbe8d8914ecb6fb2" + sha256: "2644ff9bacd05de0b8290e3abd74542c98380ee9c361e83be9ded86b9cb3ed7c" url: "https://pub.dev" source: hosted - version: "1.9.4" + version: "2.1.2" carp_health_package: dependency: "direct main" description: name: carp_health_package - sha256: db2ff8fdc4acd7fcbfc240fb4a53a7e0557766f188e4b0fc44a375ceebf50743 + sha256: "18e584a10c707e3582481396a119bbad9a942aad4628c166d134f6d04fb7a795" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.2" carp_mobile_sensing: dependency: "direct main" description: name: carp_mobile_sensing - sha256: c9b58ba07c7468a681619aad4ff87c7382b453538994a974bce63b87bd9959cc + sha256: "08578a423dcf62ad3589e9f23698ae26b910bc3d6fc3dfed6ba88a92b8b1a10d" url: "https://pub.dev" source: hosted - version: "1.13.1" + version: "2.1.6" carp_movesense_package: dependency: "direct main" description: name: carp_movesense_package - sha256: e17094a42fae084d26c7759459493389016d9efc9f1555c7a5044eec1469d87d + sha256: "3947c29511725fcd360a5b3ee09be0efdca48e4ae12e6ce26a2cc226fc333347" url: "https://pub.dev" source: hosted - version: "1.7.6" + version: "2.0.1" carp_polar_package: dependency: "direct main" description: name: carp_polar_package - sha256: "21446b411ffc3bcb34f93be33552e5a98cba59a162b2228d92fbdc21ffd7857a" + sha256: "9f73e858539c3567a917598189bcba4d93694d85da5609cb40c5e28849d07a72" url: "https://pub.dev" source: hosted - version: "1.6.2" + version: "2.0.1" carp_serializable: dependency: "direct main" description: @@ -317,26 +357,34 @@ packages: dependency: "direct main" description: name: carp_survey_package - sha256: "25c055e2267ace486beff4e31bf13eab7d6806f413a54020f74297b6d62fe6b7" + sha256: "5b20d8ccae6af04df6b7c77b8c0e5cf7aef2411f830d830329ba96651d75bae7" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "2.0.0" + carp_themes_package: + dependency: "direct main" + description: + name: carp_themes_package + sha256: "9753723406efa78fe7f379d3a5a51751acd427b89f5787bfc64f2b89eb3a8f78" + url: "https://pub.dev" + source: hosted + version: "0.0.5" carp_webservices: dependency: "direct main" description: name: carp_webservices - sha256: "6459af381237f38e65e6300763cddc04186c69e746ab97210f8134426e8a17dc" + sha256: "24e07669a9ff2f6886be2c34d5d331e3e7e8a8a71a640ec3d605a7d6439a9c59" url: "https://pub.dev" source: hosted - version: "3.8.0" + version: "4.1.0" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -369,14 +417,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" code_builder: dependency: transitive description: name: code_builder - sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.11.1" cognition_package: dependency: "direct main" description: @@ -397,18 +453,18 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "7.1.1" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" convert: dependency: transitive description: @@ -437,10 +493,10 @@ packages: dependency: transitive description: name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.15.1" cron: dependency: transitive description: @@ -453,10 +509,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.5" + version: "0.3.5+2" crypto: dependency: transitive description: @@ -485,34 +541,34 @@ packages: dependency: transitive description: name: dart_style - sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.8" data_serializer: dependency: transitive description: name: data_serializer - sha256: "547f06990e8e8abce76267bd7b8a071ddc8fe109316ae02402e761c892dd33f6" + sha256: "75e94d751e6f04ae6babc40c2436445c73691f60bb6d9eb7e026e50eb0ddb5de" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" dbus: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.14" dchs_flutter_beacon: dependency: transitive description: name: dchs_flutter_beacon - sha256: "392d56d69585845311fba75493e852fab78eb9b4669812758e6f475f861f9862" + sha256: d713882502946fe4189bc0ba6542c3670a3ed8670f1d5494da5e1c59394df26d url: "https://pub.dev" source: hosted - version: "0.6.6" + version: "0.6.8" desktop_webview_window: dependency: transitive description: @@ -525,10 +581,10 @@ packages: dependency: transitive description: name: device_info_plus - sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd url: "https://pub.dev" source: hosted - version: "11.5.0" + version: "12.4.0" device_info_plus_platform_interface: dependency: transitive description: @@ -557,10 +613,10 @@ packages: dependency: transitive description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" exception_templates: dependency: transitive description: @@ -589,10 +645,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -646,6 +702,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + flutter_background: + dependency: transitive + description: + name: flutter_background + sha256: ee47be26beddc59875158a93eeaaad0c0b5f3b21afbdad3de73a2903be824f0e + url: "https://pub.dev" + source: hosted + version: "1.3.1" flutter_blue_plus: dependency: "direct main" description: @@ -694,6 +758,11 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -706,34 +775,34 @@ packages: dependency: transitive description: name: flutter_local_notifications - sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1" url: "https://pub.dev" source: hosted - version: "19.5.0" + version: "21.0.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "8.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307 url: "https://pub.dev" source: hosted - version: "9.1.0" + version: "11.0.0" flutter_local_notifications_windows: dependency: transitive description: name: flutter_local_notifications_windows - sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "3.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -743,10 +812,10 @@ packages: dependency: "direct main" description: name: flutter_plugin_android_lifecycle - sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.32" + version: "2.0.35" flutter_secure_storage: dependency: transitive description: @@ -799,34 +868,34 @@ packages: dependency: transitive description: name: flutter_sound - sha256: ef89477f6e8ce2fa395158ebc4a8b11982e3ada440b4021c06fd97a4e771554b + sha256: "77f0252a2f08449d621f68b8fd617c3b391e7f862eaa94bb32f53cc2dc3bbe85" url: "https://pub.dev" source: hosted - version: "9.28.0" + version: "9.30.0" flutter_sound_platform_interface: dependency: transitive description: name: flutter_sound_platform_interface - sha256: "3394d7e664a09796818014ff85a81db0dec397f4c286cbe52f8783886fa5a497" + sha256: "5ffc858fd96c6fa277e3bb25eecc100849d75a8792b1f9674d1ba817aea9f861" url: "https://pub.dev" source: hosted - version: "9.28.0" + version: "9.30.0" flutter_sound_web: dependency: transitive description: name: flutter_sound_web - sha256: "4e10c94a8574bd93bb8668af59bf76f5312a890bccd3778d73168a7133217dc5" + sha256: "3af46f45f44768c01c83ba260855c956d0963673664947926d942aa6fbf6f6fb" url: "https://pub.dev" source: hosted - version: "9.28.0" + version: "9.30.0" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: "055de8921be7b8e8b98a233c7a5ef84b3a6fcc32f46f1ebf5b9bb3576d108355" + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -836,10 +905,10 @@ packages: dependency: transitive description: name: flutter_timezone - sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934" + sha256: "869677426fde92dbe170fb7d2d4929f2a8343c2f5f62f08b0bb64f908630b073" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "5.1.0" flutter_web_auth_2: dependency: transitive description: @@ -869,6 +938,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -897,10 +971,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c" + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" graphs: dependency: transitive description: @@ -913,10 +987,18 @@ packages: dependency: transitive description: name: health - sha256: "0432c4e5c5348164adff57e78ca3191c88f0cdf7c2b0d72b6785a6af965177ac" + sha256: "2d9e119f3a1d281139f93149b41032b9f80b759960875bc784c5a25dd3c17524" url: "https://pub.dev" source: hosted - version: "12.2.1" + version: "13.3.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" html: dependency: transitive description: @@ -929,10 +1011,10 @@ packages: dependency: transitive description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -953,10 +1035,15 @@ packages: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.8.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -989,6 +1076,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" jose_plus: dependency: transitive description: @@ -1009,18 +1112,18 @@ packages: dependency: "direct main" description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.12.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe" + sha256: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501 url: "https://pub.dev" source: hosted - version: "6.11.1" + version: "6.14.0" just_audio: dependency: transitive description: @@ -1089,18 +1192,18 @@ packages: dependency: transitive description: name: light - sha256: cb6551bf3ac336fcd5d0ce85ba3345a6f195785bb913f7541a976b4367aeb3a3 + sha256: e1f9b09222881390203a3f31ab2a19e1375e067840018bbdb49a8716e849bdcb url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "5.0.0" lints: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" list_operators: dependency: transitive description: @@ -1137,10 +1240,10 @@ packages: dependency: transitive description: name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.7.0" logging: dependency: transitive description: @@ -1153,18 +1256,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" mdsflutter: dependency: "direct main" description: @@ -1177,10 +1280,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1193,26 +1296,26 @@ packages: dependency: transitive description: name: mobility_features - sha256: "6c2f522f2fb6891234f65b0fd0743824567d90110beb461933cdd1cf5f69f0ba" + sha256: "102728cbd718451a462ad2ea28b5f0735b1920eafc6a00148fc3fa3195daea4b" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" mockito: dependency: "direct dev" description: name: mockito - sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99" + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 url: "https://pub.dev" source: hosted - version: "5.5.0" + version: "5.6.4" network_info_plus: dependency: transitive description: name: network_info_plus - sha256: f926b2ba86aa0086a0dfbb9e5072089bc213d854135c1712f1d29fc89ba3c877 + sha256: "2866dadcbee2709e20d67737a1556f5675b8b0cdcf2c1659ba74bc21bffede4f" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.0" network_info_plus_platform_interface: dependency: transitive description: @@ -1241,10 +1344,10 @@ packages: dependency: transitive description: name: noise_meter - sha256: "1eab67e5023c24c43757fa2b6b1b8bc43277e9cc6a7b0fb1c6f6537cc0e3116c" + sha256: a05ffa13ac533e4fcfb1dd772f43d7262c5130c80bbc1bf2842f7b15794f6264 url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.1.0" nonce: dependency: transitive description: @@ -1253,6 +1356,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" oidc: dependency: transitive description: @@ -1369,18 +1480,18 @@ packages: dependency: "direct main" description: name: open_settings_plus - sha256: "7576f0922a650dd5d9c128235cb7537e56a99a5f3ac0943a4a07613cc25c4582" + sha256: "17c5d1c90affd224b0c19ee3dc5bbcff39b630889a90912ba1441c7e821fa914" url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.2" openmhealth_schemas: dependency: transitive description: name: openmhealth_schemas - sha256: "52fe06194d5cc44721827a67432253444fe2dc245d2dc6649bf97b2a5b82b3a9" + sha256: "04dfadafe6cde266846fc778448bb8c13a798066e0e9e54caf213279284bad2e" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.3.1" package_config: dependency: transitive description: @@ -1393,10 +1504,10 @@ packages: dependency: transitive description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "9.0.1" package_info_plus_platform_interface: dependency: transitive description: @@ -1433,18 +1544,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.20" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -1473,34 +1584,34 @@ packages: dependency: transitive description: name: pedometer - sha256: "71e2608ad472d1fc3bcfce14a151e5c8229c5edd9e2a8a0f1ec416e3ceb00e1a" + sha256: "146d9663d4af6041328ac15cd96cdcd426b866d277d72f540f701193a61a193d" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.2.0" permission_handler: dependency: "direct main" description: name: permission_handler - sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 url: "https://pub.dev" source: hosted - version: "11.4.0" + version: "12.0.3" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" url: "https://pub.dev" source: hosted - version: "12.1.0" + version: "13.0.1" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" url: "https://pub.dev" source: hosted - version: "9.4.7" + version: "9.4.10" permission_handler_html: dependency: transitive description: @@ -1529,10 +1640,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -1561,10 +1672,10 @@ packages: dependency: transitive description: name: polar - sha256: "703e13b2c0646d2327c3ef7e83ed09ed5f01f6de14d8813d9b62951db1f1357f" + sha256: f0c168159a57f12863981d1dba14dc3622582f54ef0ce9658c377804997dc7c7 url: "https://pub.dev" source: hosted - version: "7.6.0" + version: "7.10.0" pool: dependency: transitive description: @@ -1577,10 +1688,18 @@ packages: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.0" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" protobuf: dependency: transitive description: @@ -1609,10 +1728,10 @@ packages: dependency: "direct main" description: name: qr_code_scanner_plus - sha256: b764e5004251c58d9dee0c295e6006e05bd8d249e78ac3383abdb5afe0a996cd + sha256: "2bcf9df32aa639fba07c183b1ab9c3f03eb6b1d688ab4f4f6bc868a75afd824d" url: "https://pub.dev" source: hosted - version: "2.0.14" + version: "2.1.2" recase: dependency: transitive description: @@ -1621,6 +1740,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" reorderables: dependency: transitive description: @@ -1633,10 +1760,10 @@ packages: dependency: "direct main" description: name: research_package - sha256: "956cc0a8eba67174d17ac686998ef65c0b553cffec4b33fee507ddb51b9c29fd" + sha256: "399b3ed901aeb35641039dcf0a9ae3c96288054e30833b6ee4ae4cbbce566a3d" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.3.0" retry: dependency: transitive description: @@ -1665,18 +1792,18 @@ packages: dependency: transitive description: name: screen_state - sha256: de85988dc39f03ab1a8bdadfdd4b0592d71842c161b065a6660dad62d4e42385 + sha256: ee9dd1d902bd0ce8af921f8cbbc88713280498528c5229ebdfb2138a5d70e333 url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "5.0.2" sensors_plus: dependency: transitive description: name: sensors_plus - sha256: "89e2bfc3d883743539ce5774a2b93df61effde40ff958ecad78cd66b1a8b8d52" + sha256: "56e8cd4260d9ed8e00ecd8da5d9fdc8a1b2ec12345a750dfa51ff83fcf12e3fa" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "7.0.0" sensors_plus_platform_interface: dependency: transitive description: @@ -1689,26 +1816,26 @@ packages: dependency: transitive description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" + sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" url: "https://pub.dev" source: hosted - version: "2.4.15" + version: "2.4.26" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -1721,10 +1848,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -1798,18 +1925,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3" + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.2.3" source_helper: dependency: transitive description: name: source_helper - sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" url: "https://pub.dev" source: hosted - version: "1.3.8" + version: "1.3.12" source_map_stack_trace: dependency: transitive description: @@ -1830,50 +1957,50 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sqflite: dependency: transitive description: name: sqflite - sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b url: "https://pub.dev" source: hosted - version: "2.4.2+2" + version: "2.4.3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.5.11" sqflite_darwin: dependency: transitive description: name: sqflite_darwin - sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + sha256: "164a5d73ab87a134566057219988bafde837029a64264e61f1f04376ef3cfcd2" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" sqflite_platform_interface: dependency: transitive description: name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" stack_trace: dependency: transitive description: @@ -1886,18 +2013,18 @@ packages: dependency: transitive description: name: statistics - sha256: "83ff786a263790576cedc47a03806abd89e7c8cae0eb0d745bb50032838826fa" + sha256: "9da2eb983faee4a3be1f712f08305c5d93f1e37017d110b5c8d0e83736b5fb7f" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" stats: dependency: transitive description: name: stats - sha256: "35dba13a465ac4f788d2f21211bb4736902f49ef02471ba39337d56c4644290c" + sha256: f08a0b8dcf95cc68960e85eaa21c80e8837fb816c719a99a0edd36c44831d403 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" stream_channel: dependency: transitive description: @@ -1922,14 +2049,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.1" system_info2: dependency: transitive description: @@ -1950,26 +2085,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.17" timeago: dependency: "direct main" description: @@ -1982,18 +2117,10 @@ packages: dependency: transitive description: name: timezone - sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 - url: "https://pub.dev" - source: hosted - version: "0.10.1" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "0.11.0" typed_data: dependency: transitive description: @@ -2006,18 +2133,18 @@ packages: dependency: transitive description: name: universal_html - sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" + sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4 url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.3.0" universal_io: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.1" upower: dependency: transitive description: @@ -2038,34 +2165,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 url: "https://pub.dev" source: hosted - version: "6.3.24" + version: "6.3.32" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.5" + version: "6.4.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -2078,34 +2205,34 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: transitive description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.2" vector_graphics_codec: dependency: transitive description: @@ -2118,10 +2245,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.5" vector_math: dependency: transitive description: @@ -2134,34 +2261,34 @@ packages: dependency: "direct main" description: name: video_player - sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf" + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" url: "https://pub.dev" source: hosted - version: "2.10.1" + version: "2.11.1" video_player_android: dependency: transitive description: name: video_player_android - sha256: cf768d02924b91e333e2bc1ff928528f57d686445874f383bafab12d0bdfc340 + sha256: "5d18d04084cc0cfc7afde39d0a308d4041e8ae6e9d5255bc086c263998dd1201" url: "https://pub.dev" source: hosted - version: "2.8.17" + version: "2.9.6" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "03fc6d07dba2499588d30887329b399c1fe2d68ce4b7fcff0db79f44a2603f69" + sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58" url: "https://pub.dev" source: hosted - version: "2.8.6" + version: "2.9.7" video_player_platform_interface: dependency: transitive description: name: video_player_platform_interface - sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" + sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0" url: "https://pub.dev" source: hosted - version: "6.6.0" + version: "6.7.0" video_player_web: dependency: transitive description: @@ -2174,18 +2301,18 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.2.0" watcher: dependency: transitive description: name: watcher - sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "1.2.1" weather: dependency: transitive description: @@ -2218,6 +2345,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" webkit_inspection_protocol: dependency: transitive description: @@ -2246,10 +2381,10 @@ packages: dependency: transitive description: name: window_to_front - sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241" url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.0.4" x509_plus: dependency: transitive description: @@ -2283,5 +2418,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 428642a4..99460214 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,32 +1,33 @@ name: carp_study_app description: The generic CARP study app. publish_to: "none" -version: 4.1.1+88 +version: 4.4.0+1 environment: - sdk: ">=3.2.0 <4.0.0" - flutter: ">=3.16.0" + sdk: ">=3.8.0 <4.0.0" + flutter: ">=3.32.0" dependencies: flutter: sdk: flutter - carp_serializable: ^2.0.0 - carp_core: ^1.9.0 - carp_mobile_sensing: ^1.13.0 - carp_context_package: ^1.10.0 - carp_connectivity_package: ^1.7.0 - carp_survey_package: ^1.8.2 - carp_audio_package: ^1.10.0 - carp_polar_package: ^1.6.1 - carp_health_package: ^3.2.0 - carp_movesense_package: ^1.7.2 + carp_serializable: ^2.0.1 + carp_core: ^2.1.2 + carp_mobile_sensing: ^2.1.6 + carp_context_package: ^2.0.2 + carp_connectivity_package: ^2.0.0 + carp_survey_package: ^2.0.0 + carp_audio_package: ^2.0.0 + carp_polar_package: ^2.0.0 + carp_health_package: ^4.0.2 + carp_movesense_package: ^2.0.0 + carp_themes_package: ^0.0.5 - carp_webservices: ^3.8.0 - carp_backend: ^1.9.2 + carp_webservices: ^4.0.0 + carp_backend: ^2.0.0 - research_package: ^2.0.0 - cognition_package: ^1.6.1 + research_package: ^2.2.0 + cognition_package: ^1.7.0 url_launcher: ^6.1.5 timeago: ^3.1.0 @@ -38,15 +39,15 @@ dependencies: flutter_localizations: sdk: flutter - intl: '>= 0.19.0 <= 0.21.0' + intl: ">= 0.19.0 <= 0.21.0" fl_chart: 1.0.0 google_fonts: ^6.1.0 go_router: ^15.0.0 flutter_svg: ^2.0.4 flutter_blue_plus: ^1.15.5 - permission_handler: ^11.0.1 - connectivity_plus: ^6.0.0 + permission_handler: ^12.0.3 + connectivity_plus: ^7.0.0 open_settings_plus: ^0.4.0 appcheck: ^1.5.2 flutter_plugin_android_lifecycle: ^2.0.24 @@ -69,45 +70,46 @@ dependency_overrides: # carp_core: # path: ../carp/carp.sensing-flutter/carp_core/ # carp_mobile_sensing: - # path: ../carp/carp.sensing-flutter/carp_mobile_sensing/ + # path: ../carp.sensing-flutter/carp_mobile_sensing/ # carp_context_package: - # path: ../carp/carp.sensing-flutter/packages/carp_context_package/ + # path: ../carp.sensing-flutter/packages/carp_context_package/ + # carp_esense_package: + # path: ../carp.sensing-flutter/packages/carp_esense_package/ + # carp_health_package: + # path: ../carp.sensing-flutter/packages/carp_health_package/ + # carp_polar_package: + # path: ../carp.sensing-flutter/packages/carp_polar_package/ + # carp_movisens_package: + # path: ../carp.sensing-flutter/packages/carp_movisens_package/ + # carp_movesense_package: + # path: ../carp.sensing-flutter/packages/carp_movesense_package/ # carp_connectivity_package: - # path: ../../packages/carp_connectivity_package/ + # path: ../carp.sensing-flutter/packages/carp_connectivity_package/ # carp_survey_package: - # path: ../carp/carp.sensing-flutter/packages/carp_survey_package/ + # path: ../carp.sensing-flutter/packages/carp_survey_package/ # carp_communication_package: - # path: ../../packages/carp_communication_package/ + # path: ../carp.sensing-flutter/packages/carp_communication_package/ # carp_audio_package: - # path: ../carp/carp.sensing-flutter/packages/carp_audio_package/ + # path: ../carp.sensing-flutter/packages/carp_audio_package/ # carp_apps_package: - # path: ../../packages/carp_apps_package/ - # carp_esense_package: - # path: ../../packages/carp_esense_package/ - # carp_polar_package: - # path: ../carp/carp.sensing-flutter/packages/carp_polar_package/ - # carp_movisens_package: - # path: ../../packages/carp_movisens_package/ - # carp_health_package: - # path: ../carp/carp.sensing-flutter/packages/carp_health_package/ - # carp_movesense_package: - # path: ../carp/carp.sensing-flutter/packages/carp_movesense_package/ + # path: ../carp.sensing-flutter/packages/carp_apps_package/ # carp_webservices: # path: ../carp.sensing-flutter/backends/carp_webservices/ # carp_backend: - # path: ../carp/carp.sensing-flutter/backends/carp_backend/ - # research_package: + # path: ../carp.sensing-flutter/backends/carp_backend/ + # research_package: # path: ../research.package/ # cognition_package: # path: ../cognition_package - dev_dependencies: test: any json_serializable: any build_runner: any flutter_test: sdk: flutter + integration_test: + sdk: flutter mockito: any flutter_lints: any @@ -118,8 +120,14 @@ dev_dependencies: # 3. run 'flutter pub get' # 3. run 'dart run flutter_launcher_icons:main' - flutter: + # Route all iOS native deps through CocoaPods. The Polar BLE SDK (via the + # `polar` plugin) and Movesense (`mdsflutter`, CocoaPods-only) both pull in + # SwiftProtobuf; with Swift Package Manager enabled Polar resolves it via SPM + # while Movesense uses CocoaPods, producing duplicate-symbol link errors. + config: + enable-swift-package-manager: false + uses-material-design: true assets: diff --git a/test/app_bloc_test.dart b/test/app_bloc_test.dart new file mode 100644 index 00000000..87da0f97 --- /dev/null +++ b/test/app_bloc_test.dart @@ -0,0 +1,41 @@ +import 'package:cognition_package/cognition_package.dart'; +import 'package:research_package/research_package.dart'; + +import 'exports.dart'; +import 'test_utils.dart'; + +void main() { + setUpAll(() async { + CarpMobileSensing.ensureInitialized(); + ResearchPackage.ensureInitialized(); + CognitionPackage.ensureInitialized(); + await initTestSettings(); + AppConfig.deploymentMode = DeploymentMode.local; + }); + + group('AppBloc.configureStudy', () { + test('is atomic and retryable - a failed configuration resets the state', () async { + final bloc = AppBloc(); + LocalSettings().study = SmartphoneStudy(studyDeploymentId: 'dep-bloc-1', deviceRoleName: 'phone'); + + // Sensing cannot be set up in a unit test environment, so the + // configuration fails - the state must roll back instead of being + // stuck in `configuring` (which would block any retry forever). + await expectLater(bloc.configureStudy(), throwsA(anything)); + expect(bloc.state, AppState.created); + expect(bloc.isConfiguring, isFalse); + + // A retry must run (and fail) again - not silently return. + await expectLater(bloc.configureStudy(), throwsA(anything)); + expect(bloc.state, AppState.created); + }); + + test('throws when no study has been set', () async { + final bloc = AppBloc(); + await LocalSettings().eraseStudyDeployment(); + + await expectLater(bloc.configureStudy(), throwsStateError); + expect(bloc.isConfiguring, isFalse); + }); + }); +} diff --git a/test/cams_app_test.dart b/test/cams_app_test.dart index 120ac75a..6354d7dd 100644 --- a/test/cams_app_test.dart +++ b/test/cams_app_test.dart @@ -1,19 +1,9 @@ import 'dart:convert'; import 'dart:io'; -import 'package:carp_survey_package/survey.dart'; -import 'package:cognition_package/model.dart'; - -import 'package:carp_connectivity_package/connectivity.dart'; -// import 'package:carp_esense_package/esense.dart'; -import 'package:carp_context_package/carp_context_package.dart'; -import 'package:carp_audio_package/media.dart'; -// import 'package:carp_communication_package/communication.dart'; -// import 'package:carp_apps_package/apps.dart'; + import 'package:carp_backend/carp_backend.dart'; import 'package:research_package/research_package.dart'; -// import 'package:carp_webservices/carp_auth/carp_auth.dart'; -// import 'package:carp_webservices/carp_services/carp_services.dart'; -import 'package:carp_movesense_package/carp_movesense_package.dart'; +import 'package:cognition_package/cognition_package.dart'; import 'exports.dart'; @@ -27,26 +17,116 @@ void main() { CognitionPackage.ensureInitialized(); CarpDataManager(); - // register the different sampling package since we're using measures from them - SamplingPackageRegistry().register(ConnectivitySamplingPackage()); - SamplingPackageRegistry().register(ContextSamplingPackage()); - SamplingPackageRegistry().register(MediaSamplingPackage()); - // SamplingPackageRegistry().register(CommunicationSamplingPackage()); - // SamplingPackageRegistry().register(AppsSamplingPackage()); - // SamplingPackageRegistry().register(ESenseSamplingPackage()); - SamplingPackageRegistry().register(PolarSamplingPackage()); - SamplingPackageRegistry().register(MovesenseSamplingPackage()); - SamplingPackageRegistry().register(SurveySamplingPackage()); + // Register exactly what the app registers - sampling packages plus the + // old-namespace device aliases - so these tests exercise the real + // deserialization path. See Sensing. + Sensing(); }); - group("Local Study Protocol Manager", () { - // skipping this test since it is throwing strange "asUnmodifiableView" errors....? - test('JSON File -> StudyProtocol', skip: true, () async { - final plainJson = - File('test/json/study_protocol.json').readAsStringSync(); + group("Study protocol deserialization", () { + // Fixtures are real study protocols with the apiKey fields blanked. + SmartphoneStudyProtocol parse(String path) => + SmartphoneStudyProtocol.fromJson(json.decode(File(path).readAsStringSync()) as Map); + + // The demo protocol's devices, by concrete runtime class and role name. + // Asserting the runtime type (not just "non-empty") is what proves each + // device's '__type' resolved to its real class - via the Sensing aliases + // for the 1.x namespace, and natively for 2.x. A regression in either path + // would surface as a missing/wrong type here (or a SerializationException). + const expectedConnectedTypes = { + 'LocationService', + 'WeatherService', + 'AirQualityService', + 'HealthService', + 'PolarDevice', + 'MovesenseDevice', + }; + const expectedConnectedRoles = { + 'Location Service', + 'Weather Service', + 'Air Quality Service', + 'Health Service', + 'Polar HR Sensor', + 'Movesense ECG Device', + }; + + // The data-collection (Measure) types the protocol declares across all + // tasks - the sensors/services actually sampled, plus CAMS control types. + const expectedDataTypes = { + // device & sensor sampling + 'dk.cachet.carp.ambientlight', + 'dk.cachet.carp.stepcount', + 'dk.cachet.carp.freememory', + 'dk.cachet.carp.deviceinformation', + 'dk.cachet.carp.batterystate', + 'dk.cachet.carp.screenevent', + 'dk.cachet.carp.activity', + // context services + 'dk.cachet.carp.location', + 'dk.cachet.carp.mobility', + 'dk.cachet.carp.weather', + 'dk.cachet.carp.airquality', + // wearables + 'dk.cachet.carp.polar.hr', + 'dk.cachet.carp.movesense.hr', + 'dk.cachet.carp.movesense.state', + // health + 'dk.cachet.carp.health', + // app tasks / media / survey + 'dk.cachet.carp.audio', + 'dk.cachet.carp.image', + 'dk.cachet.carp.survey', + // CAMS control & app-task lifecycle + 'dk.cachet.carp.error', + 'dk.cachet.carp.heartbeat', + 'dk.cachet.carp.triggeredtask', + 'dk.cachet.carp.completedtask', + 'dk.cachet.carp.completedapptask', + }; + + void expectDemoProtocol(SmartphoneStudyProtocol protocol) { + // Primary device: a single Smartphone. + expect(protocol.primaryDevices.map((d) => d.runtimeType.toString()), ['Smartphone']); + expect(protocol.primaryDevices.single.roleName, 'Primary Phone'); + + // Connected devices/services: all six resolved to their concrete classes. + final connected = protocol.connectedDevices!; + expect(connected.length, 6); + expect(connected.map((d) => d.runtimeType.toString()).toSet(), expectedConnectedTypes); + expect(connected.map((d) => d.roleName).toSet(), expectedConnectedRoles); + + // Task graph is fully parsed. + expect(protocol.tasks.length, 21); + expect(protocol.taskControls.length, 21); + + // Data-collection types: the protocol declares all the expected sensor/ + // service/app-task measures (it also adds dynamic per-task + // 'completedapptask.' lifecycle measures, which we don't pin), and + // every expected type is provided by a registered sampling package - i.e. + // the app can actually collect it. A namespace/registration regression in + // any package would drop a type from one side or the other. + final declaredTypes = {for (final task in protocol.tasks) ...?task.measures?.map((m) => m.type)}; + expect(declaredTypes, containsAll(expectedDataTypes)); + + final supportedTypes = SamplingPackageRegistry().dataTypes.map((d) => d.type).toSet(); + expect( + expectedDataTypes.difference(supportedTypes), + isEmpty, + reason: 'these declared data types are not registered in any sampling package', + ); + } + + // CAMS 2.x must parse protocols serialized under the old (CAMS 1.x) device + // namespace 'dk.cachet.carp.common.application.devices.*', resolved via the + // aliases registered in Sensing. The configurations repo still generates + // protocols with CAMS 1.x, so this is the format the app receives today. + test('parses a CAMS 1.x protocol (old device namespace)', () { + expectDemoProtocol(parse('test/json/protocol_cams_1x.json')); + }); - SmartphoneStudyProtocol.fromJson( - json.decode(plainJson) as Map); + // ...and natively under the CAMS 2.x namespace 'dk.carp.cams.devices.*'. + test('parses a CAMS 2.x protocol (new device namespace)', () { + expectDemoProtocol(parse('test/json/protocol_cams_2x.json')); }); }); } diff --git a/test/exports.dart b/test/exports.dart index 93c84f57..a5d89980 100644 --- a/test/exports.dart +++ b/test/exports.dart @@ -6,6 +6,6 @@ export 'package:mockito/mockito.dart'; export 'package:mockito/annotations.dart'; export 'package:carp_mobile_sensing/carp_mobile_sensing.dart'; export 'package:carp_polar_package/carp_polar_package.dart'; -export 'package:carp_core/carp_core.dart'; +export 'package:carp_core/carp_core.dart' hide Smartphone, BLEHeartRateDevice; export 'heart_rate_data_model_test.mocks.dart'; diff --git a/test/heart_rate_data_model_test.dart b/test/heart_rate_data_model_test.dart index a723ca6f..397b6056 100644 --- a/test/heart_rate_data_model_test.dart +++ b/test/heart_rate_data_model_test.dart @@ -1,126 +1,22 @@ import 'exports.dart'; -@GenerateNiceMocks([ - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), -]) +@GenerateNiceMocks([MockSpec()]) void main() { - setUp(() {}); group("HeartRateCardViewModel", () { - test('initializes HeartRateCardViewModel', skip: true, () { - final controller = MockSmartphoneDeploymentController(); - final model = MockHeartRateCardViewModel(); - final dataModel = MockHourlyHeartRate(); - when(model.createModel()).thenReturn(dataModel); + test('initializes with an empty heart rate model', () { + final controller = MockSmartphoneStudyController(); + when(controller.measurements).thenAnswer((_) => const Stream.empty()); final viewModel = HeartRateCardViewModel(); viewModel.init(controller); - verify(model.createModel()); - }); - group('init', () { - group('should listen to heart rate events', () { - final mockSmartphoneDeploymentController = - MockSmartphoneDeploymentController(); - final mockPolarHRSample = MockPolarHRSample(); - final mockPolarHRDatum = MockPolarHR(); - final mockMeasurement = MockMeasurement(); - final viewModel = HeartRateCardViewModel(); - final heartRateStreamController = - StreamController.broadcast(); - - setUp(() { - when(mockSmartphoneDeploymentController.measurements) - .thenAnswer((_) => heartRateStreamController.stream); - - viewModel.init(mockSmartphoneDeploymentController); - }); - tearDownAll(() { - heartRateStreamController.close(); - }); - group('and update model variables', () { - test('with one event', () async { - // Add a heart rate data point to the stream - when(mockPolarHRSample.hr).thenReturn(80); - when(mockPolarHRDatum.samples).thenReturn([mockPolarHRSample]); - when(mockMeasurement.data).thenReturn(mockPolarHRDatum); - - heartRateStreamController.sink.add(mockMeasurement); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(viewModel.currentHeartRate, equals(80.0)); - expect(viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(80, 80))); - expect( - viewModel.hourlyHeartRate, - equals((HourlyHeartRate().addHeartRate(DateTime.now().hour, 80)) - .hourlyHeartRate)); - }); - test('with multiple events', () async { - // Add a heart rate data point to the stream - when(mockPolarHRSample.hr).thenReturn(90); - when(mockPolarHRDatum.samples).thenReturn([mockPolarHRSample]); - when(mockMeasurement.data).thenReturn(mockPolarHRDatum); - - heartRateStreamController.sink.add(mockMeasurement); - - await Future.delayed(const Duration(milliseconds: 100)); - when(mockPolarHRSample.hr).thenReturn(60); - when(mockPolarHRDatum.samples).thenReturn([mockPolarHRSample]); - when(mockMeasurement.data).thenReturn(mockPolarHRDatum); - - heartRateStreamController.sink.add(mockMeasurement); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(viewModel.currentHeartRate, equals(60)); - expect(viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(60, 90))); - expect( - viewModel.hourlyHeartRate, - equals((HourlyHeartRate() - .addHeartRate(DateTime.now().hour, 60) - .addHeartRate(DateTime.now().hour, 90)) - .hourlyHeartRate)); - }); - test('with events with data that is 0', () async { - // Add a heart rate data point to the stream - when(mockPolarHRSample.hr).thenReturn(0); - when(mockPolarHRDatum.samples).thenReturn([mockPolarHRSample]); - when(mockMeasurement.data).thenReturn(mockPolarHRDatum); - - heartRateStreamController.sink.add(mockMeasurement); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(viewModel.currentHeartRate, equals(null)); - expect( - viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(null, null))); - expect(viewModel.hourlyHeartRate, - equals((HourlyHeartRate()).hourlyHeartRate)); - // expect(viewModel.skinContact, equals(false)); - }); - test('with contactStatus being true', () async { - // Add a heart rate data point to the stream - when(mockPolarHRSample.hr).thenReturn(1); - when(mockPolarHRSample.contactStatusSupported).thenReturn(true); - when(mockPolarHRSample.contactStatus).thenReturn(true); - when(mockPolarHRDatum.samples).thenReturn([mockPolarHRSample]); - when(mockMeasurement.data).thenReturn(mockPolarHRDatum); - - heartRateStreamController.sink.add(mockMeasurement); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(viewModel.currentHeartRate, equals(anything)); - expect(viewModel.dayMinMax, equals(anything)); - expect(viewModel.hourlyHeartRate, equals(anything)); - // expect(viewModel.skinContact, equals(true)); - }); - }); - }); + expect(viewModel.model, isA()); + expect(viewModel.currentHeartRate, isNull); + // No data yet: every hour bucket is present but empty. + expect(viewModel.hourlyHeartRate.values, everyElement(HeartRateMinMaxPrHour(null, null))); }); }); + group('HourlyHeartRate', () { test('resetDataAtMidnight', () { // create an HourlyHeartRate object with some data @@ -129,8 +25,7 @@ void main() { hr.hourlyHeartRate[13] = HeartRateMinMaxPrHour(75, 85); hr.maxHeartRate = 85; hr.minHeartRate = 70; - hr.lastUpdated = - DateTime.now().subtract(const Duration(days: 1)); // yesterday + hr.lastUpdated = DateTime.now().subtract(const Duration(days: 1)); // yesterday // call resetDataAtMidnight hr.resetDataAtMidnight(); @@ -182,12 +77,3 @@ void main() { }); }); } - -class MockSerializableViewModel extends Mock implements SerializableViewModel {} - -SerializableViewModel restore() { - var viewModel = MockSerializableViewModel(); - when(viewModel.restore).thenReturn(() async => MockDataModel()); - - return viewModel; -} diff --git a/test/heart_rate_data_model_test.mocks.dart b/test/heart_rate_data_model_test.mocks.dart index 6d4076ff..2c04d8ba 100644 --- a/test/heart_rate_data_model_test.mocks.dart +++ b/test/heart_rate_data_model_test.mocks.dart @@ -3,16 +3,13 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i7; -import 'dart:ui' as _i8; +import 'dart:async' as _i6; import 'package:carp_core/carp_core.dart' as _i3; import 'package:carp_mobile_sensing/carp_mobile_sensing.dart' as _i2; -import 'package:carp_polar_package/carp_polar_package.dart' as _i9; -import 'package:carp_study_app/main.dart' as _i4; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i6; -import 'package:permission_handler/permission_handler.dart' as _i5; +import 'package:mockito/src/dummies.dart' as _i5; +import 'package:permission_handler/permission_handler.dart' as _i4; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -27,1073 +24,137 @@ import 'package:permission_handler/permission_handler.dart' as _i5; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member -class _FakeDeviceController_0 extends _i1.SmartFake - implements _i2.DeviceController { - _FakeDeviceController_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); +class _FakeSmartphoneStudy_0 extends _i1.SmartFake implements _i2.SmartphoneStudy { + _FakeSmartphoneStudy_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeSmartphoneDeploymentExecutor_1 extends _i1.SmartFake - implements _i2.SmartphoneDeploymentExecutor { - _FakeSmartphoneDeploymentExecutor_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); +class _FakeSmartphoneDeploymentExecutor_1 extends _i1.SmartFake implements _i2.SmartphoneDeploymentExecutor { + _FakeSmartphoneDeploymentExecutor_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeData_2 extends _i1.SmartFake implements _i3.Data { - _FakeData_2( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeDeploymentService_3 extends _i1.SmartFake - implements _i3.DeploymentService { - _FakeDeploymentService_3( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeHeartRateMinMaxPrHour_4 extends _i1.SmartFake - implements _i4.HeartRateMinMaxPrHour { - _FakeHeartRateMinMaxPrHour_4( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeHourlyHeartRate_5 extends _i1.SmartFake - implements _i4.HourlyHeartRate { - _FakeHourlyHeartRate_5( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeDateTime_6 extends _i1.SmartFake implements DateTime { - _FakeDateTime_6( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeDataType_7 extends _i1.SmartFake implements _i3.DataType { - _FakeDataType_7( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeDataModel_8 extends _i1.SmartFake implements _i4.DataModel { - _FakeDataModel_8( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -/// A class which mocks [SmartphoneDeploymentController]. +/// A class which mocks [SmartphoneStudyController]. /// /// See the documentation for Mockito's code generation for more information. -class MockSmartphoneDeploymentController extends _i1.Mock - implements _i2.SmartphoneDeploymentController { +class MockSmartphoneStudyController extends _i1.Mock implements _i2.SmartphoneStudyController { @override - _i2.DeviceController get deviceRegistry => (super.noSuchMethod( - Invocation.getter(#deviceRegistry), - returnValue: _FakeDeviceController_0( - this, - Invocation.getter(#deviceRegistry), - ), - returnValueForMissingStub: _FakeDeviceController_0( - this, - Invocation.getter(#deviceRegistry), - ), - ) as _i2.DeviceController); - - @override - Map<_i5.Permission, _i5.PermissionStatus> get permissions => + _i2.SmartphoneStudy get study => (super.noSuchMethod( - Invocation.getter(#permissions), - returnValue: <_i5.Permission, _i5.PermissionStatus>{}, - returnValueForMissingStub: <_i5.Permission, _i5.PermissionStatus>{}, - ) as Map<_i5.Permission, _i5.PermissionStatus>); - - @override - _i2.SmartphoneDeploymentExecutor get executor => (super.noSuchMethod( - Invocation.getter(#executor), - returnValue: _FakeSmartphoneDeploymentExecutor_1( - this, - Invocation.getter(#executor), - ), - returnValueForMissingStub: _FakeSmartphoneDeploymentExecutor_1( - this, - Invocation.getter(#executor), - ), - ) as _i2.SmartphoneDeploymentExecutor); - - @override - String get privacySchemaName => (super.noSuchMethod( - Invocation.getter(#privacySchemaName), - returnValue: _i6.dummyValue( - this, - Invocation.getter(#privacySchemaName), - ), - returnValueForMissingStub: _i6.dummyValue( - this, - Invocation.getter(#privacySchemaName), - ), - ) as String); - - @override - _i2.DataTransformer get transformer => (super.noSuchMethod( - Invocation.getter(#transformer), - returnValue: (_i3.Data __p0) => _FakeData_2( - this, - Invocation.getter(#transformer), - ), - returnValueForMissingStub: (_i3.Data __p0) => _FakeData_2( - this, - Invocation.getter(#transformer), - ), - ) as _i2.DataTransformer); + Invocation.getter(#study), + returnValue: _FakeSmartphoneStudy_0(this, Invocation.getter(#study)), + returnValueForMissingStub: _FakeSmartphoneStudy_0(this, Invocation.getter(#study)), + ) + as _i2.SmartphoneStudy); @override - _i7.Stream<_i3.Measurement> get measurements => (super.noSuchMethod( - Invocation.getter(#measurements), - returnValue: _i7.Stream<_i3.Measurement>.empty(), - returnValueForMissingStub: _i7.Stream<_i3.Measurement>.empty(), - ) as _i7.Stream<_i3.Measurement>); - - @override - int get samplingSize => (super.noSuchMethod( - Invocation.getter(#samplingSize), - returnValue: 0, - returnValueForMissingStub: 0, - ) as int); - - @override - _i3.DeploymentService get deploymentService => (super.noSuchMethod( - Invocation.getter(#deploymentService), - returnValue: _FakeDeploymentService_3( - this, - Invocation.getter(#deploymentService), - ), - returnValueForMissingStub: _FakeDeploymentService_3( - this, - Invocation.getter(#deploymentService), - ), - ) as _i3.DeploymentService); - - @override - _i7.Stream<_i3.StudyStatus> get statusEvents => (super.noSuchMethod( - Invocation.getter(#statusEvents), - returnValue: _i7.Stream<_i3.StudyStatus>.empty(), - returnValueForMissingStub: _i7.Stream<_i3.StudyStatus>.empty(), - ) as _i7.Stream<_i3.StudyStatus>); - - @override - _i3.StudyStatus get status => (super.noSuchMethod( - Invocation.getter(#status), - returnValue: _i3.StudyStatus.DeploymentNotStarted, - returnValueForMissingStub: _i3.StudyStatus.DeploymentNotStarted, - ) as _i3.StudyStatus); - - @override - bool get isInitialized => (super.noSuchMethod( - Invocation.getter(#isInitialized), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - bool get isDeployed => (super.noSuchMethod( - Invocation.getter(#isDeployed), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - bool get isStopped => (super.noSuchMethod( - Invocation.getter(#isStopped), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - List<_i3.DeviceConfiguration<_i3.DeviceRegistration>> - get remainingDevicesToRegister => (super.noSuchMethod( + List<_i3.DeviceConfiguration<_i3.DeviceRegistration>> get remainingDevicesToRegister => + (super.noSuchMethod( Invocation.getter(#remainingDevicesToRegister), returnValue: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], - returnValueForMissingStub: <_i3 - .DeviceConfiguration<_i3.DeviceRegistration>>[], - ) as List<_i3.DeviceConfiguration<_i3.DeviceRegistration>>); - - @override - set deployment(_i3.PrimaryDeviceDeployment? _deployment) => - super.noSuchMethod( - Invocation.setter( - #deployment, - _deployment, - ), - returnValueForMissingStub: null, - ); - - @override - set deviceRegistry(_i3.DeviceDataCollectorFactory? _deviceRegistry) => - super.noSuchMethod( - Invocation.setter( - #deviceRegistry, - _deviceRegistry, - ), - returnValueForMissingStub: null, - ); - - @override - set deploymentService(_i3.DeploymentService? _deploymentService) => - super.noSuchMethod( - Invocation.setter( - #deploymentService, - _deploymentService, - ), - returnValueForMissingStub: null, - ); - - @override - set deploymentStatus(_i3.StudyDeploymentStatus? _deploymentStatus) => - super.noSuchMethod( - Invocation.setter( - #deploymentStatus, - _deploymentStatus, - ), - returnValueForMissingStub: null, - ); + returnValueForMissingStub: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], + ) + as List<_i3.DeviceConfiguration<_i3.DeviceRegistration>>); @override - set status(_i3.StudyStatus? newStatus) => super.noSuchMethod( - Invocation.setter( - #status, - newStatus, - ), - returnValueForMissingStub: null, - ); - - @override - _i7.Stream<_i3.Measurement> measurementsByType(String? type) => + Map<_i4.Permission, _i4.PermissionStatus> get permissions => (super.noSuchMethod( - Invocation.method( - #measurementsByType, - [type], - ), - returnValue: _i7.Stream<_i3.Measurement>.empty(), - returnValueForMissingStub: _i7.Stream<_i3.Measurement>.empty(), - ) as _i7.Stream<_i3.Measurement>); - - @override - _i7.Future configure({ - _i2.DataEndPoint? dataEndPoint, - _i2.DataTransformer? transformer, - }) => - (super.noSuchMethod( - Invocation.method( - #configure, - [], - { - #dataEndPoint: dataEndPoint, - #transformer: transformer, - }, - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future askForAllPermissions() => (super.noSuchMethod( - Invocation.method( - #askForAllPermissions, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - void initializeDevices() => super.noSuchMethod( - Invocation.method( - #initializeDevices, - [], - ), - returnValueForMissingStub: null, - ); - - @override - void initializeDevice( - _i3.DeviceConfiguration<_i3.DeviceRegistration>? configuration) => - super.noSuchMethod( - Invocation.method( - #initializeDevice, - [configuration], - ), - returnValueForMissingStub: null, - ); + Invocation.getter(#permissions), + returnValue: <_i4.Permission, _i4.PermissionStatus>{}, + returnValueForMissingStub: <_i4.Permission, _i4.PermissionStatus>{}, + ) + as Map<_i4.Permission, _i4.PermissionStatus>); @override - void startHeartbeatMonitoring() => super.noSuchMethod( - Invocation.method( - #startHeartbeatMonitoring, - [], - ), - returnValueForMissingStub: null, - ); - - @override - _i7.Future connectAllConnectableDevices() => (super.noSuchMethod( - Invocation.method( - #connectAllConnectableDevices, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future<_i3.StudyStatus> tryDeployment({bool? useCached = true}) => + _i2.SmartphoneDeploymentExecutor get executor => (super.noSuchMethod( - Invocation.method( - #tryDeployment, - [], - {#useCached: useCached}, - ), - returnValue: _i7.Future<_i3.StudyStatus>.value( - _i3.StudyStatus.DeploymentNotStarted), - returnValueForMissingStub: _i7.Future<_i3.StudyStatus>.value( - _i3.StudyStatus.DeploymentNotStarted), - ) as _i7.Future<_i3.StudyStatus>); - - @override - _i7.Future saveDeployment() => (super.noSuchMethod( - Invocation.method( - #saveDeployment, - [], - ), - returnValue: _i7.Future.value(false), - returnValueForMissingStub: _i7.Future.value(false), - ) as _i7.Future); - - @override - _i7.Future restoreDeployment() => (super.noSuchMethod( - Invocation.method( - #restoreDeployment, - [], - ), - returnValue: _i7.Future.value(false), - returnValueForMissingStub: _i7.Future.value(false), - ) as _i7.Future); + Invocation.getter(#executor), + returnValue: _FakeSmartphoneDeploymentExecutor_1(this, Invocation.getter(#executor)), + returnValueForMissingStub: _FakeSmartphoneDeploymentExecutor_1(this, Invocation.getter(#executor)), + ) + as _i2.SmartphoneDeploymentExecutor); @override - _i7.Future eraseDeployment() => (super.noSuchMethod( - Invocation.method( - #eraseDeployment, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future start([bool? start = true]) => (super.noSuchMethod( - Invocation.method( - #start, - [start], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future stop() => (super.noSuchMethod( - Invocation.method( - #stop, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future remove() => (super.noSuchMethod( - Invocation.method( - #remove, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - void dispose() => super.noSuchMethod( - Invocation.method( - #dispose, - [], - ), - returnValueForMissingStub: null, - ); - - @override - _i7.Future addStudy( - _i3.Study? study, - _i3.DeviceRegistration? deviceRegistration, - ) => + String get privacySchemaName => (super.noSuchMethod( - Invocation.method( - #addStudy, - [ - study, - deviceRegistration, - ], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); + Invocation.getter(#privacySchemaName), + returnValue: _i5.dummyValue(this, Invocation.getter(#privacySchemaName)), + returnValueForMissingStub: _i5.dummyValue(this, Invocation.getter(#privacySchemaName)), + ) + as String); @override - _i7.Future<_i3.StudyDeploymentStatus?> getStudyDeploymentStatus() => + _i6.Stream<_i3.Measurement> get measurements => (super.noSuchMethod( - Invocation.method( - #getStudyDeploymentStatus, - [], - ), - returnValue: _i7.Future<_i3.StudyDeploymentStatus?>.value(), - returnValueForMissingStub: - _i7.Future<_i3.StudyDeploymentStatus?>.value(), - ) as _i7.Future<_i3.StudyDeploymentStatus?>); + Invocation.getter(#measurements), + returnValue: _i6.Stream<_i3.Measurement>.empty(), + returnValueForMissingStub: _i6.Stream<_i3.Measurement>.empty(), + ) + as _i6.Stream<_i3.Measurement>); @override - _i7.Future tryRegisterConnectedDevice( - _i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => + _i6.Stream<_i3.Measurement> measurementsByType(String? type) => (super.noSuchMethod( - Invocation.method( - #tryRegisterConnectedDevice, - [device], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); + Invocation.method(#measurementsByType, [type]), + returnValue: _i6.Stream<_i3.Measurement>.empty(), + returnValueForMissingStub: _i6.Stream<_i3.Measurement>.empty(), + ) + as _i6.Stream<_i3.Measurement>); @override - _i7.Future tryRegisterRemainingDevicesToRegister() => + _i6.Future tryRegisterConnectedDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => (super.noSuchMethod( - Invocation.method( - #tryRegisterRemainingDevicesToRegister, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); -} + Invocation.method(#tryRegisterConnectedDevice, [device]), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); -/// A class which mocks [HeartRateCardViewModel]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockHeartRateCardViewModel extends _i1.Mock - implements _i4.HeartRateCardViewModel { @override - Map get hourlyHeartRate => + _i6.Future tryRegisterRemainingDevicesToRegister() => (super.noSuchMethod( - Invocation.getter(#hourlyHeartRate), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); - - @override - _i4.HeartRateMinMaxPrHour get dayMinMax => (super.noSuchMethod( - Invocation.getter(#dayMinMax), - returnValue: _FakeHeartRateMinMaxPrHour_4( - this, - Invocation.getter(#dayMinMax), - ), - returnValueForMissingStub: _FakeHeartRateMinMaxPrHour_4( - this, - Invocation.getter(#dayMinMax), - ), - ) as _i4.HeartRateMinMaxPrHour); - - @override - _i4.HourlyHeartRate get model => (super.noSuchMethod( - Invocation.getter(#model), - returnValue: _FakeHourlyHeartRate_5( - this, - Invocation.getter(#model), - ), - returnValueForMissingStub: _FakeHourlyHeartRate_5( - this, - Invocation.getter(#model), - ), - ) as _i4.HourlyHeartRate); - - @override - _i7.Future get filename => (super.noSuchMethod( - Invocation.getter(#filename), - returnValue: _i7.Future.value(_i6.dummyValue( - this, - Invocation.getter(#filename), - )), - returnValueForMissingStub: - _i7.Future.value(_i6.dummyValue( - this, - Invocation.getter(#filename), - )), - ) as _i7.Future); - - @override - bool get hasListeners => (super.noSuchMethod( - Invocation.getter(#hasListeners), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - _i4.HourlyHeartRate createModel() => (super.noSuchMethod( - Invocation.method( - #createModel, - [], - ), - returnValue: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #createModel, - [], - ), - ), - returnValueForMissingStub: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #createModel, - [], - ), - ), - ) as _i4.HourlyHeartRate); - - @override - void init(_i2.SmartphoneDeploymentController? ctrl) => super.noSuchMethod( - Invocation.method( - #init, - [ctrl], - ), - returnValueForMissingStub: null, - ); - - @override - void clear() => super.noSuchMethod( - Invocation.method( - #clear, - [], - ), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method( - #dispose, - [], - ), - returnValueForMissingStub: null, - ); - - @override - _i7.Future save() => (super.noSuchMethod( - Invocation.method( - #save, - [], - ), - returnValue: _i7.Future.value(false), - returnValueForMissingStub: _i7.Future.value(false), - ) as _i7.Future); - - @override - bool delete() => (super.noSuchMethod( - Invocation.method( - #delete, - [], - ), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); + Invocation.method(#tryRegisterRemainingDevicesToRegister, []), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); @override - _i7.Future<_i4.HourlyHeartRate?> restore() => (super.noSuchMethod( - Invocation.method( - #restore, - [], - ), - returnValue: _i7.Future<_i4.HourlyHeartRate?>.value(), - returnValueForMissingStub: _i7.Future<_i4.HourlyHeartRate?>.value(), - ) as _i7.Future<_i4.HourlyHeartRate?>); - - @override - void addListener(_i8.VoidCallback? listener) => super.noSuchMethod( - Invocation.method( - #addListener, - [listener], - ), - returnValueForMissingStub: null, - ); - - @override - void removeListener(_i8.VoidCallback? listener) => super.noSuchMethod( - Invocation.method( - #removeListener, - [listener], - ), - returnValueForMissingStub: null, - ); - - @override - void notifyListeners() => super.noSuchMethod( - Invocation.method( - #notifyListeners, - [], - ), - returnValueForMissingStub: null, - ); -} - -/// A class which mocks [HourlyHeartRate]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockHourlyHeartRate extends _i1.Mock implements _i4.HourlyHeartRate { - @override - Map get hourlyHeartRate => + _i6.Future tryUnregisterDisconnectedDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => (super.noSuchMethod( - Invocation.getter(#hourlyHeartRate), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); + Invocation.method(#tryUnregisterDisconnectedDevice, [device]), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); @override - DateTime get lastUpdated => (super.noSuchMethod( - Invocation.getter(#lastUpdated), - returnValue: _FakeDateTime_6( - this, - Invocation.getter(#lastUpdated), - ), - returnValueForMissingStub: _FakeDateTime_6( - this, - Invocation.getter(#lastUpdated), - ), - ) as DateTime); - - @override - set hourlyHeartRate(Map? _hourlyHeartRate) => - super.noSuchMethod( - Invocation.setter( - #hourlyHeartRate, - _hourlyHeartRate, - ), - returnValueForMissingStub: null, - ); - - @override - set lastUpdated(DateTime? _lastUpdated) => super.noSuchMethod( - Invocation.setter( - #lastUpdated, - _lastUpdated, - ), - returnValueForMissingStub: null, - ); - - @override - set currentHeartRate(double? _currentHeartRate) => super.noSuchMethod( - Invocation.setter( - #currentHeartRate, - _currentHeartRate, - ), - returnValueForMissingStub: null, - ); - - @override - set maxHeartRate(double? _maxHeartRate) => super.noSuchMethod( - Invocation.setter( - #maxHeartRate, - _maxHeartRate, - ), - returnValueForMissingStub: null, - ); - - @override - set minHeartRate(double? _minHeartRate) => super.noSuchMethod( - Invocation.setter( - #minHeartRate, - _minHeartRate, - ), - returnValueForMissingStub: null, - ); - - @override - _i4.HourlyHeartRate resetDataAtMidnight() => (super.noSuchMethod( - Invocation.method( - #resetDataAtMidnight, - [], - ), - returnValue: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #resetDataAtMidnight, - [], - ), - ), - returnValueForMissingStub: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #resetDataAtMidnight, - [], - ), - ), - ) as _i4.HourlyHeartRate); - - @override - _i4.HourlyHeartRate addHeartRate( - int? hour, - double? heartRate, - ) => + _i6.Future tryReregisterDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => (super.noSuchMethod( - Invocation.method( - #addHeartRate, - [ - hour, - heartRate, - ], - ), - returnValue: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #addHeartRate, - [ - hour, - heartRate, - ], - ), - ), - returnValueForMissingStub: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #addHeartRate, - [ - hour, - heartRate, - ], - ), - ), - ) as _i4.HourlyHeartRate); + Invocation.method(#tryReregisterDevice, [device]), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); @override - _i4.HourlyHeartRate fromJson(Map? json) => + _i6.Future askForAllPermissions() => (super.noSuchMethod( - Invocation.method( - #fromJson, - [json], - ), - returnValue: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #fromJson, - [json], - ), - ), - returnValueForMissingStub: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #fromJson, - [json], - ), - ), - ) as _i4.HourlyHeartRate); - - @override - Map toJson() => (super.noSuchMethod( - Invocation.method( - #toJson, - [], - ), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); -} - -/// A class which mocks [PolarHRSample]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockPolarHRSample extends _i1.Mock implements _i9.PolarHRSample { - @override - int get hr => (super.noSuchMethod( - Invocation.getter(#hr), - returnValue: 0, - returnValueForMissingStub: 0, - ) as int); - - @override - List get rrsMs => (super.noSuchMethod( - Invocation.getter(#rrsMs), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - bool get contactStatus => (super.noSuchMethod( - Invocation.getter(#contactStatus), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - bool get contactStatusSupported => (super.noSuchMethod( - Invocation.getter(#contactStatusSupported), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - Map toJson() => (super.noSuchMethod( - Invocation.method( - #toJson, - [], - ), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); -} - -/// A class which mocks [PolarHR]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockPolarHR extends _i1.Mock implements _i9.PolarHR { - @override - Function get fromJsonFunction => (super.noSuchMethod( - Invocation.getter(#fromJsonFunction), - returnValue: () {}, - returnValueForMissingStub: () {}, - ) as Function); - - @override - String get jsonType => (super.noSuchMethod( - Invocation.getter(#jsonType), - returnValue: _i6.dummyValue( - this, - Invocation.getter(#jsonType), - ), - returnValueForMissingStub: _i6.dummyValue( - this, - Invocation.getter(#jsonType), - ), - ) as String); - - @override - List<_i9.PolarHRSample> get samples => (super.noSuchMethod( - Invocation.getter(#samples), - returnValue: <_i9.PolarHRSample>[], - returnValueForMissingStub: <_i9.PolarHRSample>[], - ) as List<_i9.PolarHRSample>); - - @override - set sensorSpecificData(_i3.Data? _sensorSpecificData) => super.noSuchMethod( - Invocation.setter( - #sensorSpecificData, - _sensorSpecificData, - ), - returnValueForMissingStub: null, - ); - - @override - _i3.DataType get format => (super.noSuchMethod( - Invocation.getter(#format), - returnValue: _FakeDataType_7( - this, - Invocation.getter(#format), - ), - returnValueForMissingStub: _FakeDataType_7( - this, - Invocation.getter(#format), - ), - ) as _i3.DataType); - - @override - set $type(String? _$type) => super.noSuchMethod( - Invocation.setter( - #$type, - _$type, - ), - returnValueForMissingStub: null, - ); + Invocation.method(#askForAllPermissions, []), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); @override - Map toJson() => (super.noSuchMethod( - Invocation.method( - #toJson, - [], - ), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); + void restart() => super.noSuchMethod(Invocation.method(#restart, []), returnValueForMissingStub: null); @override - bool equivalentTo(_i3.Data? other) => (super.noSuchMethod( - Invocation.method( - #equivalentTo, - [other], - ), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); -} - -/// A class which mocks [Measurement]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockMeasurement extends _i1.Mock implements _i3.Measurement { - @override - int get sensorStartTime => (super.noSuchMethod( - Invocation.getter(#sensorStartTime), - returnValue: 0, - returnValueForMissingStub: 0, - ) as int); - - @override - _i3.Data get data => (super.noSuchMethod( - Invocation.getter(#data), - returnValue: _FakeData_2( - this, - Invocation.getter(#data), - ), - returnValueForMissingStub: _FakeData_2( - this, - Invocation.getter(#data), - ), - ) as _i3.Data); - - @override - _i3.DataType get dataType => (super.noSuchMethod( - Invocation.getter(#dataType), - returnValue: _FakeDataType_7( - this, - Invocation.getter(#dataType), - ), - returnValueForMissingStub: _FakeDataType_7( - this, - Invocation.getter(#dataType), - ), - ) as _i3.DataType); - - @override - set sensorStartTime(int? _sensorStartTime) => super.noSuchMethod( - Invocation.setter( - #sensorStartTime, - _sensorStartTime, - ), - returnValueForMissingStub: null, - ); + void resume() => super.noSuchMethod(Invocation.method(#resume, []), returnValueForMissingStub: null); @override - set sensorEndTime(int? _sensorEndTime) => super.noSuchMethod( - Invocation.setter( - #sensorEndTime, - _sensorEndTime, - ), - returnValueForMissingStub: null, - ); - - @override - set taskControl(_i3.TaskControl? _taskControl) => super.noSuchMethod( - Invocation.setter( - #taskControl, - _taskControl, - ), - returnValueForMissingStub: null, - ); - - @override - set data(_i3.Data? _data) => super.noSuchMethod( - Invocation.setter( - #data, - _data, - ), - returnValueForMissingStub: null, - ); - - @override - Map toJson() => (super.noSuchMethod( - Invocation.method( - #toJson, - [], - ), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); -} - -/// A class which mocks [DataModel]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockDataModel extends _i1.Mock implements _i4.DataModel { - @override - _i4.DataModel fromJson(Map? json) => (super.noSuchMethod( - Invocation.method( - #fromJson, - [json], - ), - returnValue: _FakeDataModel_8( - this, - Invocation.method( - #fromJson, - [json], - ), - ), - returnValueForMissingStub: _FakeDataModel_8( - this, - Invocation.method( - #fromJson, - [json], - ), - ), - ) as _i4.DataModel); + void pause() => super.noSuchMethod(Invocation.method(#pause, []), returnValueForMissingStub: null); @override - Map toJson() => (super.noSuchMethod( - Invocation.method( - #toJson, - [], - ), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); + void dispose() => super.noSuchMethod(Invocation.method(#dispose, []), returnValueForMissingStub: null); } diff --git a/test/json/deployment_caws.json b/test/json/deployment_caws.json index a9b6828d..43ba6f69 100644 --- a/test/json/deployment_caws.json +++ b/test/json/deployment_caws.json @@ -23,14 +23,14 @@ }, { "__type": "dk.cachet.carp.common.application.devices.WeatherService", - "apiKey": "12b6e28582eb9298577c734a31ba9f4f", + "apiKey": "", "roleName": "Weather Service", "isOptional": true, "defaultSamplingConfiguration": {} }, { "__type": "dk.cachet.carp.common.application.devices.AirQualityService", - "apiKey": "9e538456b2b85c92647d8b65090e29f957638c77", + "apiKey": "", "roleName": "Air Quality Service", "isOptional": true, "defaultSamplingConfiguration": {} diff --git a/test/json/deployment_db.json b/test/json/deployment_db.json index ac7bc2c2..ec2119c9 100644 --- a/test/json/deployment_db.json +++ b/test/json/deployment_db.json @@ -55,14 +55,14 @@ "roleName": "Weather Service", "isOptional": true, "defaultSamplingConfiguration": {}, - "apiKey": "12b6e28582eb9298577c734a31ba9f4f" + "apiKey": "" }, { "__type": "dk.cachet.carp.common.application.devices.AirQualityService", "roleName": "Air Quality Service", "isOptional": true, "defaultSamplingConfiguration": {}, - "apiKey": "9e538456b2b85c92647d8b65090e29f957638c77" + "apiKey": "" }, { "__type": "dk.cachet.carp.common.application.devices.PolarDevice", @@ -74,7 +74,7 @@ "connectedDeviceRegistrations": { "Weather Service": { "__type": "dk.cachet.carp.common.application.devices.DefaultDeviceRegistration", - "deviceId": "12b6e28582eb9298577c734a31ba9f4f", + "deviceId": "REDACTED", "deviceDisplayName": "Weather Service (OW)", "registrationCreatedOn": "2024-05-06T09:09:44.524050Z" }, @@ -86,7 +86,7 @@ }, "Air Quality Service": { "__type": "dk.cachet.carp.common.application.devices.DefaultDeviceRegistration", - "deviceId": "9e538456b2b85c92647d8b65090e29f957638c77", + "deviceId": "REDACTED", "deviceDisplayName": "Air Quality Service (WAQI)", "registrationCreatedOn": "2024-05-06T09:09:44.595861Z" } diff --git a/test/json/deployment_status.json b/test/json/deployment_status.json index 6cb0f89a..aad2028d 100644 --- a/test/json/deployment_status.json +++ b/test/json/deployment_status.json @@ -60,7 +60,7 @@ "__type": "dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Registered", "device": { "__type": "dk.cachet.carp.common.application.devices.WeatherService", - "apiKey": "12b6e28582eb9298577c734a31ba9f4f", + "apiKey": "", "roleName": "Weather Service", "isOptional": true, "defaultSamplingConfiguration": {} @@ -73,7 +73,7 @@ "__type": "dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Registered", "device": { "__type": "dk.cachet.carp.common.application.devices.AirQualityService", - "apiKey": "9e538456b2b85c92647d8b65090e29f957638c77", + "apiKey": "", "roleName": "Air Quality Service", "isOptional": true, "defaultSamplingConfiguration": {} diff --git a/test/json/deployment_status_running.json b/test/json/deployment_status_running.json index 3788c415..c625d604 100644 --- a/test/json/deployment_status_running.json +++ b/test/json/deployment_status_running.json @@ -31,7 +31,7 @@ "__type": "dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Registered", "device": { "__type": "dk.cachet.carp.common.application.devices.WeatherService", - "apiKey": "12b6e28582eb9298577c734a31ba9f4f", + "apiKey": "", "roleName": "Weather Service", "isOptional": true, "defaultSamplingConfiguration": {} @@ -44,7 +44,7 @@ "__type": "dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Registered", "device": { "__type": "dk.cachet.carp.common.application.devices.AirQualityService", - "apiKey": "9e538456b2b85c92647d8b65090e29f957638c77", + "apiKey": "", "roleName": "Air Quality Service", "isOptional": true, "defaultSamplingConfiguration": {} diff --git a/test/json/protocol_cams_1x.json b/test/json/protocol_cams_1x.json new file mode 100644 index 00000000..b38023f6 --- /dev/null +++ b/test/json/protocol_cams_1x.json @@ -0,0 +1,1210 @@ +{ + "id": "dcda6ca6-f9ab-41e5-b642-b5a349d2ab01", + "createdOn": "2025-04-09T10:59:58.258Z", + "version": 0, + "ownerId": "9c354cd9-0fd9-49a4-910d-46b28ea43997", + "name": "CARP Demo Protocol", + "description": "", + "primaryDevices": [ + { + "__type": "dk.cachet.carp.common.application.devices.Smartphone", + "isPrimaryDevice": true, + "roleName": "Primary Phone" + } + ], + "connectedDevices": [ + { + "__type": "dk.cachet.carp.common.application.devices.LocationService", + "accuracy": "balanced", + "distance": 10, + "interval": 60000000, + "roleName": "Location Service", + "isOptional": true, + "defaultSamplingConfiguration": {}, + "notificationOnTapBringToFront": false + }, + { + "__type": "dk.cachet.carp.common.application.devices.WeatherService", + "apiKey": "", + "roleName": "Weather Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.cachet.carp.common.application.devices.AirQualityService", + "apiKey": "", + "roleName": "Air Quality Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.cachet.carp.common.application.devices.HealthService", + "roleName": "Health Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.cachet.carp.common.application.devices.PolarDevice", + "roleName": "Polar HR Sensor", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.cachet.carp.common.application.devices.MovesenseDevice", + "roleName": "Movesense ECG Device", + "deviceType": "UNKNOWN", + "isOptional": true, + "defaultSamplingConfiguration": {} + } + ], + "connections": [ + { + "roleName": "Location Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Weather Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Air Quality Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Health Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Polar HR Sensor", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Movesense ECG Device", + "connectedToRoleName": "Primary Phone" + } + ], + "tasks": [ + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #7", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #8", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #9", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #10", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #11", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.ambientlight" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.stepcount" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.freememory" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.deviceinformation" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.batterystate" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.screenevent" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.activity" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #12", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.location" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.mobility" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #13", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.weather" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #14", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.airquality" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #15", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #16", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #17", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.polar.hr" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #18", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #19", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.movesense.state" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.movesense.hr" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #20", + "type": "one_time_sensing", + "title": "environment.title", + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.weather", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "environment.description", + "instructions": "", + "notification": false + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #21", + "type": "survey", + "title": "survey.demographics.title", + "expire": 432000000000, + "rpTask": { + "steps": [ + { + "title": "survey.demographics.question.sex", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.femal", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.male", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.other", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + }, + { + "title": "survey.demographics.question.age", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.2", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.under_20", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "20-29", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "30-39", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "40-49", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "50-59", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "60-69", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "70-79", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "80-89", + "value": 8, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.90_above", + "value": 9, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 10, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + }, + { + "title": "survey.demographics.question.smoke", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.3", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.smoke.never", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.ex", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.1", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.1-10", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.11-20", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.21+", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "demo_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "survey.demographics.description", + "instructions": "", + "notification": false, + "minutesToComplete": 2 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #22", + "type": "survey", + "title": "Symptoms", + "expire": 86400000000, + "rpTask": { + "steps": [ + { + "title": "Do you have any of the following symptoms today?", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "sym_1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "None", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Fever (warmer than usual)", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Dry cough", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Wet cough", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Sore throat, runny or blocked nose", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Loss of taste and smell", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Difficulty breathing or feeling short of breath", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Tightness in your chest", + "value": 8, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Dizziness, confusion or vertigo", + "value": 9, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Headache", + "value": 10, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Muscle aches", + "value": 11, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Chills", + "value": 12, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Prefer not to say", + "value": 13, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "MultipleChoice", + "questionType": "MultipleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "symptoms_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A short 1-item survey on your daily symptoms.", + "instructions": "", + "notification": true, + "minutesToComplete": 1 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #23", + "type": "audio", + "title": "reading.title", + "measures": [ + { + "type": "dk.cachet.carp.audio", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "overrideSamplingConfiguration": { + "once": true, + "__type": "dk.cachet.carp.common.application.sampling.LocationSamplingConfiguration" + } + } + ], + "description": "reading.description", + "instructions": "reading.instructions", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #24", + "type": "image", + "title": "wound.title", + "measures": [ + { + "type": "dk.cachet.carp.image", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "wound.description", + "instructions": "wound.instructions", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #25", + "type": "cognition", + "title": "Parkinsons Assessment", + "rpTask": { + "steps": [ + { + "text": "In the following pages, you will be asked to solve two simple test which will help assess your symptoms on a daily basis. Each test has an instruction page, which you should read carefully before starting the test.\n\nPlease sit down comfortably and hold the phone in one hand while performing the test with the other.", + "title": "Parkinsons Disease Assessment", + "__type": "RPInstructionStep", + "optional": false, + "identifier": "parkinsons_instruction" + }, + { + "title": "RPActivityStep", + "__type": "RPFlankerActivity", + "optional": false, + "identifier": "flanker_1", + "lengthOfTest": 30, + "numberOfCards": 10, + "includeResults": true, + "includeInstructions": true + }, + { + "title": "RPActivityStep", + "__type": "RPTappingActivity", + "optional": false, + "identifier": "tapping_1", + "lengthOfTest": 10, + "includeResults": true, + "includeInstructions": true + } + ], + "__type": "RPOrderedTask", + "identifier": "parkinsons_assessment", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A simple task assessing cognitive functioning and finger tapping speed.", + "instructions": "", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.HealthAppTask", + "name": "Task #27", + "type": "health", + "title": "Physical Health Data", + "types": [ + "WEIGHT", + "HEIGHT", + "BODY_FAT_PERCENTAGE", + "BODY_TEMPERATURE" + ], + "measures": [ + { + "type": "dk.cachet.carp.health", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "overrideSamplingConfiguration": { + "past": 2592000000000, + "__type": "dk.cachet.carp.common.application.sampling.HealthSamplingConfiguration", + "future": 86400000000, + "healthDataTypes": [ + "WEIGHT", + "HEIGHT", + "BODY_FAT_PERCENTAGE", + "BODY_TEMPERATURE" + ] + } + } + ], + "description": "This task will collect your weight, height, and other bodily data from the Health database on the phone.", + "instructions": "", + "notification": false + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #26", + "type": "survey", + "title": "Parkinsons Disease Activities of Daily Living Scale", + "expire": 86400000000, + "rpTask": { + "steps": [ + { + "title": "Please tick one of the descriptions that best describes how your Parkinsons disease has affected your day-to-day activities in the last month.", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "parkinsons_1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "No difficulties with day-to-day activities.", + "value": 1, + "__type": "RPChoice", + "detailText": "For example: Your Parkinsons disease at present is not affecting your daily living.", + "isFreeText": false + }, + { + "text": "Mild difficulties with day-to-day activities.", + "value": 2, + "__type": "RPChoice", + "detailText": "For example: Slowness with some aspects of housework, gardening or shopping. Able to dress and manage personal hygiene completely independently but rate is slower. You may feel that your medication is not quite effective as it was.", + "isFreeText": false + }, + { + "text": "Moderate difficulties with day-to-day activities.", + "value": 3, + "__type": "RPChoice", + "detailText": "For example: Your Parkinsons disease is interfering with your daily activities. It is increasingly difficult to do simple activities without some help such as rising from a chair, washing, dressing, shopping, housework. You may have some difficulties walking and may require assistance. Difficulties with recreational activities or the ability to drive a car. The medication is now less effective.", + "isFreeText": false + }, + { + "text": "High levels of difficulties with day-to-day activities.", + "value": 4, + "__type": "RPChoice", + "detailText": "For example: You now require much more assistance with activities of daily living such as washing, dressing, housework or feeding yourself. You may have greater difficulties with mobility and find you are becoming more dependent for assistance from others or aids and appliances. Your medication appears to be significantly less effective.", + "isFreeText": false + }, + { + "text": "Extreme difficulties with day-to-day activities.", + "value": 5, + "__type": "RPChoice", + "detailText": "For example: You require assistance in all daily activities. These may include dressing, washing, feeding yourself or walking unaided. You may now be housebound and obtain little or no benefit from your medication.", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "parkinsons_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A new simple and brief subjective measure of disability in Parkinsons disease", + "instructions": "", + "notification": true, + "minutesToComplete": 1 + } + ], + "triggers": { + "0": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Primary Phone" + }, + "1": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Location Service" + }, + "2": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Weather Service" + }, + "3": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Air Quality Service" + }, + "4": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Primary Phone" + }, + "5": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Location Service" + }, + "6": { + "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", + "period": 1800000000, + "sourceDeviceRoleName": "Weather Service" + }, + "7": { + "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", + "period": 1800000000, + "sourceDeviceRoleName": "Air Quality Service" + }, + "8": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Health Service" + }, + "9": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Polar HR Sensor" + }, + "10": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Polar HR Sensor" + }, + "11": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Movesense ECG Device" + }, + "12": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Movesense ECG Device" + }, + "13": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #20", + "sourceDeviceRoleName": "Primary Phone" + }, + "14": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #21", + "sourceDeviceRoleName": "Primary Phone" + }, + "15": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #22", + "sourceDeviceRoleName": "Primary Phone" + }, + "16": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #23", + "sourceDeviceRoleName": "Primary Phone" + }, + "17": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #24", + "sourceDeviceRoleName": "Primary Phone" + }, + "18": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #25", + "sourceDeviceRoleName": "Primary Phone" + }, + "19": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #27", + "sourceDeviceRoleName": "Primary Phone" + }, + "20": { + "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", + "taskName": "Task #25", + "triggerCondition": "done", + "sourceDeviceRoleName": "Primary Phone" + } + }, + "taskControls": [ + { + "triggerId": 0, + "taskName": "Task #7", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 1, + "taskName": "Task #8", + "destinationDeviceRoleName": "Location Service", + "control": "Start" + }, + { + "triggerId": 2, + "taskName": "Task #9", + "destinationDeviceRoleName": "Weather Service", + "control": "Start" + }, + { + "triggerId": 3, + "taskName": "Task #10", + "destinationDeviceRoleName": "Air Quality Service", + "control": "Start" + }, + { + "triggerId": 4, + "taskName": "Task #11", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 5, + "taskName": "Task #12", + "destinationDeviceRoleName": "Location Service", + "control": "Start" + }, + { + "triggerId": 6, + "taskName": "Task #13", + "destinationDeviceRoleName": "Weather Service", + "control": "Start" + }, + { + "triggerId": 7, + "taskName": "Task #14", + "destinationDeviceRoleName": "Air Quality Service", + "control": "Start" + }, + { + "triggerId": 8, + "taskName": "Task #15", + "destinationDeviceRoleName": "Health Service", + "control": "Start" + }, + { + "triggerId": 9, + "taskName": "Task #16", + "destinationDeviceRoleName": "Polar HR Sensor", + "control": "Start" + }, + { + "triggerId": 10, + "taskName": "Task #17", + "destinationDeviceRoleName": "Polar HR Sensor", + "control": "Start" + }, + { + "triggerId": 11, + "taskName": "Task #18", + "destinationDeviceRoleName": "Movesense ECG Device", + "control": "Start" + }, + { + "triggerId": 12, + "taskName": "Task #19", + "destinationDeviceRoleName": "Movesense ECG Device", + "control": "Start" + }, + { + "triggerId": 13, + "taskName": "Task #20", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 14, + "taskName": "Task #21", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 15, + "taskName": "Task #22", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 16, + "taskName": "Task #23", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 17, + "taskName": "Task #24", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 18, + "taskName": "Task #25", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 19, + "taskName": "Task #27", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 20, + "taskName": "Task #26", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + } + ], + "participantRoles": [ + { + "role": "Participant", + "isOptional": false + } + ], + "expectedParticipantData": [ + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.address" + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.full_name" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.cachet.carp.input.sex" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.informed_consent" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + } + ], + "applicationData": { + "dataEndPoint": { + "name": "CARP Web Service", + "type": "CAWS", + "__type": "CarpDataEndPoint", + "compress": true, + "dataFormat": "dk.cachet.carp", + "uploadMethod": "stream", + "uploadInterval": 10, + "onlyUploadOnWiFi": false, + "deleteWhenUploaded": true + }, + "studyDescription": { + "title": "study.description.title", + "__type": "StudyDescription", + "purpose": "study.description.purpose", + "description": "study.description.description", + "responsible": { + "id": "study.responsible.id", + "name": "study.responsible.name", + "email": "study.responsible.email", + "title": "study.responsible.title", + "__type": "StudyResponsible", + "address": "study.responsible.address", + "affiliation": "study.responsible.affiliation" + }, + "privacyPolicyUrl": "study.description.privacy", + "studyDescriptionUrl": "study.description.url" + } + } +} \ No newline at end of file diff --git a/test/json/protocol_cams_2x.json b/test/json/protocol_cams_2x.json new file mode 100644 index 00000000..79720137 --- /dev/null +++ b/test/json/protocol_cams_2x.json @@ -0,0 +1,1210 @@ +{ + "id": "dcda6ca6-f9ab-41e5-b642-b5a349d2ab01", + "createdOn": "2025-04-09T10:59:58.258Z", + "version": 0, + "ownerId": "9c354cd9-0fd9-49a4-910d-46b28ea43997", + "name": "CARP Demo Protocol", + "description": "", + "primaryDevices": [ + { + "__type": "dk.carp.cams.devices.Smartphone", + "isPrimaryDevice": true, + "roleName": "Primary Phone" + } + ], + "connectedDevices": [ + { + "__type": "dk.carp.cams.devices.LocationService", + "accuracy": "balanced", + "distance": 10, + "interval": 60000000, + "roleName": "Location Service", + "isOptional": true, + "defaultSamplingConfiguration": {}, + "notificationOnTapBringToFront": false + }, + { + "__type": "dk.carp.cams.devices.WeatherService", + "apiKey": "", + "roleName": "Weather Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.carp.cams.devices.AirQualityService", + "apiKey": "", + "roleName": "Air Quality Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.carp.cams.devices.HealthService", + "roleName": "Health Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.carp.cams.devices.PolarDevice", + "roleName": "Polar HR Sensor", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.carp.cams.devices.MovesenseDevice", + "roleName": "Movesense ECG Device", + "deviceType": "UNKNOWN", + "isOptional": true, + "defaultSamplingConfiguration": {} + } + ], + "connections": [ + { + "roleName": "Location Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Weather Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Air Quality Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Health Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Polar HR Sensor", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Movesense ECG Device", + "connectedToRoleName": "Primary Phone" + } + ], + "tasks": [ + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #7", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #8", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #9", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #10", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #11", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.ambientlight" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.stepcount" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.freememory" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.deviceinformation" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.batterystate" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.screenevent" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.activity" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #12", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.location" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.mobility" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #13", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.weather" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #14", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.airquality" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #15", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #16", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #17", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.polar.hr" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #18", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #19", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.movesense.state" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.movesense.hr" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #20", + "type": "one_time_sensing", + "title": "environment.title", + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.weather", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "environment.description", + "instructions": "", + "notification": false + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #21", + "type": "survey", + "title": "survey.demographics.title", + "expire": 432000000000, + "rpTask": { + "steps": [ + { + "title": "survey.demographics.question.sex", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.femal", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.male", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.other", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + }, + { + "title": "survey.demographics.question.age", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.2", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.under_20", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "20-29", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "30-39", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "40-49", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "50-59", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "60-69", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "70-79", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "80-89", + "value": 8, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.90_above", + "value": 9, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 10, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + }, + { + "title": "survey.demographics.question.smoke", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.3", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.smoke.never", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.ex", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.1", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.1-10", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.11-20", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.21+", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "demo_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "survey.demographics.description", + "instructions": "", + "notification": false, + "minutesToComplete": 2 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #22", + "type": "survey", + "title": "Symptoms", + "expire": 86400000000, + "rpTask": { + "steps": [ + { + "title": "Do you have any of the following symptoms today?", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "sym_1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "None", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Fever (warmer than usual)", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Dry cough", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Wet cough", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Sore throat, runny or blocked nose", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Loss of taste and smell", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Difficulty breathing or feeling short of breath", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Tightness in your chest", + "value": 8, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Dizziness, confusion or vertigo", + "value": 9, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Headache", + "value": 10, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Muscle aches", + "value": 11, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Chills", + "value": 12, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Prefer not to say", + "value": 13, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "MultipleChoice", + "questionType": "MultipleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "symptoms_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A short 1-item survey on your daily symptoms.", + "instructions": "", + "notification": true, + "minutesToComplete": 1 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #23", + "type": "audio", + "title": "reading.title", + "measures": [ + { + "type": "dk.cachet.carp.audio", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "overrideSamplingConfiguration": { + "once": true, + "__type": "dk.cachet.carp.common.application.sampling.LocationSamplingConfiguration" + } + } + ], + "description": "reading.description", + "instructions": "reading.instructions", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #24", + "type": "image", + "title": "wound.title", + "measures": [ + { + "type": "dk.cachet.carp.image", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "wound.description", + "instructions": "wound.instructions", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #25", + "type": "cognition", + "title": "Parkinsons Assessment", + "rpTask": { + "steps": [ + { + "text": "In the following pages, you will be asked to solve two simple test which will help assess your symptoms on a daily basis. Each test has an instruction page, which you should read carefully before starting the test.\n\nPlease sit down comfortably and hold the phone in one hand while performing the test with the other.", + "title": "Parkinsons Disease Assessment", + "__type": "RPInstructionStep", + "optional": false, + "identifier": "parkinsons_instruction" + }, + { + "title": "RPActivityStep", + "__type": "RPFlankerActivity", + "optional": false, + "identifier": "flanker_1", + "lengthOfTest": 30, + "numberOfCards": 10, + "includeResults": true, + "includeInstructions": true + }, + { + "title": "RPActivityStep", + "__type": "RPTappingActivity", + "optional": false, + "identifier": "tapping_1", + "lengthOfTest": 10, + "includeResults": true, + "includeInstructions": true + } + ], + "__type": "RPOrderedTask", + "identifier": "parkinsons_assessment", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A simple task assessing cognitive functioning and finger tapping speed.", + "instructions": "", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.HealthAppTask", + "name": "Task #27", + "type": "health", + "title": "Physical Health Data", + "types": [ + "WEIGHT", + "HEIGHT", + "BODY_FAT_PERCENTAGE", + "BODY_TEMPERATURE" + ], + "measures": [ + { + "type": "dk.cachet.carp.health", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "overrideSamplingConfiguration": { + "past": 2592000000000, + "__type": "dk.cachet.carp.common.application.sampling.HealthSamplingConfiguration", + "future": 86400000000, + "healthDataTypes": [ + "WEIGHT", + "HEIGHT", + "BODY_FAT_PERCENTAGE", + "BODY_TEMPERATURE" + ] + } + } + ], + "description": "This task will collect your weight, height, and other bodily data from the Health database on the phone.", + "instructions": "", + "notification": false + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #26", + "type": "survey", + "title": "Parkinsons Disease Activities of Daily Living Scale", + "expire": 86400000000, + "rpTask": { + "steps": [ + { + "title": "Please tick one of the descriptions that best describes how your Parkinsons disease has affected your day-to-day activities in the last month.", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "parkinsons_1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "No difficulties with day-to-day activities.", + "value": 1, + "__type": "RPChoice", + "detailText": "For example: Your Parkinsons disease at present is not affecting your daily living.", + "isFreeText": false + }, + { + "text": "Mild difficulties with day-to-day activities.", + "value": 2, + "__type": "RPChoice", + "detailText": "For example: Slowness with some aspects of housework, gardening or shopping. Able to dress and manage personal hygiene completely independently but rate is slower. You may feel that your medication is not quite effective as it was.", + "isFreeText": false + }, + { + "text": "Moderate difficulties with day-to-day activities.", + "value": 3, + "__type": "RPChoice", + "detailText": "For example: Your Parkinsons disease is interfering with your daily activities. It is increasingly difficult to do simple activities without some help such as rising from a chair, washing, dressing, shopping, housework. You may have some difficulties walking and may require assistance. Difficulties with recreational activities or the ability to drive a car. The medication is now less effective.", + "isFreeText": false + }, + { + "text": "High levels of difficulties with day-to-day activities.", + "value": 4, + "__type": "RPChoice", + "detailText": "For example: You now require much more assistance with activities of daily living such as washing, dressing, housework or feeding yourself. You may have greater difficulties with mobility and find you are becoming more dependent for assistance from others or aids and appliances. Your medication appears to be significantly less effective.", + "isFreeText": false + }, + { + "text": "Extreme difficulties with day-to-day activities.", + "value": 5, + "__type": "RPChoice", + "detailText": "For example: You require assistance in all daily activities. These may include dressing, washing, feeding yourself or walking unaided. You may now be housebound and obtain little or no benefit from your medication.", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "parkinsons_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A new simple and brief subjective measure of disability in Parkinsons disease", + "instructions": "", + "notification": true, + "minutesToComplete": 1 + } + ], + "triggers": { + "0": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Primary Phone" + }, + "1": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Location Service" + }, + "2": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Weather Service" + }, + "3": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Air Quality Service" + }, + "4": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Primary Phone" + }, + "5": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Location Service" + }, + "6": { + "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", + "period": 1800000000, + "sourceDeviceRoleName": "Weather Service" + }, + "7": { + "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", + "period": 1800000000, + "sourceDeviceRoleName": "Air Quality Service" + }, + "8": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Health Service" + }, + "9": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Polar HR Sensor" + }, + "10": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Polar HR Sensor" + }, + "11": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Movesense ECG Device" + }, + "12": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Movesense ECG Device" + }, + "13": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #20", + "sourceDeviceRoleName": "Primary Phone" + }, + "14": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #21", + "sourceDeviceRoleName": "Primary Phone" + }, + "15": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #22", + "sourceDeviceRoleName": "Primary Phone" + }, + "16": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #23", + "sourceDeviceRoleName": "Primary Phone" + }, + "17": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #24", + "sourceDeviceRoleName": "Primary Phone" + }, + "18": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #25", + "sourceDeviceRoleName": "Primary Phone" + }, + "19": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #27", + "sourceDeviceRoleName": "Primary Phone" + }, + "20": { + "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", + "taskName": "Task #25", + "triggerCondition": "done", + "sourceDeviceRoleName": "Primary Phone" + } + }, + "taskControls": [ + { + "triggerId": 0, + "taskName": "Task #7", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 1, + "taskName": "Task #8", + "destinationDeviceRoleName": "Location Service", + "control": "Start" + }, + { + "triggerId": 2, + "taskName": "Task #9", + "destinationDeviceRoleName": "Weather Service", + "control": "Start" + }, + { + "triggerId": 3, + "taskName": "Task #10", + "destinationDeviceRoleName": "Air Quality Service", + "control": "Start" + }, + { + "triggerId": 4, + "taskName": "Task #11", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 5, + "taskName": "Task #12", + "destinationDeviceRoleName": "Location Service", + "control": "Start" + }, + { + "triggerId": 6, + "taskName": "Task #13", + "destinationDeviceRoleName": "Weather Service", + "control": "Start" + }, + { + "triggerId": 7, + "taskName": "Task #14", + "destinationDeviceRoleName": "Air Quality Service", + "control": "Start" + }, + { + "triggerId": 8, + "taskName": "Task #15", + "destinationDeviceRoleName": "Health Service", + "control": "Start" + }, + { + "triggerId": 9, + "taskName": "Task #16", + "destinationDeviceRoleName": "Polar HR Sensor", + "control": "Start" + }, + { + "triggerId": 10, + "taskName": "Task #17", + "destinationDeviceRoleName": "Polar HR Sensor", + "control": "Start" + }, + { + "triggerId": 11, + "taskName": "Task #18", + "destinationDeviceRoleName": "Movesense ECG Device", + "control": "Start" + }, + { + "triggerId": 12, + "taskName": "Task #19", + "destinationDeviceRoleName": "Movesense ECG Device", + "control": "Start" + }, + { + "triggerId": 13, + "taskName": "Task #20", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 14, + "taskName": "Task #21", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 15, + "taskName": "Task #22", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 16, + "taskName": "Task #23", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 17, + "taskName": "Task #24", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 18, + "taskName": "Task #25", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 19, + "taskName": "Task #27", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 20, + "taskName": "Task #26", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + } + ], + "participantRoles": [ + { + "role": "Participant", + "isOptional": false + } + ], + "expectedParticipantData": [ + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.address" + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.full_name" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.cachet.carp.input.sex" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.informed_consent" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + } + ], + "applicationData": { + "dataEndPoint": { + "name": "CARP Web Service", + "type": "CAWS", + "__type": "CarpDataEndPoint", + "compress": true, + "dataFormat": "dk.cachet.carp", + "uploadMethod": "stream", + "uploadInterval": 10, + "onlyUploadOnWiFi": false, + "deleteWhenUploaded": true + }, + "studyDescription": { + "title": "study.description.title", + "__type": "StudyDescription", + "purpose": "study.description.purpose", + "description": "study.description.description", + "responsible": { + "id": "study.responsible.id", + "name": "study.responsible.name", + "email": "study.responsible.email", + "title": "study.responsible.title", + "__type": "StudyResponsible", + "address": "study.responsible.address", + "affiliation": "study.responsible.affiliation" + }, + "privacyPolicyUrl": "study.description.privacy", + "studyDescriptionUrl": "study.description.url" + } + } +} \ No newline at end of file diff --git a/test/json/study_protocol.json b/test/json/study_protocol.json deleted file mode 100644 index 93de9ea9..00000000 --- a/test/json/study_protocol.json +++ /dev/null @@ -1,1211 +0,0 @@ -{ - "applicationData": { - "studyDescription": { - "__type": "StudyDescription", - "title": "study.description.title", - "description": "study.description.description", - "purpose": "study.description.purpose", - "studyDescriptionUrl": "study.description.url", - "privacyPolicyUrl": "study.description.privacy", - "responsible": { - "__type": "StudyResponsible", - "id": "study.responsible.id", - "name": "study.responsible.name", - "title": "study.responsible.title", - "email": "study.responsible.email", - "address": "study.responsible.address", - "affiliation": "study.responsible.affiliation" - } - }, - "dataEndPoint": { - "__type": "CarpDataEndPoint", - "type": "CAWS", - "dataFormat": "dk.cachet.carp", - "uploadMethod": "datapoint", - "name": "CARP Web Services", - "onlyUploadOnWiFi": false, - "uploadInterval": 10, - "deleteWhenUploaded": true - } - }, - "id": "eb5e585f-e247-41dc-8e8d-39697b68732f", - "createdOn": "2023-04-13T18:13:18.511469Z", - "version": 0, - "description": "study.description.description", - "ownerId": "eb5e585f-e247-41dc-8e8d-39697b68732f", - "name": "CARP Study App Demo Protocol", - "primaryDevices": [ - { - "__type": "dk.cachet.carp.common.application.devices.Smartphone", - "roleName": "Primary Phone", - "defaultSamplingConfiguration": {}, - "isPrimaryDevice": true - } - ], - "connectedDevices": [ - { - "__type": "dk.cachet.carp.common.application.devices.ESenseDevice", - "roleName": "eSense", - "isOptional": true, - "supportedDataTypes": [ - "dk.cachet.carp.esense.button", - "dk.cachet.carp.esense.sensor" - ], - "defaultSamplingConfiguration": {} - }, - { - "__type": "dk.cachet.carp.common.application.devices.PolarDevice", - "roleName": "Polar HR Device", - "isOptional": true, - "supportedDataTypes": [ - "dk.cachet.carp.polar.accelerometer", - "dk.cachet.carp.polar.gyroscope", - "dk.cachet.carp.polar.magnetometer", - "dk.cachet.carp.polar.ppg", - "dk.cachet.carp.polar.ppi", - "dk.cachet.carp.polar.ecg", - "dk.cachet.carp.polar.hr" - ], - "defaultSamplingConfiguration": {} - }, - { - "__type": "dk.cachet.carp.common.application.devices.LocationService", - "roleName": "Location Service", - "isOptional": true, - "defaultSamplingConfiguration": {}, - "accuracy": "balanced", - "distance": 0.0, - "interval": 60000000 - }, - { - "__type": "dk.cachet.carp.common.application.devices.WeatherService", - "roleName": "Weather Service", - "isOptional": true, - "defaultSamplingConfiguration": {}, - "apiKey": "12b6e28582eb9298577c734a31ba9f4f" - }, - { - "__type": "dk.cachet.carp.common.application.devices.AirQualityService", - "roleName": "Air Quality Service", - "isOptional": true, - "defaultSamplingConfiguration": {}, - "apiKey": "9e538456b2b85c92647d8b65090e29f957638c77" - } - ], - "connections": [ - { - "roleName": "eSense", - "connectedToRoleName": "Primary Phone" - }, - { - "roleName": "Polar HR Device", - "connectedToRoleName": "Primary Phone" - }, - { - "roleName": "Location Service", - "connectedToRoleName": "Primary Phone" - }, - { - "roleName": "Weather Service", - "connectedToRoleName": "Primary Phone" - }, - { - "roleName": "Air Quality Service", - "connectedToRoleName": "Primary Phone" - } - ], - "tasks": [ - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #26", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.coverage" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.error" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #27", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #28", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #29", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #30", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #31", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #32", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.ambientlight" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.stepcount" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.freememory" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.deviceinformation" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.batterystate" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.screenevent" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.activity" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #33", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #34", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.mobility" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #35", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.weather" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #36", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.airquality" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #37", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.esense.button" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.esense.sensor" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #38", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.polar.hr" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.polar.ecg" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.AppTask", - "name": "Task #39", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.weather" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.airquality" - } - ], - "type": "one_time_sensing", - "title": "environment.title", - "description": "environment.description", - "instructions": "", - "notification": false - }, - { - "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", - "name": "Task #40", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - } - ], - "type": "survey", - "title": "Demographics", - "description": "A short 4-item survey on your background.", - "instructions": "", - "minutesToComplete": 2, - "expire": 432000000000, - "notification": false, - "rpTask": { - "__type": "RPOrderedTask", - "identifier": "demographic_survey", - "close_after_finished": true, - "steps": [ - { - "__type": "RPQuestionStep", - "identifier": "demographic_1", - "title": "Which is your biological sex?", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "SingleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "Female", - "value": 1, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Male", - "value": 2, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Other", - "value": 3, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Prefer not to say", - "value": 4, - "is_free_text": false - } - ], - "answer_style": "SingleChoice" - } - }, - { - "__type": "RPQuestionStep", - "identifier": "demographic_2", - "title": "How old are you?", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "SingleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "Under 20", - "value": 1, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "20-29", - "value": 2, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "30-39", - "value": 3, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "40-49", - "value": 4, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "50-59", - "value": 5, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "60-69", - "value": 6, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "70-79", - "value": 7, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "80-89", - "value": 8, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "90 and above", - "value": 9, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Prefer not to say", - "value": 10, - "is_free_text": false - } - ], - "answer_style": "SingleChoice" - } - }, - { - "__type": "RPQuestionStep", - "identifier": "demographic_3", - "title": "Do you have any of these medical conditions?", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "MultipleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "None", - "value": 1, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Asthma", - "value": 2, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Cystic fibrosis", - "value": 3, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "COPD/Emphysema", - "value": 4, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Pulmonary fibrosis", - "value": 5, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Other lung disease ", - "value": 6, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "High Blood Pressure", - "value": 7, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Angina", - "value": 8, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Previous stroke or Transient ischaemic attack ", - "value": 9, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Valvular heart disease", - "value": 10, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Previous heart attack", - "value": 11, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Other heart disease", - "value": 12, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Diabetes", - "value": 13, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Cancer", - "value": 14, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Previous organ transplant", - "value": 15, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "HIV or impaired immune system", - "value": 16, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Other long-term condition", - "value": 17, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Prefer not to say", - "value": 18, - "is_free_text": false - } - ], - "answer_style": "MultipleChoice" - } - }, - { - "__type": "RPQuestionStep", - "identifier": "demographic_4", - "title": "Do you, or have you, ever smoked (including e-cigarettes)?", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "SingleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "Never smoked", - "value": 1, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Ex-smoker", - "value": 2, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Current smoker (less than once a day", - "value": 3, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Current smoker (1-10 cigarettes pr day", - "value": 4, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Current smoker (11-20 cigarettes pr day", - "value": 5, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Current smoker (21+ cigarettes pr day", - "value": 6, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Prefer not to say", - "value": 7, - "is_free_text": false - } - ], - "answer_style": "SingleChoice" - } - } - ] - } - }, - { - "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", - "name": "Task #41", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - } - ], - "type": "survey", - "title": "Symptoms", - "description": "A short 1-item survey on your daily symptoms.", - "instructions": "", - "minutesToComplete": 1, - "expire": 86400000000, - "notification": true, - "rpTask": { - "__type": "RPOrderedTask", - "identifier": "symptoms_survey", - "close_after_finished": true, - "steps": [ - { - "__type": "RPQuestionStep", - "identifier": "symptoms_1", - "title": "Do you have any of the following symptoms today?", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "MultipleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "None", - "value": 1, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Fever (warmer than usual)", - "value": 2, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Dry cough", - "value": 3, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Wet cough", - "value": 4, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Sore throat, runny or blocked nose", - "value": 5, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Loss of taste and smell", - "value": 6, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Difficulty breathing or feeling short of breath", - "value": 7, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Tightness in your chest", - "value": 8, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Dizziness, confusion or vertigo", - "value": 9, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Headache", - "value": 10, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Muscle aches", - "value": 11, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Chills", - "value": 12, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Prefer not to say", - "value": 13, - "is_free_text": false - } - ], - "answer_style": "MultipleChoice" - } - } - ] - } - }, - { - "__type": "dk.cachet.carp.common.application.tasks.AppTask", - "name": "Task #42", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.audio" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - } - ], - "type": "audio", - "title": "reading.title", - "description": "reading.description", - "instructions": "reading.instructions", - "minutesToComplete": 3, - "notification": false - }, - { - "__type": "dk.cachet.carp.common.application.tasks.AppTask", - "name": "Task #43", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.image" - } - ], - "type": "image", - "title": "wound.title", - "description": "wound.description", - "instructions": "wound.instructions", - "minutesToComplete": 3, - "notification": false - }, - { - "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", - "name": "Task #44", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.acceleration" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.rotation" - } - ], - "type": "cognition", - "title": "parkinsons.title", - "description": "parkinsons.description", - "instructions": "", - "minutesToComplete": 3, - "notification": false, - "rpTask": { - "__type": "RPOrderedTask", - "identifier": "parkinsons_assessment", - "close_after_finished": true, - "steps": [ - { - "__type": "RPInstructionStep", - "identifier": "parkinsons_instruction", - "title": "parkinsons.instructions.title", - "text": "parkinsons.instructions.text", - "optional": false - }, - { - "__type": "RPFlankerActivity", - "identifier": "flanker_1", - "title": "RPActivityStep", - "optional": false, - "include_instructions": true, - "include_results": true, - "length_of_test": 30, - "number_of_cards": 10 - }, - { - "__type": "RPTappingActivity", - "identifier": "tapping_1", - "title": "RPActivityStep", - "optional": false, - "include_instructions": true, - "include_results": true, - "length_of_test": 10 - } - ] - } - }, - { - "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", - "name": "Task #45", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - } - ], - "type": "survey", - "title": "The Parkinsons Disease Activities of Daily Living Scale", - "description": "A new simple and brief subjective measure of disability in Parkinsons disease", - "instructions": "", - "minutesToComplete": 1, - "expire": 86400000000, - "notification": true, - "rpTask": { - "__type": "RPOrderedTask", - "identifier": "parkinsons_survey", - "close_after_finished": true, - "steps": [ - { - "__type": "RPQuestionStep", - "identifier": "parkinsons_1", - "title": "Please tick one of the descriptions that best describes how your Parkinsons disease has affected your day-to-day activities in the last month.", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "SingleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "No difficulties with day-to-day activities.", - "value": 1, - "detail_text": "For example: Your Parkinsons disease at present is not affecting your daily living.", - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Mild difficulties with day-to-day activities.", - "value": 2, - "detail_text": "For example: Slowness with some aspects of housework, gardening or shopping. Able to dress and manage personal hygiene completely independently but rate is slower. You may feel that your medication is not quite effective as it was.", - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Moderate difficulties with day-to-day activities.", - "value": 3, - "detail_text": "For example: Your Parkinsons disease is interfering with your daily activities. It is increasingly difficult to do simple activities without some help such as rising from a chair, washing, dressing, shopping, housework. You may have some difficulties walking and may require assistance. Difficulties with recreational activities or the ability to drive a car. The medication is now less effective.", - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "High levels of difficulties with day-to-day activities.", - "value": 4, - "detail_text": "For example: You now require much more assistance with activities of daily living such as washing, dressing, housework or feeding yourself. You may have greater difficulties with mobility and find you are becoming more dependent for assistance from others or aids and appliances. Your medication appears to be significantly less effective.", - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Extreme difficulties with day-to-day activities.", - "value": 5, - "detail_text": "For example: You require assistance in all daily activities. These may include dressing, washing, feeding yourself or walking unaided. You may now be housebound and obtain little or no benefit from your medication.", - "is_free_text": false - } - ], - "answer_style": "SingleChoice" - } - } - ] - } - } - ], - "triggers": { - "0": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "1": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "2": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "3": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "4": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "5": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "6": { - "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "7": { - "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", - "sourceDeviceRoleName": "Primary Phone", - "period": 300000000 - }, - "8": { - "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "9": { - "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", - "sourceDeviceRoleName": "Primary Phone", - "period": 1800000000 - }, - "10": { - "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", - "sourceDeviceRoleName": "Primary Phone", - "period": 1800000000 - }, - "11": { - "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "12": { - "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "13": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "14": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "15": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "16": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "17": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "18": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "19": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #39", - "triggerCondition": "done" - }, - "20": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #40", - "triggerCondition": "done" - }, - "21": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #41", - "triggerCondition": "done" - }, - "22": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #42", - "triggerCondition": "done" - }, - "23": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #43", - "triggerCondition": "done" - }, - "24": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #44", - "triggerCondition": "done" - }, - "25": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #44", - "triggerCondition": "done" - } - }, - "taskControls": [ - { - "triggerId": 0, - "taskName": "Task #26", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 1, - "taskName": "Task #27", - "destinationDeviceRoleName": "eSense", - "control": "Start" - }, - { - "triggerId": 2, - "taskName": "Task #28", - "destinationDeviceRoleName": "Polar HR Device", - "control": "Start" - }, - { - "triggerId": 3, - "taskName": "Task #29", - "destinationDeviceRoleName": "Location Service", - "control": "Start" - }, - { - "triggerId": 4, - "taskName": "Task #30", - "destinationDeviceRoleName": "Weather Service", - "control": "Start" - }, - { - "triggerId": 5, - "taskName": "Task #31", - "destinationDeviceRoleName": "Air Quality Service", - "control": "Start" - }, - { - "triggerId": 6, - "taskName": "Task #32", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 7, - "taskName": "Task #33", - "destinationDeviceRoleName": "Location Service", - "control": "Start" - }, - { - "triggerId": 8, - "taskName": "Task #34", - "destinationDeviceRoleName": "Location Service", - "control": "Start" - }, - { - "triggerId": 9, - "taskName": "Task #35", - "destinationDeviceRoleName": "Weather Service", - "control": "Start" - }, - { - "triggerId": 10, - "taskName": "Task #36", - "destinationDeviceRoleName": "Air Quality Service", - "control": "Start" - }, - { - "triggerId": 11, - "taskName": "Task #37", - "destinationDeviceRoleName": "eSense", - "control": "Start" - }, - { - "triggerId": 12, - "taskName": "Task #38", - "destinationDeviceRoleName": "Polar HR Device", - "control": "Start" - }, - { - "triggerId": 13, - "taskName": "Task #39", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 14, - "taskName": "Task #40", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 15, - "taskName": "Task #41", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 16, - "taskName": "Task #42", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 17, - "taskName": "Task #43", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 18, - "taskName": "Task #44", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 19, - "taskName": "Task #39", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 20, - "taskName": "Task #40", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 21, - "taskName": "Task #41", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 22, - "taskName": "Task #42", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 23, - "taskName": "Task #43", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 24, - "taskName": "Task #44", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 25, - "taskName": "Task #45", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - } - ], - "participantRoles": [ - { - "role": "Participant", - "isOptional": false - } - ], - "assignedDevices": {}, - "expectedParticipantData": [] -} \ No newline at end of file diff --git a/test/services_test.dart b/test/services_test.dart new file mode 100644 index 00000000..632828a5 --- /dev/null +++ b/test/services_test.dart @@ -0,0 +1,361 @@ +import 'package:carp_backend/carp_backend.dart'; +import 'package:carp_context_package/carp_context_package.dart'; +import 'package:carp_core/carp_core.dart' as core; +import 'package:carp_webservices/carp_auth/carp_auth.dart'; +import 'package:cognition_package/cognition_package.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:research_package/research_package.dart'; + +import 'exports.dart'; +import 'test_utils.dart'; +import 'services_test.mocks.dart'; + +class _FakeMessageManager extends MessageManager { + List toReturn = []; + bool throwOnGet = false; + int initializeCount = 0; + int getCount = 0; + + @override + void initialize() => initializeCount++; + + @override + Future getMessage(String messageId) async => null; + + @override + Future> getMessages({DateTime? start, DateTime? end, int? count = 20}) async { + getCount++; + if (throwOnGet) throw Exception('offline'); + return List.of(toReturn); + } + + @override + Future setMessage(Message message) async {} + + @override + Future deleteMessage(String messageId) async {} + + @override + Future deleteAllMessages() async {} +} + +class _FakeConsentManager extends InformedConsentManager { + RPOrderedTask? document; + bool? lastRefresh; + + @override + RPOrderedTask? get informedConsent => document; + + @override + Future getConsentDocument({bool refresh = false}) async { + lastRefresh = refresh; + return document; + } + + @override + Future setConsentDocument(RPOrderedTask informedConsent) async => true; + + @override + Future deleteConsentDocument() async => true; +} + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) +void main() { + setUpAll(() async { + CarpMobileSensing.ensureInitialized(); + ResearchPackage.ensureInitialized(); + CognitionPackage.ensureInitialized(); + await initTestSettings(); + }); + + setUp(() { + AppConfig.deploymentMode = DeploymentMode.local; + }); + + group('AppConfig', () { + test('defaults to info debug level when no environment is given', () { + // No --dart-define values are set in tests, so the default applies. + expect(AppConfig.debugLevel, DebugLevel.info); + }); + }); + + group('MessageService', () { + Message message(String id, DateTime timestamp) => Message(id: id, title: 'message-$id', timestamp: timestamp); + + test('refresh sorts messages newest first and emits the count', () async { + final manager = _FakeMessageManager() + ..toReturn = [ + message('old', DateTime(2024, 1, 1)), + message('new', DateTime(2025, 1, 1)), + message('mid', DateTime(2024, 6, 1)), + ]; + final service = MessageService(manager); + + final emitted = expectLater(service.stream, emits(3)); + await service.refresh(); + await emitted; + + expect(service.messages.map((m) => m.id), ['new', 'mid', 'old']); + expect(service.byId('mid')?.id, 'mid'); + expect(service.byId('unknown'), isNull); + }); + + test('refresh keeps the old list and does not throw when the manager fails', () async { + final manager = _FakeMessageManager()..toReturn = [message('a', DateTime(2024, 1, 1))]; + final service = MessageService(manager); + await service.refresh(); + + manager.throwOnGet = true; + await service.refresh(); + + expect(service.messages.length, 1); + }); + + test('start polls periodically, is idempotent, and stop cancels the timer', () { + fakeAsync((async) { + final manager = _FakeMessageManager(); + final service = MessageService(manager, pollingInterval: const Duration(minutes: 30)); + + service.start(); + service.start(); // no duplicate timer + async.flushMicrotasks(); + expect(manager.initializeCount, 2); + expect(manager.getCount, 2); // one refresh per start() call + + async.elapse(const Duration(minutes: 61)); + expect(manager.getCount, 4); // two polls from a single timer + + service.stop(); + async.elapse(const Duration(hours: 2)); + expect(manager.getCount, 4); + + service.dispose(); + }); + }); + + test('dispose closes the stream and refresh is still safe', () async { + final service = MessageService(_FakeMessageManager()); + service.dispose(); + await expectLater(service.stream, emitsDone); + await service.refresh(); // must not throw on the closed controller + }); + }); + + group('AuthService', () { + late MockCarpBackend backend; + late AuthService auth; + + setUp(() { + backend = MockCarpBackend(); + auth = AuthService(backend: backend); + }); + + test('username and friendlyUsername are empty when signed out', () { + when(backend.user).thenReturn(null); + + expect(auth.user, isNull); + expect(auth.username, ''); + expect(auth.friendlyUsername, ''); + }); + + test('username and friendlyUsername come from the signed-in user', () { + when(backend.user).thenReturn(CarpUser(username: 'jdoe', id: '42', firstName: 'John')); + + expect(auth.username, 'jdoe'); + expect(auth.friendlyUsername, 'John'); + }); + + test('delegates authentication state to the backend', () { + when(backend.isAuthenticated).thenReturn(true); + + expect(auth.isAuthenticated, isTrue); + expect(auth.invitations, isEmpty); + }); + + test('getInvitations keeps only invitations assigned to a smartphone', () async { + ActiveParticipationInvitation invitation(PrimaryDeviceConfiguration? device) { + final invitation = ActiveParticipationInvitation( + Participation('dep-1', 'participant-1', AssignedTo()), + StudyInvitation('Test study'), + ); + if (device != null) invitation.assignedDevices = [AssignedPrimaryDevice(device: device)]; + return invitation; + } + + final forPhone = invitation(Smartphone()); + final forOtherDevice = invitation(core.Smartphone(roleName: 'web')); + final unassigned = invitation(null); + when(backend.getInvitations()).thenAnswer((_) async => [forPhone, forOtherDevice, unassigned]); + + final invitations = await auth.getInvitations(); + + expect(invitations, [forPhone, unassigned]); + expect(auth.invitations, [forPhone, unassigned]); + }); + }); + + group('ConsentService', () { + late _FakeConsentManager manager; + late MockCarpBackend backend; + late ConsentService consent; + + setUp(() { + manager = _FakeConsentManager(); + backend = MockCarpBackend(); + consent = ConsentService(manager, backend: backend); + }); + + test('getDocument delegates to the consent manager', () async { + expect(await consent.getDocument(refresh: true), isNull); + expect(manager.lastRefresh, isTrue); + }); + + test('local mode falls back to the locally stored participant flag', () async { + LocalSettings().participant = Participant(studyDeploymentId: 'dep-1'); + expect(await consent.hasBeenAccepted(null), isFalse); + + await consent.accept(); + expect(await consent.hasBeenAccepted(null), isTrue); + }); + + test('caches the consent status for synchronous reads', () async { + LocalSettings().participant = Participant(studyDeploymentId: 'dep-cache'); + + expect(consent.isAccepted, isNull); + expect(await consent.refreshStatus(null), isFalse); + expect(consent.isAccepted, isFalse); + + await consent.accept(); + expect(consent.isAccepted, isTrue); + + consent.reset(); + expect(consent.isAccepted, isNull); + }); + + test('accept notifies listeners', () async { + LocalSettings().participant = Participant(studyDeploymentId: 'dep-1'); + var notified = false; + consent.addListener(() => notified = true); + + await consent.accept(); + expect(notified, isTrue); + }); + + test('non-local mode asks the backend and is false when it fails', () async { + AppConfig.deploymentMode = DeploymentMode.test; + final study = SmartphoneStudy(studyDeploymentId: 'dep-1', deviceRoleName: 'phone'); + when(backend.getInformedConsentByRole('dep-1', null)).thenAnswer((_) async => throw Exception('offline')); + + expect(await consent.hasBeenAccepted(study), isFalse); + }); + + test('non-local mode is true when the backend has a consent document', () async { + AppConfig.deploymentMode = DeploymentMode.test; + final study = SmartphoneStudy(studyDeploymentId: 'dep-1', deviceRoleName: 'phone'); + when( + backend.getInformedConsentByRole('dep-1', null), + ).thenAnswer((_) async => InformedConsentInput(userId: '42', name: 'jdoe', consent: '{}', signatureImage: '')); + + expect(await consent.hasBeenAccepted(study), isTrue); + }); + }); + + group('Old-namespace CAWS compatibility', () { + test('a pre-CAMS-2.x deployment status with CAMS device types deserializes', () { + Sensing(); // registers the sampling packages + + // The shape CAWS returns for deployments created before CAMS 2.x - + // device types are in the old 'dk.cachet.carp.common.application.devices' + // namespace, while CAMS 2.x registers them as 'dk.carp.cams.devices'. + final json = { + '__type': 'dk.cachet.carp.deployments.application.StudyDeploymentStatus.Running', + 'createdOn': '2026-06-10T12:46:37.103476598Z', + 'studyDeploymentId': '3014e5bc-7f79-45e9-afc3-02a38bfc888f', + 'deviceStatusList': [ + { + '__type': 'dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Deployed', + 'device': { + '__type': 'dk.cachet.carp.common.application.devices.Smartphone', + 'isPrimaryDevice': true, + 'roleName': 'Primary Phone', + }, + }, + { + '__type': 'dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Registered', + 'device': { + '__type': 'dk.cachet.carp.common.application.devices.LocationService', + 'accuracy': 'balanced', + 'distance': 10, + 'interval': 60000000, + 'roleName': 'Location Service', + 'isOptional': true, + 'defaultSamplingConfiguration': {}, + }, + 'canBeDeployed': false, + 'remainingDevicesToRegisterToObtainDeployment': [], + 'remainingDevicesToRegisterBeforeDeployment': [], + }, + ], + 'participantStatusList': >[], + }; + + final status = StudyDeploymentStatus.fromJson(json); + + expect(status.deviceStatusList.length, 2); + expect(status.deviceStatusList[0].device, isA()); + expect(status.deviceStatusList[1].device, isA()); + // The aliased instances carry the CAMS 2.x type, so device-manager + // lookups by type string work. + expect(status.deviceStatusList[1].device.type, 'dk.carp.cams.devices.LocationService'); + }); + }); + + group('StudyService', () { + late StudyService service; + + setUp(() { + service = StudyService(); + }); + + test('capability queries are false without a deployment', () { + expect(service.isDeployed, isFalse); + expect(service.deployment, isNull); + expect(service.studyStartTimestamp, isNull); + expect(service.hasMeasures(), isFalse); + expect(service.hasMeasure(AppTask.SURVEY_TYPE), isFalse); + expect(service.hasUserTasks(), isFalse); + expect(service.expectedParticipantData, isEmpty); + }); + + test('start is a safe no-op when the study is not deployed', () async { + await service.start(); // no controller - must not throw + expect(service.isRunning, isFalse); + }); + + test('tryDeployment is a safe no-op before the study is added', () async { + expect(await service.tryDeployment(), isNull); + }); + + test('setting the study persists it and hasStudy reflects it', () { + service.study = SmartphoneStudy(studyDeploymentId: 'dep-2', deviceRoleName: 'phone'); + + expect(service.hasStudy, isTrue); + expect(service.study?.studyDeploymentId, 'dep-2'); + }); + + test('configure throws and stays retryable when deployment cannot succeed', () async { + service.study = SmartphoneStudy(studyDeploymentId: 'dep-2', deviceRoleName: 'phone'); + + // The client manager is not configured in unit tests, so configure() + // must fail - and fail again on retry rather than being stuck. + await expectLater(service.configure(), throwsA(anything)); + await expectLater(service.configure(), throwsA(anything)); + }); + }); +} diff --git a/test/services_test.mocks.dart b/test/services_test.mocks.dart new file mode 100644 index 00000000..eca70298 --- /dev/null +++ b/test/services_test.mocks.dart @@ -0,0 +1,653 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in carp_study_app/test/services_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i7; + +import 'package:carp_core/carp_core.dart' as _i6; +import 'package:carp_mobile_sensing/carp_mobile_sensing.dart' as _i4; +import 'package:carp_study_app/main.dart' as _i5; +import 'package:carp_webservices/carp_auth/carp_auth.dart' as _i3; +import 'package:carp_webservices/carp_services/carp_services.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i9; +import 'package:research_package/research_package.dart' as _i8; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeUri_0 extends _i1.SmartFake implements Uri { + _FakeUri_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeCarpApp_1 extends _i1.SmartFake implements _i2.CarpApp { + _FakeCarpApp_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeCarpAuthProperties_2 extends _i1.SmartFake implements _i3.CarpAuthProperties { + _FakeCarpAuthProperties_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeCarpUser_3 extends _i1.SmartFake implements _i3.CarpUser { + _FakeCarpUser_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeAppTask_4 extends _i1.SmartFake implements _i4.AppTask { + _FakeAppTask_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeDateTime_5 extends _i1.SmartFake implements DateTime { + _FakeDateTime_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeAppTaskExecutor_6 extends _i1.SmartFake + implements _i4.AppTaskExecutor { + _FakeAppTaskExecutor_6(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeBackgroundTaskExecutor_7 extends _i1.SmartFake implements _i4.BackgroundTaskExecutor { + _FakeBackgroundTaskExecutor_7(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +/// A class which mocks [CarpBackend]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { + @override + Uri get uri => + (super.noSuchMethod( + Invocation.getter(#uri), + returnValue: _FakeUri_0(this, Invocation.getter(#uri)), + returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#uri)), + ) + as Uri); + + @override + Uri get authUri => + (super.noSuchMethod( + Invocation.getter(#authUri), + returnValue: _FakeUri_0(this, Invocation.getter(#authUri)), + returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#authUri)), + ) + as Uri); + + @override + _i2.CarpApp get app => + (super.noSuchMethod( + Invocation.getter(#app), + returnValue: _FakeCarpApp_1(this, Invocation.getter(#app)), + returnValueForMissingStub: _FakeCarpApp_1(this, Invocation.getter(#app)), + ) + as _i2.CarpApp); + + @override + _i3.CarpAuthProperties get authProperties => + (super.noSuchMethod( + Invocation.getter(#authProperties), + returnValue: _FakeCarpAuthProperties_2(this, Invocation.getter(#authProperties)), + returnValueForMissingStub: _FakeCarpAuthProperties_2(this, Invocation.getter(#authProperties)), + ) + as _i3.CarpAuthProperties); + + @override + bool get isAuthenticated => + (super.noSuchMethod(Invocation.getter(#isAuthenticated), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + List<_i6.ActiveParticipationInvitation> get invitations => + (super.noSuchMethod( + Invocation.getter(#invitations), + returnValue: <_i6.ActiveParticipationInvitation>[], + returnValueForMissingStub: <_i6.ActiveParticipationInvitation>[], + ) + as List<_i6.ActiveParticipationInvitation>); + + @override + set user(_i3.CarpUser? user) => super.noSuchMethod(Invocation.setter(#user, user), returnValueForMissingStub: null); + + @override + set invitations(List<_i6.ActiveParticipationInvitation>? value) => + super.noSuchMethod(Invocation.setter(#invitations, value), returnValueForMissingStub: null); + + @override + set study(_i4.SmartphoneStudy? study) => + super.noSuchMethod(Invocation.setter(#study, study), returnValueForMissingStub: null); + + @override + _i7.Future initialize() => + (super.noSuchMethod( + Invocation.method(#initialize, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future authenticate() => + (super.noSuchMethod( + Invocation.method(#authenticate, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future authenticateWithMagicLink(String? uri) => + (super.noSuchMethod( + Invocation.method(#authenticateWithMagicLink, [uri]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i3.CarpUser> refresh() => + (super.noSuchMethod( + Invocation.method(#refresh, []), + returnValue: _i7.Future<_i3.CarpUser>.value(_FakeCarpUser_3(this, Invocation.method(#refresh, []))), + returnValueForMissingStub: _i7.Future<_i3.CarpUser>.value( + _FakeCarpUser_3(this, Invocation.method(#refresh, [])), + ), + ) + as _i7.Future<_i3.CarpUser>); + + @override + _i7.Future signOut() => + (super.noSuchMethod( + Invocation.method(#signOut, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future> getInvitations() => + (super.noSuchMethod( + Invocation.method(#getInvitations, []), + returnValue: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), + returnValueForMissingStub: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), + ) + as _i7.Future>); + + @override + _i7.Future<_i6.InformedConsentInput?> uploadInformedConsent(_i8.RPTaskResult? consent) => + (super.noSuchMethod( + Invocation.method(#uploadInformedConsent, [consent]), + returnValue: _i7.Future<_i6.InformedConsentInput?>.value(), + returnValueForMissingStub: _i7.Future<_i6.InformedConsentInput?>.value(), + ) + as _i7.Future<_i6.InformedConsentInput?>); + + @override + _i7.Future<_i6.InformedConsentInput?>? getInformedConsentByRole(String? studyDeploymentId, String? role) => + (super.noSuchMethod( + Invocation.method(#getInformedConsentByRole, [studyDeploymentId, role]), + returnValueForMissingStub: null, + ) + as _i7.Future<_i6.InformedConsentInput?>?); +} + +/// A class which mocks [AuthService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAuthService extends _i1.Mock implements _i5.AuthService { + @override + bool get isAuthenticated => + (super.noSuchMethod(Invocation.getter(#isAuthenticated), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + String get username => + (super.noSuchMethod( + Invocation.getter(#username), + returnValue: _i9.dummyValue(this, Invocation.getter(#username)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#username)), + ) + as String); + + @override + String get friendlyUsername => + (super.noSuchMethod( + Invocation.getter(#friendlyUsername), + returnValue: _i9.dummyValue(this, Invocation.getter(#friendlyUsername)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#friendlyUsername)), + ) + as String); + + @override + Uri get serverUri => + (super.noSuchMethod( + Invocation.getter(#serverUri), + returnValue: _FakeUri_0(this, Invocation.getter(#serverUri)), + returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#serverUri)), + ) + as Uri); + + @override + List<_i6.ActiveParticipationInvitation> get invitations => + (super.noSuchMethod( + Invocation.getter(#invitations), + returnValue: <_i6.ActiveParticipationInvitation>[], + returnValueForMissingStub: <_i6.ActiveParticipationInvitation>[], + ) + as List<_i6.ActiveParticipationInvitation>); + + @override + _i7.Future initialize() => + (super.noSuchMethod( + Invocation.method(#initialize, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future> getInvitations() => + (super.noSuchMethod( + Invocation.method(#getInvitations, []), + returnValue: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), + returnValueForMissingStub: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), + ) + as _i7.Future>); + + @override + _i7.Future authenticate() => + (super.noSuchMethod( + Invocation.method(#authenticate, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future authenticateWithMagicLink(String? uri) => + (super.noSuchMethod( + Invocation.method(#authenticateWithMagicLink, [uri]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future signOut() => + (super.noSuchMethod( + Invocation.method(#signOut, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); +} + +/// A class which mocks [SystemInfoService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSystemInfoService extends _i1.Mock implements _i5.SystemInfoService { + @override + _i7.Future checkConnectivity() => + (super.noSuchMethod( + Invocation.method(#checkConnectivity, []), + returnValue: _i7.Future.value(false), + returnValueForMissingStub: _i7.Future.value(false), + ) + as _i7.Future); + + @override + _i7.Future isHealthInstalled() => + (super.noSuchMethod( + Invocation.method(#isHealthInstalled, []), + returnValue: _i7.Future.value(false), + returnValueForMissingStub: _i7.Future.value(false), + ) + as _i7.Future); + + @override + _i7.Future getAppHasUpdate() => + (super.noSuchMethod( + Invocation.method(#getAppHasUpdate, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); +} + +/// A class which mocks [UserTask]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockUserTask extends _i1.Mock implements _i4.UserTask { + @override + _i4.AppTask get task => + (super.noSuchMethod( + Invocation.getter(#task), + returnValue: _FakeAppTask_4(this, Invocation.getter(#task)), + returnValueForMissingStub: _FakeAppTask_4(this, Invocation.getter(#task)), + ) + as _i4.AppTask); + + @override + String get id => + (super.noSuchMethod( + Invocation.getter(#id), + returnValue: _i9.dummyValue(this, Invocation.getter(#id)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#id)), + ) + as String); + + @override + String get type => + (super.noSuchMethod( + Invocation.getter(#type), + returnValue: _i9.dummyValue(this, Invocation.getter(#type)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#type)), + ) + as String); + + @override + String get name => + (super.noSuchMethod( + Invocation.getter(#name), + returnValue: _i9.dummyValue(this, Invocation.getter(#name)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#name)), + ) + as String); + + @override + String get title => + (super.noSuchMethod( + Invocation.getter(#title), + returnValue: _i9.dummyValue(this, Invocation.getter(#title)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#title)), + ) + as String); + + @override + String get description => + (super.noSuchMethod( + Invocation.getter(#description), + returnValue: _i9.dummyValue(this, Invocation.getter(#description)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#description)), + ) + as String); + + @override + String get instructions => + (super.noSuchMethod( + Invocation.getter(#instructions), + returnValue: _i9.dummyValue(this, Invocation.getter(#instructions)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#instructions)), + ) + as String); + + @override + bool get notification => + (super.noSuchMethod(Invocation.getter(#notification), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + DateTime get triggerTime => + (super.noSuchMethod( + Invocation.getter(#triggerTime), + returnValue: _FakeDateTime_5(this, Invocation.getter(#triggerTime)), + returnValueForMissingStub: _FakeDateTime_5(this, Invocation.getter(#triggerTime)), + ) + as DateTime); + + @override + DateTime get enqueued => + (super.noSuchMethod( + Invocation.getter(#enqueued), + returnValue: _FakeDateTime_5(this, Invocation.getter(#enqueued)), + returnValueForMissingStub: _FakeDateTime_5(this, Invocation.getter(#enqueued)), + ) + as DateTime); + + @override + _i4.UserTaskState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _i4.UserTaskState.initialized, + returnValueForMissingStub: _i4.UserTaskState.initialized, + ) + as _i4.UserTaskState); + + @override + bool get availableForUser => + (super.noSuchMethod(Invocation.getter(#availableForUser), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + bool get hasNotificationBeenCreated => + (super.noSuchMethod( + Invocation.getter(#hasNotificationBeenCreated), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); + + @override + _i7.Stream<_i4.UserTaskState> get stateEvents => + (super.noSuchMethod( + Invocation.getter(#stateEvents), + returnValue: _i7.Stream<_i4.UserTaskState>.empty(), + returnValueForMissingStub: _i7.Stream<_i4.UserTaskState>.empty(), + ) + as _i7.Stream<_i4.UserTaskState>); + + @override + _i4.AppTaskExecutor<_i4.AppTask> get appTaskExecutor => + (super.noSuchMethod( + Invocation.getter(#appTaskExecutor), + returnValue: _FakeAppTaskExecutor_6<_i4.AppTask>(this, Invocation.getter(#appTaskExecutor)), + returnValueForMissingStub: _FakeAppTaskExecutor_6<_i4.AppTask>(this, Invocation.getter(#appTaskExecutor)), + ) + as _i4.AppTaskExecutor<_i4.AppTask>); + + @override + _i4.BackgroundTaskExecutor get backgroundTaskExecutor => + (super.noSuchMethod( + Invocation.getter(#backgroundTaskExecutor), + returnValue: _FakeBackgroundTaskExecutor_7(this, Invocation.getter(#backgroundTaskExecutor)), + returnValueForMissingStub: _FakeBackgroundTaskExecutor_7(this, Invocation.getter(#backgroundTaskExecutor)), + ) + as _i4.BackgroundTaskExecutor); + + @override + bool get hasWidget => + (super.noSuchMethod(Invocation.getter(#hasWidget), returnValue: false, returnValueForMissingStub: false) as bool); + + @override + set id(String? value) => super.noSuchMethod(Invocation.setter(#id, value), returnValueForMissingStub: null); + + @override + set triggerTime(DateTime? value) => + super.noSuchMethod(Invocation.setter(#triggerTime, value), returnValueForMissingStub: null); + + @override + set enqueued(DateTime? value) => + super.noSuchMethod(Invocation.setter(#enqueued, value), returnValueForMissingStub: null); + + @override + set doneTime(DateTime? value) => + super.noSuchMethod(Invocation.setter(#doneTime, value), returnValueForMissingStub: null); + + @override + set state(_i4.UserTaskState? state) => + super.noSuchMethod(Invocation.setter(#state, state), returnValueForMissingStub: null); + + @override + set hasNotificationBeenCreated(bool? value) => + super.noSuchMethod(Invocation.setter(#hasNotificationBeenCreated, value), returnValueForMissingStub: null); + + @override + set backgroundTaskExecutor(_i4.BackgroundTaskExecutor? value) => + super.noSuchMethod(Invocation.setter(#backgroundTaskExecutor, value), returnValueForMissingStub: null); + + @override + set result(_i6.Data? value) => super.noSuchMethod(Invocation.setter(#result, value), returnValueForMissingStub: null); + + @override + void onStart() => super.noSuchMethod(Invocation.method(#onStart, []), returnValueForMissingStub: null); + + @override + void onCancel({bool? dequeue = false}) => + super.noSuchMethod(Invocation.method(#onCancel, [], {#dequeue: dequeue}), returnValueForMissingStub: null); + + @override + void onExpired() => super.noSuchMethod(Invocation.method(#onExpired, []), returnValueForMissingStub: null); + + @override + void onDone({bool? dequeue = false, _i6.Data? result}) => super.noSuchMethod( + Invocation.method(#onDone, [], {#dequeue: dequeue, #result: result}), + returnValueForMissingStub: null, + ); + + @override + void onNotification() => super.noSuchMethod(Invocation.method(#onNotification, []), returnValueForMissingStub: null); +} + +/// A class which mocks [StudyService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockStudyService extends _i1.Mock implements _i5.StudyService { + @override + bool get hasStudy => + (super.noSuchMethod(Invocation.getter(#hasStudy), returnValue: false, returnValueForMissingStub: false) as bool); + + @override + bool get isDeployed => + (super.noSuchMethod(Invocation.getter(#isDeployed), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + Set<_i6.ExpectedParticipantData?> get expectedParticipantData => + (super.noSuchMethod( + Invocation.getter(#expectedParticipantData), + returnValue: <_i6.ExpectedParticipantData?>{}, + returnValueForMissingStub: <_i6.ExpectedParticipantData?>{}, + ) + as Set<_i6.ExpectedParticipantData?>); + + @override + bool get isRunning => + (super.noSuchMethod(Invocation.getter(#isRunning), returnValue: false, returnValueForMissingStub: false) as bool); + + @override + Iterable<_i5.DeviceViewModel> get deploymentDevices => + (super.noSuchMethod( + Invocation.getter(#deploymentDevices), + returnValue: <_i5.DeviceViewModel>[], + returnValueForMissingStub: <_i5.DeviceViewModel>[], + ) + as Iterable<_i5.DeviceViewModel>); + + @override + set study(_i4.SmartphoneStudy? study) => + super.noSuchMethod(Invocation.setter(#study, study), returnValueForMissingStub: null); + + @override + _i7.Future<_i6.StudyDeploymentStatus?> refreshDeploymentStatus() => + (super.noSuchMethod( + Invocation.method(#refreshDeploymentStatus, []), + returnValue: _i7.Future<_i6.StudyDeploymentStatus?>.value(), + returnValueForMissingStub: _i7.Future<_i6.StudyDeploymentStatus?>.value(), + ) + as _i7.Future<_i6.StudyDeploymentStatus?>); + + @override + _i7.Future configure() => + (super.noSuchMethod( + Invocation.method(#configure, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i6.StudyStatus?> tryDeployment() => + (super.noSuchMethod( + Invocation.method(#tryDeployment, []), + returnValue: _i7.Future<_i6.StudyStatus?>.value(), + returnValueForMissingStub: _i7.Future<_i6.StudyStatus?>.value(), + ) + as _i7.Future<_i6.StudyStatus?>); + + @override + _i7.Future start() => + (super.noSuchMethod( + Invocation.method(#start, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + void addMeasurement(_i6.Measurement? measurement) => + super.noSuchMethod(Invocation.method(#addMeasurement, [measurement]), returnValueForMissingStub: null); + + @override + bool hasMeasures() => + (super.noSuchMethod(Invocation.method(#hasMeasures, []), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + bool hasMeasure(String? type) => + (super.noSuchMethod(Invocation.method(#hasMeasure, [type]), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + bool hasUserTasks() => + (super.noSuchMethod(Invocation.method(#hasUserTasks, []), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + _i7.Future> getParticipantDataListFromDeployment() => + (super.noSuchMethod( + Invocation.method(#getParticipantDataListFromDeployment, []), + returnValue: _i7.Future>.value(<_i6.ParticipantData>[]), + returnValueForMissingStub: _i7.Future>.value(<_i6.ParticipantData>[]), + ) + as _i7.Future>); + + @override + void setParticipantData(Map? data) => + super.noSuchMethod(Invocation.method(#setParticipantData, [data]), returnValueForMissingStub: null); + + @override + _i7.Future deployLocalProtocol() => + (super.noSuchMethod( + Invocation.method(#deployLocalProtocol, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future remove() => + (super.noSuchMethod( + Invocation.method(#remove, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); +} diff --git a/test/test_utils.dart b/test/test_utils.dart new file mode 100644 index 00000000..b1da9f71 --- /dev/null +++ b/test/test_utils.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'exports.dart'; + +/// Initialize CAMS [Settings] for unit tests by mocking the platform +/// channels it depends on (shared preferences, package info, paths). +Future initTestSettings() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + SharedPreferences.setMockInitialValues({}); + PackageInfo.setMockInitialValues( + appName: 'carp_study_app_test', + packageName: 'dk.cachet.carp_study_app.test', + version: '0.0.0', + buildNumber: '0', + buildSignature: '', + ); + PathProviderPlatform.instance = FakePathProviderPlatform(); + + await Settings().init(); +} + +class FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { + static final String _tempPath = Directory.systemTemp.createTempSync('carp_study_app_test').path; + + @override + Future getApplicationDocumentsPath() async => _tempPath; + + @override + Future getApplicationSupportPath() async => _tempPath; + + @override + Future getTemporaryPath() async => _tempPath; + + @override + Future getLibraryPath() async => _tempPath; +} diff --git a/test/view_models_test.dart b/test/view_models_test.dart new file mode 100644 index 00000000..e26ca071 --- /dev/null +++ b/test/view_models_test.dart @@ -0,0 +1,319 @@ +import 'package:carp_backend/carp_backend.dart'; +import 'package:carp_audio_package/media.dart'; +import 'package:cognition_package/cognition_package.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:research_package/research_package.dart'; + +import 'exports.dart'; +import 'test_utils.dart'; +import 'services_test.mocks.dart'; + +class _FakeMessageManager extends MessageManager { + List toReturn = []; + + @override + void initialize() {} + + @override + Future getMessage(String messageId) async => null; + + @override + Future> getMessages({DateTime? start, DateTime? end, int? count = 20}) async => List.of(toReturn); + + @override + Future setMessage(Message message) async {} + + @override + Future deleteMessage(String messageId) async {} + + @override + Future deleteAllMessages() async {} +} + +void main() { + setUpAll(() async { + CarpMobileSensing.ensureInitialized(); + ResearchPackage.ensureInitialized(); + CognitionPackage.ensureInitialized(); + await initTestSettings(); + AppConfig.deploymentMode = DeploymentMode.local; + }); + + group('StudyPageViewModel', () { + test('falls back to defaults when nothing is deployed', () { + final model = StudyPageViewModel( + studyService: StudyService(), + messageService: MessageService(_FakeMessageManager()), + ); + + expect(model.title, 'Unnamed'); + expect(model.description, ''); + expect(model.privacyPolicyUrl, 'https://carp.dk/privacy-policy-app/'); + expect(model.studyDeploymentId, ''); + expect(model.messages, isEmpty); + }); + + test('shows messages from the message service, oldest first', () async { + final manager = _FakeMessageManager() + ..toReturn = [ + Message(id: 'new', timestamp: DateTime(2025, 1, 1)), + Message(id: 'old', timestamp: DateTime(2024, 1, 1)), + ]; + final messages = MessageService(manager); + await messages.refresh(); + final model = StudyPageViewModel(studyService: StudyService(), messageService: messages); + + // The service keeps them newest first; the page shows them reversed. + expect(model.messages.map((m) => m.id), ['old', 'new']); + }); + + test('refresh completes safely when the study is not yet deployed', () async { + final manager = _FakeMessageManager()..toReturn = [Message(id: 'a', timestamp: DateTime(2024, 1, 1))]; + final messages = MessageService(manager); + final model = StudyPageViewModel(studyService: StudyService(), messageService: messages); + + await model.refresh(); // no deployment, no controller - must not throw + + expect(model.messages.length, 1); + }); + + test('init checks for an app update and notifies', () async { + final system = MockSystemInfoService(); + when(system.getAppHasUpdate()).thenAnswer((_) async => true); + final model = StudyPageViewModel( + studyService: StudyService(), + messageService: MessageService(_FakeMessageManager()), + systemInfoService: system, + ); + var notified = false; + model.addListener(() => notified = true); + + model.init(MockSmartphoneStudyController()); + await Future.delayed(Duration.zero); + + expect(model.appUpdateAvailable, isTrue); + expect(notified, isTrue); + }); + }); + + group('LoginViewModel', () { + late MockAuthService auth; + late MockSystemInfoService system; + late LoginViewModel model; + + setUp(() { + auth = MockAuthService(); + system = MockSystemInfoService(); + model = LoginViewModel(authService: auth, systemInfoService: system); + }); + + test('signIn is offline without authenticating when there is no connectivity', () async { + when(system.checkConnectivity()).thenAnswer((_) async => false); + + expect(await model.signIn(), SignInResult.offline); + verifyNever(auth.authenticate()); + }); + + test('signIn initializes, authenticates, and reports success', () async { + when(system.checkConnectivity()).thenAnswer((_) async => true); + when(auth.isAuthenticated).thenReturn(true); + + expect(await model.signIn(), SignInResult.success); + verifyInOrder([auth.initialize(), auth.authenticate()]); + }); + + test('signIn reports failure when authentication did not stick', () async { + when(system.checkConnectivity()).thenAnswer((_) async => true); + when(auth.isAuthenticated).thenReturn(false); + + expect(await model.signIn(), SignInResult.failed); + }); + + test('signInWithMagicLink rejects non-link codes without signing in', () async { + expect(await model.signInWithMagicLink('not a link'), isFalse); + verifyNever(auth.authenticateWithMagicLink(any)); + }); + + test('signInWithMagicLink authenticates with a valid link', () async { + when(auth.isAuthenticated).thenReturn(true); + + expect(await model.signInWithMagicLink('https://carp.dk/magic'), isTrue); + verify(auth.authenticateWithMagicLink('https://carp.dk/magic')).called(1); + }); + }); + + group('InvitationsViewModel', () { + late MockAuthService auth; + late InvitationsViewModel model; + + setUp(() { + auth = MockAuthService(); + model = InvitationsViewModel(authService: auth); + }); + + test('loads invitations and notifies', () async { + final invitation = ActiveParticipationInvitation( + Participation('dep-1', 'participant-1', AssignedTo()), + StudyInvitation('Test study'), + ); + when(auth.getInvitations()).thenAnswer((_) async => [invitation]); + var notified = false; + model.addListener(() => notified = true); + + expect(model.isLoading, isTrue); + await model.loadInvitations(); + + expect(model.isLoading, isFalse); + expect(model.invitations, [invitation]); + expect(model.getInvitation('participant-1'), invitation); + expect(notified, isTrue); + }); + + test('reports an error when loading fails', () async { + when(auth.getInvitations()).thenAnswer((_) async => throw Exception('offline')); + + await model.loadInvitations(); + + expect(model.hasError, isTrue); + expect(model.isLoading, isFalse); + expect(model.invitations, isEmpty); + }); + + ActiveParticipationInvitation invitation(String participantId) => + ActiveParticipationInvitation(Participation('dep-1', participantId, AssignedTo()), StudyInvitation('Study')); + + test('landingRoute targets the single invitation detail when there is exactly one', () async { + when(auth.getInvitations()).thenAnswer((_) async => [invitation('participant-1')]); + await model.loadInvitations(); + + expect(model.landingRoute, '${InvitationDetailsPage.route}/participant-1'); + }); + + test('landingRoute targets the list for zero or multiple invitations', () async { + when(auth.getInvitations()).thenAnswer((_) async => []); + await model.loadInvitations(); + expect(model.landingRoute, InvitationListPage.route); + + when(auth.getInvitations()).thenAnswer((_) async => [invitation('a'), invitation('b')]); + await model.loadInvitations(); + expect(model.landingRoute, InvitationListPage.route); + }); + + test('ensureInvitationsLoaded loads once; loadInvitations always refreshes', () async { + when(auth.getInvitations()).thenAnswer((_) async => []); + + await model.ensureInvitationsLoaded(); + await model.ensureInvitationsLoaded(); + verify(auth.getInvitations()).called(1); // second call is a no-op + + await model.loadInvitations(); // manual refresh still hits the backend + verify(auth.getInvitations()).called(1); + }); + + test('ensureInvitationsLoaded retries after a failed load', () async { + when(auth.getInvitations()).thenAnswer((_) async => throw Exception('offline')); + await model.ensureInvitationsLoaded(); + expect(model.isLoaded, isFalse); + + when(auth.getInvitations()).thenAnswer((_) async => [invitation('participant-1')]); + await model.ensureInvitationsLoaded(); // not loaded yet, so it retries + expect(model.isLoaded, isTrue); + }); + }); + + group('TaskListPageViewModel', () { + MockUserTask backgroundTask() { + final task = MockUserTask(); + when(task.state).thenReturn(UserTaskState.enqueued); + when(task.hasWidget).thenReturn(false); + when(task.id).thenReturn('task-1'); + return task; + } + + test('startUserTask auto-completes a background task after 10 seconds', () { + fakeAsync((async) { + final task = backgroundTask(); + final model = TaskListPageViewModel(studyService: StudyService()); + + expect(model.startUserTask(task), isFalse); + verify(task.onStart()).called(1); + verifyNever(task.onDone()); + + async.elapse(const Duration(seconds: 11)); + + verify(task.onDone()).called(1); + expect(model.autoCompletedTask, task); + }); + }); + + test('startUserTask does not start an already done task', () { + final task = MockUserTask(); + when(task.state).thenReturn(UserTaskState.done); + + expect(TaskListPageViewModel(studyService: StudyService()).startUserTask(task), isFalse); + verifyNever(task.onStart()); + }); + + test('clear cancels pending auto-complete timers', () { + fakeAsync((async) { + final task = backgroundTask(); + final model = TaskListPageViewModel(studyService: StudyService()); + + model.startUserTask(task); + model.clear(); + async.elapse(const Duration(seconds: 11)); + + verifyNever(task.onDone()); + expect(model.autoCompletedTask, isNull); + }); + }); + + test('checkParticipantData shows the card when no data is set', () async { + final model = TaskListPageViewModel(studyService: StudyService()); + var notified = false; + model.addListener(() => notified = true); + + await model.checkParticipantData(); // no deployment - data list is empty + + expect(model.showParticipantDataCard, isTrue); + expect(notified, isTrue); + }); + }); + + group('DataVisualizationPageViewModel', () { + test('precomputes card availability from the deployment on init', () { + final study = MockStudyService(); + when(study.hasUserTasks()).thenReturn(true); + when(study.hasMeasure(PolarSamplingPackage.HR)).thenReturn(true); + when(study.hasMeasure(MediaSamplingPackage.AUDIO)).thenReturn(true); + final model = DataVisualizationPageViewModel(studyService: study); + + model.init(MockSmartphoneStudyController()); + + expect(model.hasUserTasks, isTrue); + expect(model.hasHeartRateMeasure, isTrue); + expect(model.hasAudioMeasure, isTrue); + expect(model.hasVideoMeasure, isFalse); + expect(model.hasStepsMeasure, isFalse); + expect(model.hasMobilityMeasure, isFalse); + }); + }); + + group('ProfilePageViewModel', () { + test('is empty-safe when signed out and nothing is deployed', () { + final backend = MockCarpBackend(); + when(backend.user).thenReturn(null); + when(backend.uri).thenReturn(Uri(scheme: 'https', host: 'test.carp.dk')); + + final model = ProfilePageViewModel( + authService: AuthService(backend: backend), + studyService: StudyService(), + ); + + expect(model.username, ''); + expect(model.email, ''); + expect(model.studyId, ''); + expect(model.currentServer, 'https://test.carp.dk'); + }); + }); +}